Compare commits

..
Author SHA1 Message Date
damocles
3dbef583a7 topology: require --parent/--root explicitly + fix stale form doc (#492 nits) 2026-05-26 19:30:46 +02:00
damocles
5456377622 topology: operator-driven move-agent via set_parent (#486) 2026-05-26 19:30:46 +02:00
iris
501a86b725 flow: body to its own full-width line under the meta chips (closes #485)
mara: "the 'timestamp from -> to' part is long enough to warrant
its own line. then the actual messages can be rendered (nearly)
full width."

Confirmed in the layout — previously `.live .msgrow .msg-body`
sat inline with `flex: 1 1 0`, eating whatever the chips left.
With a 14:23:42 timestamp + agent names + arrows that was ~30ch
of prefix; long bodies wrapped awkwardly.

One-line CSS fix: `flex: 1 1 100%` on `.msg-body` forces it to
wrap to its own flex line in the existing `flex-wrap: wrap`
container. Metadata chips stay on the row above; body takes the
full width down to the row's content edge.

`min-width: 0` retained so `word-break: break-word` keeps
working. Reply rows keep their `padding-left: 1.2em` border-left
indent — the body lands within that frame, matching the visual
"this is a reply" nesting.

Out of scope but worth knowing:
- consecutive-message grouping (one header per agent run) was
  the second design option I floated; happy to land it as a
  follow-up if reading still feels chatty.
- timestamp-on-hover was the third; skipping unless asked.
2026-05-26 19:30:28 +02:00
damocles
c279cbe85a hive-forge: parse git-quoted paths in diff parser (#222 followup) 2026-05-26 19:30:07 +02:00
damocles
67a3c9e2da hive-forge: stat-style +N -M counts in collapsed-lockfile placeholder (#222) 2026-05-26 19:30:07 +02:00
damocles
15521179fc hive-forge: collapse autogenerated lockfile hunks in diff (#222) 2026-05-26 19:30:07 +02:00
iris
cfef348a7f flow.html: add SCH3DUL3S to cross-page tab strip (closes #489)
mara: "why can this even be an issue?" — fair. The SCH3DUL3S
tab was added to index.html's chrome in #459 but flow.html's
parallel tab strip was never updated to match. The flow page
operator hit a dead end going back to the schedules pane.

One-line fix: copy the same `<a class="tab" href="/#schedules"
role="tab" data-tab="schedules">` link from index.html into
flow.html's chrome, positioned between SYST3M and FL0W to match
the dashboard ordering. Count pill stays hidden (flow.js
doesn't sync `schedulesState` — same reason SW4RM/Y3R C4LL/
SYST3M pills also stay hidden on this page).
2026-05-26 18:09:33 +02:00
lexis
1e23017bd9 docs: add edit_schedule manager MCP tool to turn-loop.md (follow-up to #478) 2026-05-26 17:31:18 +02:00
9 changed files with 672 additions and 5 deletions

View file

@ -342,6 +342,14 @@ meta's.
one-shot otherwise. Even self-targeted schedules go through
approval (use `remind` for unapproved self-wake). Long downtime
fires once per recurring row on resume (catch-up clamp).
- `edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)`
partial-update a schedule (#474/#478). Pass only the fields to
change; absent fields are left alone. `targets_add` / `targets_remove`
mutate the recipient list in the same transaction; re-adding a
previously-cancelled target drops its tombstone + history (fresh
start). Clearing a scalar (e.g. `interval_seconds: null`) flips
recurring→one-shot. Refuses cancelled rows. Same authorization as
`cancel_schedule`.
- `cancel_schedule(id, targets?)` — cancel a schedule. Omit
`targets` / pass empty to cancel the whole schedule; pass a list
to cancel just those recipients (auto-cancels when every target

View file

@ -1164,10 +1164,18 @@ summary:hover { color: var(--purple); }
text-indent: 0;
}
.live .msgrow .msg-body {
flex: 1 1 0;
/* min-width: 0 lets the body shrink below its longest token so
`word-break: break-word` actually kicks in instead of forcing
the whole flex line wider than the container. */
/* #485: body takes a full flex line of its own beneath the
metadata chips (ts / arrow / from / sep / to). Previously the
body sat inline with `flex: 1 1 0`, eating whatever the chips
left which on a long timestamp + agent names + arrows meant
the body started ~30ch in and wrapped awkwardly. Pushing
`flex-basis: 100%` forces the body to wrap to its own line in
the existing `flex-wrap: wrap` row, where it can use the full
width down to the row's content edge.
`min-width: 0` still applies so `word-break: break-word`
actually kicks in instead of forcing the row wider than its
container. */
flex: 1 1 100%;
min-width: 0;
}
.live .msgrow.sent .msg-arrow { color: var(--cyan); }

View file

@ -29,6 +29,10 @@
<span class="tab-label">◆ SYST3M ◆</span>
<span class="tab-count" id="tab-count-system" hidden></span>
</a>
<a class="tab" href="/#schedules" role="tab" data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
aria-current="page"
title="all-agents chat — you are here">

View file

@ -72,6 +72,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
.route("/retry-reminder/{id}", post(post_retry_reminder))
.route("/request-spawn", post(post_request_spawn))
.route("/api/topology/set-parent", post(post_set_parent))
.route("/op-send", post(post_op_send))
.route("/meta-update", post(post_meta_update))
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
@ -849,6 +850,21 @@ struct RequestSpawnForm {
name: String,
}
/// `POST /api/topology/set-parent` body. `child` is required.
/// `new_parent` may be:
/// - absent or empty / whitespace-only → promote to root,
/// - non-empty → new parent's logical name.
///
/// (The CLI surface gates "no parent specified" behind an explicit
/// `--root` flag for safety; the HTTP surface is permissive
/// because the dashboard form encodes "no value" as the empty
/// string for the optional radio-group input.)
#[derive(Deserialize)]
struct SetParentForm {
child: String,
new_parent: Option<String>,
}
#[derive(Deserialize)]
struct AnswerForm {
answer: String,
@ -1846,6 +1862,45 @@ async fn post_request_spawn(
}
}
/// `POST /api/topology/set-parent` — operator-driven parent move
/// (#486). Form fields: `child` (required, agent name), `new_parent`
/// (optional — empty / absent string ⇒ promote to root). Refuses
/// cycles, unknown agents, and reparenting the manager. On success
/// re-emits container snapshots so the dashboard tree repaints
/// without a refresh.
async fn post_set_parent(
State(state): State<AppState>,
Form(form): Form<SetParentForm>,
) -> Response {
let child = form.child.trim().to_owned();
if child.is_empty() {
return error_response("set-parent: `child` required");
}
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
// forms submit the empty string for a "no value" radio button,
// so this is the ergonomic encoding.
let new_parent = form
.new_parent
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
match crate::topology::set_parent(&child, new_parent.as_deref()) {
Ok(()) => {
tracing::info!(
child = %child,
new_parent = ?new_parent,
"operator: set-parent via dashboard"
);
// Topology drives ContainerView.parent; refresh the
// snapshot so connected viewers see the new tree.
state.coord.rescan_containers_and_emit().await;
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
}
}
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
let logical = strip_container_prefix(&name);
state.coord.rebuild_queue.enqueue(

View file

@ -102,6 +102,23 @@ enum Cmd {
Approve { id: i64 },
/// Deny a pending request by id.
Deny { id: i64 },
/// Move an agent in the topology tree (#486). Set `--parent` to
/// a new parent agent name; pass `--root` to promote the agent
/// to root (no parent). Refuses cycles, unknown agents, and
/// any attempt to reparent the manager.
SetParent {
child: String,
/// New parent agent name. Mutually exclusive with `--root`.
/// Exactly one of `--parent` / `--root` is required — clap
/// rejects both-absent calls so a fat-fingered
/// `hive-c0re set-parent alice` doesn't silently promote
/// alice to root (argus flag on PR #492).
#[arg(long, conflicts_with = "root", required_unless_present = "root")]
parent: Option<String>,
/// Promote `child` to root (no parent).
#[arg(long)]
root: bool,
},
}
#[tokio::main]
@ -143,6 +160,20 @@ async fn main() -> Result<()> {
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
}
Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { id }).await?),
Cmd::SetParent {
child,
parent,
root,
} => {
let new_parent = if root { None } else { parent };
render(
client::request(
&cli.socket,
HostRequest::SetParent { child, new_parent },
)
.await?,
)
}
}
}

View file

@ -185,6 +185,17 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
actions::deny(&coord, *id, None).await?;
HostResponse::success()
}
HostRequest::SetParent { child, new_parent } => {
tracing::info!(%child, ?new_parent, "set_parent");
crate::topology::set_parent(child, new_parent.as_deref())
.map_err(anyhow::Error::msg)?;
// ContainerView.parent is read from topology.json — a
// change here means every container row potentially
// moves in the dashboard tree. Rescan + diff-emit so
// open viewers repaint without polling.
coord.rescan_containers_and_emit().await;
HostResponse::success()
}
})
}
.await;

View file

@ -123,6 +123,72 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>>
out
}
/// Pure validation + apply for [`set_parent`]. Splits off so tests
/// can exercise the rules (cycle / unknown / manager-protect) on
/// an in-memory `BTreeMap` without touching the on-disk
/// `topology.json`. Returns either the post-move map (caller
/// writes it back) or a user-readable error string.
pub fn apply_set_parent(
topo: &BTreeMap<String, Option<String>>,
child: &str,
new_parent: Option<&str>,
) -> Result<BTreeMap<String, Option<String>>, String> {
if child == crate::lifecycle::MANAGER_NAME {
return Err("cannot reparent the manager — it is structurally root".to_owned());
}
if !topo.contains_key(child) {
return Err(format!("unknown agent: {child}"));
}
if let Some(p) = new_parent {
if !topo.contains_key(p) {
return Err(format!("unknown parent: {p}"));
}
if p == child {
return Err("an agent cannot be its own parent".to_owned());
}
// Cycle check: walk `p`'s ancestors in the EXISTING map. If
// we hit `child`, then making `child`'s parent = `p` would
// close the loop (child → … → p → child).
let mut cur = p.to_owned();
for _ in 0..32 {
if cur == child {
return Err(format!(
"cycle: {p} is in {child}'s subtree (would create a loop)"
));
}
let Some(next) = topo.get(&cur).cloned().flatten() else {
break;
};
cur = next;
}
}
let mut next = topo.clone();
next.insert(child.to_owned(), new_parent.map(str::to_owned));
Ok(next)
}
/// Operator-driven parent move (#486 / #487). Set `child`'s parent
/// to `new_parent` (or `None` to promote to root). See
/// [`apply_set_parent`] for the validation rules. The operator-set
/// parent sticks across `reconcile()` calls (which preserves
/// existing entries).
///
/// No bind-mount / container churn today — the hierarchy is
/// currently logical-only (see #486 comment 5042). Once
/// sub-manager bind mounts land alongside #361, the caller adds
/// an umount-old / mount-new / restart-cascade step on top.
pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> {
let current = read();
// Idempotent no-op fast path: skip the disk write when nothing
// changes. apply_set_parent still runs to surface validation
// errors (e.g. unknown child) so the caller gets a real signal.
let next = apply_set_parent(&current, child, new_parent)?;
if next == current {
return Ok(());
}
write(&next).map_err(|e| format!("write topology.json: {e}"))
}
/// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = manager, manager itself = root) for any
/// agent missing from the file; removes entries for agents no longer
@ -191,4 +257,85 @@ mod tests {
let seed = default_seed(&[]);
assert!(seed.is_empty());
}
fn topo_three_level() -> BTreeMap<String, Option<String>> {
let mut m = BTreeMap::new();
m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
m.insert(
"alice".to_owned(),
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
);
m.insert("bob".to_owned(), Some("alice".to_owned()));
m.insert("carol".to_owned(), Some("alice".to_owned()));
m
}
#[test]
fn apply_set_parent_promotes_to_root() {
let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap();
assert_eq!(next.get("alice"), Some(&None));
}
#[test]
fn apply_set_parent_reparents_under_sibling_subtree() {
// bob and carol both under alice; move carol under bob.
let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap();
assert_eq!(next.get("carol"), Some(&Some("bob".to_owned())));
}
#[test]
fn apply_set_parent_refuses_manager_move() {
let err =
apply_set_parent(&topo_three_level(), crate::lifecycle::MANAGER_NAME, None).unwrap_err();
assert!(err.contains("manager"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_unknown_child() {
let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err();
assert!(err.contains("unknown agent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_unknown_parent() {
let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err();
assert!(err.contains("unknown parent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_self() {
let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err();
assert!(err.contains("own parent"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_cycle() {
// bob's parent is alice; trying to make alice's parent =
// bob would close the loop alice → bob → alice.
let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
#[test]
fn apply_set_parent_refuses_deep_cycle() {
// Three-deep chain: manager → alice → bob → carol. Moving
// alice under carol would create the loop alice → carol → bob → alice.
let mut topo = BTreeMap::new();
topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None);
topo.insert(
"alice".to_owned(),
Some(crate::lifecycle::MANAGER_NAME.to_owned()),
);
topo.insert("bob".to_owned(), Some("alice".to_owned()));
topo.insert("carol".to_owned(), Some("bob".to_owned()));
let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err();
assert!(err.contains("cycle"), "err = {err}");
}
#[test]
fn apply_set_parent_is_idempotent_noop() {
// bob is already under alice — same value returned.
let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap();
assert_eq!(next, topo_three_level());
}
}

View file

@ -1,4 +1,15 @@
//! `diff <pr> [repo]` — print the unified diff for a PR.
//!
//! By default the hunks for known autogenerated lockfiles
//! (`flake.lock`, `Cargo.lock`, `package-lock.json`, …) are
//! collapsed to a `[<path>: +N -M (autogenerated; pass --full for
//! content)]` placeholder so a `flake.lock` rev bump doesn't drown
//! the human-authored changes in 5 000 lines of lock churn
//! (#222). The header (`diff --git`, `index`, `---`, `+++`,
//! rename / mode metadata) is preserved so the reader can still
//! see WHICH lockfiles changed; the +/- counts give a `diff
//! --stat`-style magnitude (excluding the `@@` hunk header).
//! Pass `--full` to dump the unfiltered diff.
use anyhow::Result;
use clap::Args as ClapArgs;
@ -9,11 +20,394 @@ use crate::client::Client;
pub struct Args {
/// PR number.
number: u64,
/// Print the unfiltered diff including autogenerated-file
/// hunks (`flake.lock`, `Cargo.lock`, etc.). Default is to
/// collapse those hunks to a placeholder so the human-authored
/// changes aren't drowned in lock churn.
#[arg(long)]
full: bool,
}
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
print!("{diff}");
let out = if args.full {
diff
} else {
collapse_autogenerated(&diff)
};
print!("{out}");
Ok(())
}
/// Walk a unified diff line-by-line. For each per-file section
/// whose target path matches a known autogenerated file
/// (`is_autogenerated`), drop every line from the first hunk
/// header (`@@`) onward and emit a single `diff --stat`-style
/// `[file.lock: +N -M (autogenerated, --full for content)]`
/// placeholder before the next file. Non-autogenerated files
/// pass through unchanged.
fn collapse_autogenerated(diff: &str) -> String {
let mut out = String::with_capacity(diff.len());
let mut state: Option<CollapseState> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
// New file section — flush prior collapse counter.
if let Some(s) = state.take() {
out.push_str(&s.placeholder());
}
let path = parse_diff_git_path(rest);
if let Some(p) = path.as_deref()
&& is_autogenerated(p)
{
state = Some(CollapseState::new(p.to_owned()));
}
out.push_str(line);
out.push('\n');
continue;
}
if let Some(s) = state.as_mut() {
// First `@@` marks the boundary between file header and
// hunk content; everything from this point is
// suppressed (and tallied) while `state` is Some.
if !s.in_body && line.starts_with("@@") {
s.in_body = true;
}
if s.in_body {
// The hunk header `@@` itself counts as a body line
// for the +/ tally only via its descendant content
// lines; skip it for the counters.
if !line.starts_with("@@") {
match line.as_bytes().first() {
Some(b'+') => s.added += 1,
Some(b'-') => s.removed += 1,
_ => {}
}
}
continue;
}
}
out.push_str(line);
out.push('\n');
}
if let Some(s) = state.take() {
out.push_str(&s.placeholder());
}
out
}
/// Per-file accumulator for the collapsed-hunk placeholder. Tracks
/// the file's display name plus `+`/`` line counts (excluding the
/// `@@` hunk header), so the placeholder shows operator-meaningful
/// magnitude instead of "N lines omitted" (which mixed context +
/// added + removed indistinguishably).
struct CollapseState {
path: String,
in_body: bool,
added: u32,
removed: u32,
}
impl CollapseState {
fn new(path: String) -> Self {
Self {
path,
in_body: false,
added: 0,
removed: 0,
}
}
fn placeholder(&self) -> String {
format!(
"[{}: +{} -{} (autogenerated; pass --full for content)]\n",
self.path, self.added, self.removed
)
}
}
/// `rest` is everything after `diff --git `, e.g. `a/foo b/foo`
/// or `"a/path with space" "b/path with space"`. Return the
/// post-rename (`b/`-side) path so renames report the new path.
///
/// Git uses C-style quoting (`\"`, `\\`, octal escapes) for paths
/// with spaces or unusual bytes — we don't unescape because we
/// only need the bytewise file name for the lockfile allowlist
/// match. We do parse the quoted-vs-unquoted form correctly so a
/// whitespace-containing path doesn't break `split_whitespace`.
fn parse_diff_git_path(rest: &str) -> Option<String> {
if let Some(after_open) = rest.strip_prefix('"') {
// Quoted form: `"a/<path>" "b/<path>"`. Find the closing
// quote of the a-side (skipping `\"` escapes so a path
// containing `"` doesn't terminate early).
let mut iter = after_open.char_indices();
let mut a_close = None;
loop {
let Some((i, c)) = iter.next() else { break };
if c == '\\' {
// Skip the next char — it's part of the escape.
iter.next();
continue;
}
if c == '"' {
a_close = Some(i);
break;
}
}
let a_close = a_close?;
// After the a-side closing quote, we expect `" "b/...`.
// `after_open` skipped the leading `"`, so the b-side
// starts in `after_open[a_close + 1..]` — strip leading
// space + opening quote + `b/`, then strip trailing `"`.
let after_a = after_open.get(a_close + 1..)?;
let after_a = after_a.strip_prefix(' ')?;
let b_inside = after_a.strip_prefix('"')?;
// Find b-side's closing quote with the same escape rule.
let mut iter = b_inside.char_indices();
let mut b_close = None;
loop {
let Some((i, c)) = iter.next() else { break };
if c == '\\' {
iter.next();
continue;
}
if c == '"' {
b_close = Some(i);
break;
}
}
let b_close = b_close?;
let b_path = b_inside.get(..b_close)?;
Some(b_path.strip_prefix("b/").unwrap_or(b_path).to_owned())
} else {
// Unquoted: paths have no whitespace, so `split_whitespace`
// gives exactly two tokens.
let token = rest.split_whitespace().nth(1)?;
Some(token.strip_prefix("b/").unwrap_or(token).to_owned())
}
}
/// File-name match against a small whitelist of well-known
/// machine-generated lockfiles. We deliberately don't pattern-match
/// extensions like `*.lock` because some real human-authored files
/// use that suffix (e.g. `keep.lock` config markers); explicit
/// listing avoids surprising the user.
fn is_autogenerated(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
matches!(
name,
"flake.lock"
| "Cargo.lock"
| "package-lock.json"
| "pnpm-lock.yaml"
| "yarn.lock"
| "Gemfile.lock"
| "poetry.lock"
| "Pipfile.lock"
| "composer.lock"
| "go.sum"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_known_lockfiles() {
assert!(is_autogenerated("flake.lock"));
assert!(is_autogenerated("a/flake.lock"));
assert!(is_autogenerated("hive-c0re/Cargo.lock"));
assert!(is_autogenerated("frontend/packages/dashboard/package-lock.json"));
assert!(!is_autogenerated("src/main.rs"));
assert!(!is_autogenerated("Cargo.toml"));
// Suffix-only files we deliberately don't match — keep
// `is_autogenerated` allowlist-driven, not pattern-driven.
assert!(!is_autogenerated("config/keep.lock"));
}
#[test]
fn collapse_replaces_lockfile_hunks_with_placeholder() {
let diff = "\
diff --git a/Cargo.lock b/Cargo.lock
index 1111..2222 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,3 +1,3 @@
[[package]]
-name = \"old\"
+name = \"new\"
diff --git a/src/main.rs b/src/main.rs
index 3333..4444 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,2 +1,2 @@
-fn main() {}
+fn main() { println!(\"hi\"); }
";
let out = collapse_autogenerated(diff);
assert!(out.contains("diff --git a/Cargo.lock"));
assert!(out.contains("--- a/Cargo.lock"));
assert!(out.contains("+++ b/Cargo.lock"));
assert!(!out.contains("[[package]]"));
// Stat-style placeholder: 1 added (`+name = "new"`), 1
// removed (`-name = "old"`); context line and `@@` header
// don't count.
assert!(
out.contains("[Cargo.lock: +1 -1"),
"expected stat placeholder, got: {out}"
);
// Non-lockfile file passes through untouched.
assert!(out.contains("fn main() {}"));
assert!(out.contains("println!(\"hi\")"));
}
#[test]
fn collapse_passes_normal_diff_through() {
let diff = "\
diff --git a/src/lib.rs b/src/lib.rs
index aaaa..bbbb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,1 +1,1 @@
-fn old() {}
+fn new() {}
";
let out = collapse_autogenerated(diff);
assert_eq!(out, diff);
}
#[test]
fn collapse_handles_trailing_lockfile() {
// Lockfile is the LAST file in the diff — exercises the
// post-loop flush path.
let diff = "\
diff --git a/README.md b/README.md
index aaaa..bbbb 100644
--- a/README.md
+++ b/README.md
@@ -1,1 +1,1 @@
-old
+new
diff --git a/flake.lock b/flake.lock
index 1111..2222 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,2 +1,2 @@
lock-line-one
-lock-line-two
+lock-line-two-bumped
";
let out = collapse_autogenerated(diff);
assert!(out.contains("-old"));
assert!(out.contains("+new"));
assert!(out.contains("diff --git a/flake.lock"));
assert!(!out.contains("lock-line-one"));
assert!(
out.contains("[flake.lock: +1 -1"),
"expected stat placeholder, got: {out}"
);
}
#[test]
fn collapse_counts_distinguish_added_and_removed() {
// Big asymmetric churn — flake.lock rev bump with several
// adds and one removal in the worker fields. Verifies
// we don't conflate `+`/`-` totals.
let diff = "\
diff --git a/flake.lock b/flake.lock
index 1111..2222 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,8 @@
{
- \"old\": 1
+ \"new\": 1,
+ \"another\": 2,
+ \"and\": 3,
+ \"more\": 4
}
";
let out = collapse_autogenerated(diff);
assert!(
out.contains("[flake.lock: +4 -1"),
"expected +4 -1, got: {out}"
);
}
#[test]
fn parse_diff_git_path_picks_b_side() {
assert_eq!(
parse_diff_git_path("a/foo b/foo"),
Some("foo".to_owned())
);
assert_eq!(
parse_diff_git_path("a/old.txt b/new.txt"),
Some("new.txt".to_owned())
);
assert_eq!(
parse_diff_git_path("a/dir/Cargo.lock b/dir/Cargo.lock"),
Some("dir/Cargo.lock".to_owned())
);
}
#[test]
fn parse_diff_git_path_handles_quoted_paths_with_spaces() {
// Git quotes paths with spaces / unusual chars per
// `core.quotePath`. The b-side path must survive intact.
assert_eq!(
parse_diff_git_path("\"a/foo bar\" \"b/foo bar\""),
Some("foo bar".to_owned())
);
// Rename with quoted sides.
assert_eq!(
parse_diff_git_path("\"a/old name\" \"b/new name\""),
Some("new name".to_owned())
);
// Lockfile inside a directory whose name has a space.
assert_eq!(
parse_diff_git_path("\"a/dir with space/Cargo.lock\" \"b/dir with space/Cargo.lock\""),
Some("dir with space/Cargo.lock".to_owned())
);
}
#[test]
fn parse_diff_git_path_handles_escaped_quote_inside_path() {
// Git escapes embedded `"` as `\"`. The closing-quote
// search must skip these so it doesn't terminate early.
// Path is literally `a/has"quote` / `b/has"quote`.
let rest = r#""a/has\"quote" "b/has\"quote""#;
assert_eq!(
parse_diff_git_path(rest),
// Bytewise: backslash + quote stay in the result
// because we don't unescape (allowlist match is by
// file name, never contains escapes).
Some(r#"has\"quote"#.to_owned())
);
}
#[test]
fn collapse_recognises_lockfile_in_quoted_path() {
// Path "a/odd dir/Cargo.lock" forces git to use the
// quoted form. We must still detect it as a lockfile.
let diff = "\
diff --git \"a/odd dir/Cargo.lock\" \"b/odd dir/Cargo.lock\"
index 1111..2222 100644
--- \"a/odd dir/Cargo.lock\"
+++ \"b/odd dir/Cargo.lock\"
@@ -1,1 +1,1 @@
-old
+new
";
let out = collapse_autogenerated(diff);
// Content must be suppressed (lockfile detected).
assert!(!out.contains("old\n"), "lock content leaked: {out}");
assert!(!out.contains("+new"), "lock content leaked: {out}");
// Placeholder uses the parsed b-side path (without quotes).
assert!(
out.contains("[odd dir/Cargo.lock: +1 -1"),
"expected stat placeholder for quoted-path lockfile, got: {out}"
);
}
}

View file

@ -46,6 +46,15 @@ pub enum HostRequest {
Approve { id: i64 },
/// Deny a pending request by id.
Deny { id: i64 },
/// Move an agent in the topology tree (#486). Pass `new_parent =
/// None` to promote the agent to root, or `Some(name)` to set a
/// new parent. Refuses cycles, unknown agents, and any attempt
/// to reparent the manager (which is structurally root).
/// Pure topology-json edit today; bind-mount work follows in #361.
SetParent {
child: String,
new_parent: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]