From 1e23017bd951a3ea2a53ec4c72c3495a933a783c Mon Sep 17 00:00:00 2001 From: lexis Date: Tue, 26 May 2026 17:10:41 +0200 Subject: [PATCH 1/8] docs: add edit_schedule manager MCP tool to turn-loop.md (follow-up to #478) --- docs/turn-loop.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 52b32eb9..cd3e7ab5 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -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 From cfef348a7f29b801c74077ad3efb5aa2f21a1064 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 18:09:33 +0200 Subject: [PATCH 2/8] flow.html: add SCH3DUL3S to cross-page tab strip (closes #489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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). --- frontend/packages/dashboard/src/flow.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/packages/dashboard/src/flow.html b/frontend/packages/dashboard/src/flow.html index 195f1c14..407f3b85 100644 --- a/frontend/packages/dashboard/src/flow.html +++ b/frontend/packages/dashboard/src/flow.html @@ -29,6 +29,10 @@ ◆ SYST3M ◆ + + ◆ SCH3DUL3S ◆ + + From 15521179fcf0fabba508e64f6a9231b88190202d Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 16:39:36 +0200 Subject: [PATCH 3/8] hive-forge: collapse autogenerated lockfile hunks in diff (#222) --- hive-forge/src/verbs/diff.rs | 210 ++++++++++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 1 deletion(-) diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index aa64c5cd..f9e4b18a 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -1,4 +1,13 @@ //! `diff [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 one-line 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 — only the per-hunk content +//! is omitted. Pass `--full` to dump the unfiltered diff. use anyhow::Result; use clap::Args as ClapArgs; @@ -9,11 +18,210 @@ 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 +/// `[N lines of … omitted]` 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 collapse = false; + let mut in_body = false; + let mut omitted = 0usize; + + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("diff --git ") { + // New file section — flush prior collapse counter. + if collapse && omitted > 0 { + out.push_str(&placeholder(omitted)); + } + let path = parse_diff_git_path(rest); + collapse = path.as_deref().is_some_and(is_autogenerated); + in_body = false; + omitted = 0; + out.push_str(line); + out.push('\n'); + continue; + } + // First `@@` marks the boundary between file header and + // hunk content; everything from this point is suppressed + // when `collapse` is on. + if !in_body && line.starts_with("@@") { + in_body = true; + } + if collapse && in_body { + omitted += 1; + continue; + } + out.push_str(line); + out.push('\n'); + } + if collapse && omitted > 0 { + out.push_str(&placeholder(omitted)); + } + out +} + +fn placeholder(n: usize) -> String { + format!("[{n} lines of autogenerated content omitted; pass --full to view]\n") +} + +/// `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. +fn parse_diff_git_path(rest: &str) -> Option { + let token = rest.split_whitespace().nth(1)?; + let unquoted = token.trim_matches('"'); + Some(unquoted.strip_prefix("b/").unwrap_or(unquoted).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]]")); + assert!(out.contains("4 lines of autogenerated content omitted")); + // 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("4 lines of autogenerated content omitted")); + } + + #[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()) + ); + } +} From 67a3c9e2da8edcfa80d535eaa7ecfe4e4a0b821b Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 17:59:59 +0200 Subject: [PATCH 4/8] hive-forge: stat-style +N -M counts in collapsed-lockfile placeholder (#222) --- hive-forge/src/verbs/diff.rs | 139 +++++++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 32 deletions(-) diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index f9e4b18a..a9575ae3 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -2,12 +2,14 @@ //! //! By default the hunks for known autogenerated lockfiles //! (`flake.lock`, `Cargo.lock`, `package-lock.json`, …) are -//! collapsed to a one-line 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 — only the per-hunk content -//! is omitted. Pass `--full` to dump the unfiltered diff. +//! collapsed to a `[: +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; @@ -41,50 +43,88 @@ pub fn run(client: &Client, args: Args) -> Result<()> { /// 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 -/// `[N lines of … omitted]` placeholder before the next file. -/// Non-autogenerated files pass through unchanged. +/// 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 collapse = false; - let mut in_body = false; - let mut omitted = 0usize; + let mut state: Option = None; for line in diff.lines() { if let Some(rest) = line.strip_prefix("diff --git ") { // New file section — flush prior collapse counter. - if collapse && omitted > 0 { - out.push_str(&placeholder(omitted)); + if let Some(s) = state.take() { + out.push_str(&s.placeholder()); } let path = parse_diff_git_path(rest); - collapse = path.as_deref().is_some_and(is_autogenerated); - in_body = false; - omitted = 0; + 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; } - // First `@@` marks the boundary between file header and - // hunk content; everything from this point is suppressed - // when `collapse` is on. - if !in_body && line.starts_with("@@") { - in_body = true; - } - if collapse && in_body { - omitted += 1; - 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 collapse && omitted > 0 { - out.push_str(&placeholder(omitted)); + if let Some(s) = state.take() { + out.push_str(&s.placeholder()); } out } -fn placeholder(n: usize) -> String { - format!("[{n} lines of autogenerated content omitted; pass --full to view]\n") +/// 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` @@ -159,7 +199,13 @@ index 3333..4444 100644 assert!(out.contains("--- a/Cargo.lock")); assert!(out.contains("+++ b/Cargo.lock")); assert!(!out.contains("[[package]]")); - assert!(out.contains("4 lines of autogenerated content omitted")); + // 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\")")); @@ -206,7 +252,36 @@ index 1111..2222 100644 assert!(out.contains("+new")); assert!(out.contains("diff --git a/flake.lock")); assert!(!out.contains("lock-line-one")); - assert!(out.contains("4 lines of autogenerated content omitted")); + 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] From c279cbe85a86b01f3c1f71ada2763c9f0cf01fb5 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 19:16:51 +0200 Subject: [PATCH 5/8] hive-forge: parse git-quoted paths in diff parser (#222 followup) --- hive-forge/src/verbs/diff.rs | 117 ++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 3 deletions(-) diff --git a/hive-forge/src/verbs/diff.rs b/hive-forge/src/verbs/diff.rs index a9575ae3..e35793b9 100644 --- a/hive-forge/src/verbs/diff.rs +++ b/hive-forge/src/verbs/diff.rs @@ -130,10 +130,62 @@ impl CollapseState { /// `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 { - let token = rest.split_whitespace().nth(1)?; - let unquoted = token.trim_matches('"'); - Some(unquoted.strip_prefix("b/").unwrap_or(unquoted).to_owned()) + if let Some(after_open) = rest.strip_prefix('"') { + // Quoted form: `"a/" "b/"`. 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 @@ -299,4 +351,63 @@ index 1111..2222 100644 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}" + ); + } } From 501a86b725cdcdf23b7fd6b1d61f70a23f7588db Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 19:16:50 +0200 Subject: [PATCH 6/8] flow: body to its own full-width line under the meta chips (closes #485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/dashboard/src/dashboard.css | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 1c00ae6c..c7b53f9d 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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); } From 54563776227907788f4cc5e66b8059ee6c00f1a1 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 19:15:00 +0200 Subject: [PATCH 7/8] topology: operator-driven move-agent via set_parent (#486) --- hive-c0re/src/dashboard.rs | 51 +++++++++++++ hive-c0re/src/main.rs | 27 +++++++ hive-c0re/src/server.rs | 11 +++ hive-c0re/src/topology.rs | 147 +++++++++++++++++++++++++++++++++++++ hive-sh4re/src/lib.rs | 9 +++ 5 files changed, 245 insertions(+) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 0ff15d86..9124f92d 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -72,6 +72,7 @@ pub async fn serve(port: u16, coord: Arc) -> 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,17 @@ struct RequestSpawnForm { name: String, } +/// `POST /api/topology/set-parent` body. `child` is required. +/// `new_parent` may be: +/// - omitted entirely (form field absent) → no-op error, +/// - empty string → promote to root, +/// - non-empty → new parent's logical name. +#[derive(Deserialize)] +struct SetParentForm { + child: String, + new_parent: Option, +} + #[derive(Deserialize)] struct AnswerForm { answer: String, @@ -1846,6 +1858,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, + Form(form): Form, +) -> 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, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); state.coord.rebuild_queue.enqueue( diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 3b9f0e77..16f26205 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -102,6 +102,19 @@ 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`. + #[arg(long, conflicts_with = "root")] + parent: Option, + /// Promote `child` to root (no parent). + #[arg(long)] + root: bool, + }, } #[tokio::main] @@ -143,6 +156,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?, + ) + } } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 42ccc5fb..30be0364 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -185,6 +185,17 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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; diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index 527f2cdb..7830e9a9 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -123,6 +123,72 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap> 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>, + child: &str, + new_parent: Option<&str>, +) -> Result>, 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(¤t, 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> { + 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()); + } } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index e5411178..684a9496 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] From 3dbef583a7c28465d6237941ec8adc350ec02ecb Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 26 May 2026 19:21:57 +0200 Subject: [PATCH 8/8] topology: require --parent/--root explicitly + fix stale form doc (#492 nits) --- hive-c0re/src/dashboard.rs | 8 ++++++-- hive-c0re/src/main.rs | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 9124f92d..956f9ae9 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -852,9 +852,13 @@ struct RequestSpawnForm { /// `POST /api/topology/set-parent` body. `child` is required. /// `new_parent` may be: -/// - omitted entirely (form field absent) → no-op error, -/// - empty string → promote to root, +/// - 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, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 16f26205..9b00cc91 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -109,7 +109,11 @@ enum Cmd { SetParent { child: String, /// New parent agent name. Mutually exclusive with `--root`. - #[arg(long, conflicts_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, /// Promote `child` to root (no parent). #[arg(long)]