From 5443c1c9ba4ee086cfa3e262e142a3ad19a96a41 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 11 Nov 2026 21:40:51 +0100 Subject: [PATCH] an archetype flow can be customized for one agent --- docs/2-handbook/swarm/archetypes.md | 9 ++++ .../src/pages/notifications/FlowDraft.ts | 33 +++++++++--- .../pages/notifications/FlowEditorPage.tsx | 29 +++++++---- .../pages/notifications/NotificationsPage.css | 6 +++ .../pages/notifications/NotificationsPage.tsx | 39 +++++++++------ .../swarm-ui/test/flow-draft.test.mjs | 50 +++++++++++++++++++ swarm-wire/src/notify_flows.rs | 21 ++++++++ 7 files changed, 153 insertions(+), 34 deletions(-) diff --git a/docs/2-handbook/swarm/archetypes.md b/docs/2-handbook/swarm/archetypes.md index a4b7b72a9..6bbc370a0 100644 --- a/docs/2-handbook/swarm/archetypes.md +++ b/docs/2-handbook/swarm/archetypes.md @@ -162,10 +162,19 @@ within about 10 seconds, and the card has no `edit` button. just one, and to nobody until you name an agent when it has more. A custom flow can also request a review, which an archetype's flow can't. Switch the archetype's flow off to replace it with your copy. +- **customize** next to an agent opens the new-flow editor on a copy of the flow + for that agent alone: its recipient is the agent, and its name ends with the + agent's name. Saving it adds the agent's switch-off to the list in the same + write, so the agent gets its copy and not both, while the archetype's flow + keeps updating for everyone else. Deleting the copy later leaves the agent's + switch off; switch it on again to give the agent the archetype's flow. - Destroying an agent removes its per-agent switch-offs from the list, so a new agent of the same name starts with every archetype flow on. If that cleanup fails, the controller logs the agent and leaves the keys; destroying the agent again retries it. +- A customized copy is a custom flow addressed to the agent's name. It survives + the destroy like any custom flow, so delete it by hand if you reuse the name; + otherwise the new agent gets the copy and the archetype's flow. - A key for a flow or agent that no longer exists stays in the list and does nothing, so editing an archetype never breaks the stored flows. The key stays after its flow goes: a later flow with the same archetype and id comes up diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index f56715541..6261f1d38 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -370,21 +370,25 @@ function savedIds(view: FlowsView): string[] { // The draft the editor opens for a flow not saved yet: an empty one, or a copy // of the built-in, custom or archetype flow `from` (an archetype flow by its key). Nothing is sent until it is saved, so // the id is a placeholder (see `savedAs`). Null when `from` names no flow. -export function seedFlow(view: FlowsView, from: string | null): Flow | null { +export function seedFlow( + view: FlowsView, + from: string | null, + agent: string | null = null, +): Flow | null { const taken = savedIds(view); if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID }; const archetype = (view.archetype_flows ?? []).find((a) => a.key === from); - // An archetype's "members" has no meaning in the operator's document: the - // copy sends to the one member there is, or to nobody until one is named. + // An archetype's "members" has no meaning in the operator's document. A copy + // for one `agent` sends to it; a copy for all sends to the one member there + // is, or to nobody until one is named, which a save refuses. + const who = + agent ?? (archetype?.members.length === 1 ? archetype.members[0] : ""); const source = archetype - ? withMembersAs( - archetype.flow, - archetype.members.length === 1 ? archetype.members[0] : "", - ) + ? withMembersAs(archetype.flow, who) : [...view.builtins, ...view.flows].find((f) => f.id === from); if (!source) return null; return { - ...duplicateFlow(source, `${source.name} (copy)`, taken), + ...duplicateFlow(source, `${source.name} (${agent ?? "copy"})`, taken), id: NEW_FLOW_ID, }; } @@ -474,3 +478,16 @@ export function withMembersAs(flow: Flow, who: string): Flow { ), }; } + +// The disabled list a save of a draft goes out with: when the draft is the +// archetype flow `from` customized for `agent`, with that agent's switch-off +// added, so the agent gets the copy and not both. Otherwise `disabled` as is. +export function disabledOnSave( + disabled: string[], + from: string | null, + agent: string | null, +): string[] { + if (from === null || agent === null) return disabled; + const key = `${from}@${agent}`; + return disabled.includes(key) ? disabled : [...disabled, key].sort(); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index 262dd682e..8cba2829f 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -43,6 +43,7 @@ import { saveOutcome, previewRequest, saveRequest, + disabledOnSave, seedFlow, parentOf, withFlow, @@ -70,7 +71,10 @@ export function FlowEditorPage({ isNew?: boolean; }) { const flowId = decodeURIComponent(params.flowId); - const from = new URLSearchParams(useSearch()).get("from"); + const search = new URLSearchParams(useSearch()); + const from = search.get("from"); + // With `from` an archetype flow: the one agent this copy is for. + const agent = search.get("agent"); const [, navigate] = useLocation(); const [view, setView] = useState(null); const [draft, setDraft] = useState(null); @@ -108,7 +112,7 @@ export function FlowEditorPage({ setSaveError(null); if (!keepDraft) { const flow = isNew - ? seedFlow(next, from) + ? seedFlow(next, from, agent) : next.flows.find((f) => f.id === flowId); setSeed(isNew && flow ? structuredClone(flow) : null); setDraft(flow ? structuredClone(flow) : null); @@ -120,7 +124,7 @@ export function FlowEditorPage({ ); } }, - [flowId, isNew, from], + [flowId, isNew, from, agent], ); useEffect(() => { void load(false); @@ -150,16 +154,16 @@ export function FlowEditorPage({ // tab, following a link or pressing back. const leaving = useLeaveGuard(dirty); - // Send `flows` as the document; true when the controller took it. - async function put(flows: Flow[]): Promise { + // Send `flows` and `disabled` as the document; true when the controller took it. + async function put( + flows: Flow[], + disabled: string[] = view?.disabled_builtins ?? [], + ): Promise { if (!view) return false; setBusy(true); setSaveError(null); try { - const r = await fetch( - FLOWS_URL, - saveRequest(view, flows, view.disabled_builtins), - ); + const r = await fetch(FLOWS_URL, saveRequest(view, flows, disabled)); const outcome = saveOutcome(r.status); if (outcome === "saved") { const next = (await r.json()) as FlowsView; @@ -220,7 +224,12 @@ export function FlowEditorPage({ async function save() { if (!view || !draft) return; const flow = isNew ? savedAs(view, draft) : draft; - if (!(await put(withFlow(view.flows, flow))) || !isNew) return; + // The first save of a copy made for one agent also switches the + // archetype's flow off for it, in the same write. + const disabled = isNew + ? disabledOnSave(view.disabled_builtins, from, agent) + : view.disabled_builtins; + if (!(await put(withFlow(view.flows, flow), disabled)) || !isNew) return; leaving.release(); navigate(`/notifications/${encodeURIComponent(flow.id)}`, { replace: true, diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css index f9983e429..2ea764550 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css @@ -289,3 +289,9 @@ gap: 0.4em 1em; margin-top: 0.4em; } + +.notify-flow-agent { + display: inline-flex; + align-items: center; + gap: 0.4em; +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index acc00e223..e952b4051 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -100,11 +100,14 @@ export function NotificationRouter() { } // Open the editor on a draft: empty, or a copy of the flow `from`. - const draftFrom = (from?: string) => + // `agent`: a copy of the archetype flow `from` for that one agent. + const draftFrom = (from?: string, agent?: string) => navigate( from === undefined ? NEW_FLOW_PATH - : `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`, + : `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}${ + agent === undefined ? "" : `&agent=${encodeURIComponent(agent)}` + }`, ); return ( @@ -191,20 +194,24 @@ export function NotificationRouter() {
agents: {af.members.map((agent) => ( - - setBuiltin(memberKey(af.key, agent), checked) - } - /> + + + setBuiltin(memberKey(af.key, agent), checked) + } + /> + + ))}
to === "members")); }); +test("seedFlow customizes an archetype flow for one agent, and the save adds that agent's switch-off", () => { + const members = { + ...flow(), + id: "new-issues", + name: "New issues", + nodes: flow().nodes.map((n) => + n.kind === "todo" ? { ...n, to: "members" } : n, + ), + }; + const key = "archetype:triage/new-issues"; + const view = { + revision: 1, + disabled_builtins: ["closed"], + builtins: [], + flows: [], + archetype_flows: [ + { archetype: "triage", key, flow: members, members: ["alice", "bob"] }, + ], + }; + const todos = (f) => f.nodes.filter((n) => n.kind === "todo"); + const mine = seedFlow(view, key, "bob"); + assert.equal(mine.id, NEW_FLOW_ID); + assert.equal(mine.name, "New issues (bob)"); + assert.ok(todos(mine).length > 0, "control: it has a todo"); + assert.ok(todos(mine).every((n) => n.to.agent === "bob")); + // Without an agent, two members leave the recipient empty for the save to refuse. + assert.ok(todos(seedFlow(view, key)).every((n) => n.to.agent === "")); + // The save adds the one agent's key, sorted in, once; plain drafts add none. + assert.deepEqual(disabledOnSave(["closed"], key, "bob"), [ + "archetype:triage/new-issues@bob", + "closed", + ]); + assert.deepEqual( + disabledOnSave(["archetype:triage/new-issues@bob"], key, "bob"), + ["archetype:triage/new-issues@bob"], + ); + assert.deepEqual(disabledOnSave(["closed"], key, null), ["closed"]); + assert.deepEqual(disabledOnSave(["closed"], null, "bob"), ["closed"]); + const sent = JSON.parse( + saveRequest( + view, + [savedAs(view, mine)], + disabledOnSave(view.disabled_builtins, key, "bob"), + ).body, + ); + assert.deepEqual(sent.disabled_builtins, [key + "@bob", "closed"]); + assert.equal(sent.flows[0].id, "new-issues-bob"); +}); + test("savedAs takes the id from the name, clear of every saved id", () => { const view = { revision: 1, diff --git a/swarm-wire/src/notify_flows.rs b/swarm-wire/src/notify_flows.rs index 8c47a0991..00d7c988b 100644 --- a/swarm-wire/src/notify_flows.rs +++ b/swarm-wire/src/notify_flows.rs @@ -1869,4 +1869,25 @@ mod tests { // A stale key leaves the built-ins as they were. assert_eq!(with(&[&flow]).active().0.len(), builtins().len()); } + + /// What saving a customized archetype flow sends: a custom flow to one + /// agent and that agent's switch-off key, in one document. + #[test] + fn a_custom_flow_and_the_agents_switch_off_key_pass_the_check_together() { + let doc = Document { + disabled_builtins: vec![archetype_member_key("triage", "new-issues", "alice")], + flows: vec![sending_to( + "new-issues-alice", + Recipients::Agent("alice".to_owned()), + )], + ..Document::default() + }; + assert_eq!(doc.check(), Ok(())); + // The archetype's own `members` form is not what the copy carries. + let bad = Document { + flows: vec![sending_to("new-issues-alice", Recipients::Members)], + ..doc + }; + assert!(bad.check().is_err()); + } }