Compare commits
9 changed files with 5 additions and 672 deletions
|
|
@ -342,14 +342,6 @@ meta's.
|
||||||
one-shot otherwise. Even self-targeted schedules go through
|
one-shot otherwise. Even self-targeted schedules go through
|
||||||
approval (use `remind` for unapproved self-wake). Long downtime
|
approval (use `remind` for unapproved self-wake). Long downtime
|
||||||
fires once per recurring row on resume (catch-up clamp).
|
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
|
- `cancel_schedule(id, targets?)` — cancel a schedule. Omit
|
||||||
`targets` / pass empty to cancel the whole schedule; pass a list
|
`targets` / pass empty to cancel the whole schedule; pass a list
|
||||||
to cancel just those recipients (auto-cancels when every target
|
to cancel just those recipients (auto-cancels when every target
|
||||||
|
|
|
||||||
|
|
@ -1164,18 +1164,10 @@ summary:hover { color: var(--purple); }
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
.live .msgrow .msg-body {
|
.live .msgrow .msg-body {
|
||||||
/* #485: body takes a full flex line of its own beneath the
|
flex: 1 1 0;
|
||||||
metadata chips (ts / arrow / from / sep / to). Previously the
|
/* min-width: 0 lets the body shrink below its longest token so
|
||||||
body sat inline with `flex: 1 1 0`, eating whatever the chips
|
`word-break: break-word` actually kicks in instead of forcing
|
||||||
left — which on a long timestamp + agent names + arrows meant
|
the whole flex line wider than the container. */
|
||||||
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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
|
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@
|
||||||
<span class="tab-label">◆ SYST3M ◆</span>
|
<span class="tab-label">◆ SYST3M ◆</span>
|
||||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||||
</a>
|
</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"
|
<a class="tab tab-link active" id="tab-flow" href="/flow.html"
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
title="all-agents chat — you are here">
|
title="all-agents chat — you are here">
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/api/topology/set-parent", post(post_set_parent))
|
|
||||||
.route("/op-send", post(post_op_send))
|
.route("/op-send", post(post_op_send))
|
||||||
.route("/meta-update", post(post_meta_update))
|
.route("/meta-update", post(post_meta_update))
|
||||||
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
||||||
|
|
@ -850,21 +849,6 @@ struct RequestSpawnForm {
|
||||||
name: String,
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct AnswerForm {
|
struct AnswerForm {
|
||||||
answer: String,
|
answer: String,
|
||||||
|
|
@ -1862,45 +1846,6 @@ 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 {
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
state.coord.rebuild_queue.enqueue(
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
|
|
||||||
|
|
@ -102,23 +102,6 @@ enum Cmd {
|
||||||
Approve { id: i64 },
|
Approve { id: i64 },
|
||||||
/// Deny a pending request by id.
|
/// Deny a pending request by id.
|
||||||
Deny { id: i64 },
|
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]
|
#[tokio::main]
|
||||||
|
|
@ -160,20 +143,6 @@ async fn main() -> Result<()> {
|
||||||
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
|
render(client::request(&cli.socket, HostRequest::Approve { id }).await?)
|
||||||
}
|
}
|
||||||
Cmd::Deny { id } => render(client::request(&cli.socket, HostRequest::Deny { 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?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,17 +185,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
actions::deny(&coord, *id, None).await?;
|
actions::deny(&coord, *id, None).await?;
|
||||||
HostResponse::success()
|
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;
|
.await;
|
||||||
|
|
|
||||||
|
|
@ -123,72 +123,6 @@ pub fn default_seed(agent_names: &[String]) -> BTreeMap<String, Option<String>>
|
||||||
out
|
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(¤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
|
/// Reconcile `topology.json` against the current agent set. Adds an
|
||||||
/// entry (default: parent = manager, manager itself = root) for any
|
/// entry (default: parent = manager, manager itself = root) for any
|
||||||
/// agent missing from the file; removes entries for agents no longer
|
/// agent missing from the file; removes entries for agents no longer
|
||||||
|
|
@ -257,85 +191,4 @@ mod tests {
|
||||||
let seed = default_seed(&[]);
|
let seed = default_seed(&[]);
|
||||||
assert!(seed.is_empty());
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,4 @@
|
||||||
//! `diff <pr> [repo]` — print the unified diff for a PR.
|
//! `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 anyhow::Result;
|
||||||
use clap::Args as ClapArgs;
|
use clap::Args as ClapArgs;
|
||||||
|
|
@ -20,394 +9,11 @@ use crate::client::Client;
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// PR number.
|
/// PR number.
|
||||||
number: u64,
|
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<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
|
let diff = client.get_text(&format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain")?;
|
||||||
let out = if args.full {
|
print!("{diff}");
|
||||||
diff
|
|
||||||
} else {
|
|
||||||
collapse_autogenerated(&diff)
|
|
||||||
};
|
|
||||||
print!("{out}");
|
|
||||||
Ok(())
|
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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,6 @@ pub enum HostRequest {
|
||||||
Approve { id: i64 },
|
Approve { id: i64 },
|
||||||
/// Deny a pending request by id.
|
/// Deny a pending request by id.
|
||||||
Deny { id: i64 },
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue