Watch
0
0
Fork
You've already forked hyperhive
0

an archetype flow can be customized for one agent

This commit is contained in:
atlas 2026-11-11 21:40:51 +01:00 • committed by mara
commit 5443c1c9ba
7 changed files with 153 additions and 34 deletions

View file

@ -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 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 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. 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 - 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 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 cleanup fails, the controller logs the agent and leaves the keys; destroying
the agent again retries it. 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 - 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 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 after its flow goes: a later flow with the same archetype and id comes up

View file

@ -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 // 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 // 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. // 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); const taken = savedIds(view);
if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID }; if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID };
const archetype = (view.archetype_flows ?? []).find((a) => a.key === from); const archetype = (view.archetype_flows ?? []).find((a) => a.key === from);
// An archetype's "members" has no meaning in the operator's document: the // An archetype's "members" has no meaning in the operator's document. A copy
// copy sends to the one member there is, or to nobody until one is named. // 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 const source = archetype
? withMembersAs( ? withMembersAs(archetype.flow, who)
archetype.flow,
archetype.members.length === 1 ? archetype.members[0] : "",
)
: [...view.builtins, ...view.flows].find((f) => f.id === from); : [...view.builtins, ...view.flows].find((f) => f.id === from);
if (!source) return null; if (!source) return null;
return { return {
...duplicateFlow(source, `${source.name} (copy)`, taken), ...duplicateFlow(source, `${source.name} (${agent ?? "copy"})`, taken),
id: NEW_FLOW_ID, 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();
}

View file

@ -43,6 +43,7 @@ import {
saveOutcome, saveOutcome,
previewRequest, previewRequest,
saveRequest, saveRequest,
disabledOnSave,
seedFlow, seedFlow,
parentOf, parentOf,
withFlow, withFlow,
@ -70,7 +71,10 @@ export function FlowEditorPage({
isNew?: boolean; isNew?: boolean;
}) { }) {
const flowId = decodeURIComponent(params.flowId); 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 [, navigate] = useLocation();
const [view, setView] = useState<FlowsView | null>(null); const [view, setView] = useState<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(null); const [draft, setDraft] = useState<Flow | null>(null);
@ -108,7 +112,7 @@ export function FlowEditorPage({
setSaveError(null); setSaveError(null);
if (!keepDraft) { if (!keepDraft) {
const flow = isNew const flow = isNew
? seedFlow(next, from) ? seedFlow(next, from, agent)
: next.flows.find((f) => f.id === flowId); : next.flows.find((f) => f.id === flowId);
setSeed(isNew && flow ? structuredClone(flow) : null); setSeed(isNew && flow ? structuredClone(flow) : null);
setDraft(flow ? structuredClone(flow) : null); setDraft(flow ? structuredClone(flow) : null);
@ -120,7 +124,7 @@ export function FlowEditorPage({
); );
} }
}, },
[flowId, isNew, from], [flowId, isNew, from, agent],
); );
useEffect(() => { useEffect(() => {
void load(false); void load(false);
@ -150,16 +154,16 @@ export function FlowEditorPage({
// tab, following a link or pressing back. // tab, following a link or pressing back.
const leaving = useLeaveGuard(dirty); const leaving = useLeaveGuard(dirty);
// Send `flows` as the document; true when the controller took it. // Send `flows` and `disabled` as the document; true when the controller took it.
async function put(flows: Flow[]): Promise<boolean> { async function put(
flows: Flow[],
disabled: string[] = view?.disabled_builtins ?? [],
): Promise<boolean> {
if (!view) return false; if (!view) return false;
setBusy(true); setBusy(true);
setSaveError(null); setSaveError(null);
try { try {
const r = await fetch( const r = await fetch(FLOWS_URL, saveRequest(view, flows, disabled));
FLOWS_URL,
saveRequest(view, flows, view.disabled_builtins),
);
const outcome = saveOutcome(r.status); const outcome = saveOutcome(r.status);
if (outcome === "saved") { if (outcome === "saved") {
const next = (await r.json()) as FlowsView; const next = (await r.json()) as FlowsView;
@ -220,7 +224,12 @@ export function FlowEditorPage({
async function save() { async function save() {
if (!view || !draft) return; if (!view || !draft) return;
const flow = isNew ? savedAs(view, draft) : draft; 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(); leaving.release();
navigate(`/notifications/${encodeURIComponent(flow.id)}`, { navigate(`/notifications/${encodeURIComponent(flow.id)}`, {
replace: true, replace: true,

View file

@ -289,3 +289,9 @@
gap: 0.4em 1em; gap: 0.4em 1em;
margin-top: 0.4em; margin-top: 0.4em;
} }
.notify-flow-agent {
display: inline-flex;
align-items: center;
gap: 0.4em;
}

View file

@ -100,11 +100,14 @@ export function NotificationRouter() {
} }
// Open the editor on a draft: empty, or a copy of the flow `from`. // 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( navigate(
from === undefined from === undefined
? NEW_FLOW_PATH ? NEW_FLOW_PATH
: `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`, : `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}${
agent === undefined ? "" : `&agent=${encodeURIComponent(agent)}`
}`,
); );
return ( return (
@ -191,20 +194,24 @@ export function NotificationRouter() {
<div class="notify-flow-agents"> <div class="notify-flow-agents">
<span>agents:</span> <span>agents:</span>
{af.members.map((agent) => ( {af.members.map((agent) => (
<CheckboxField <span key={agent} class="notify-flow-agent">
key={agent} <CheckboxField
id={`archetype-${af.key}-${agent}`} id={`archetype-${af.key}-${agent}`}
label={agent} label={agent}
checked={ checked={
!view.disabled_builtins.includes( !view.disabled_builtins.includes(
memberKey(af.key, agent), memberKey(af.key, agent),
) )
} }
disabled={pending} disabled={pending}
onChange={(checked) => onChange={(checked) =>
setBuiltin(memberKey(af.key, agent), checked) setBuiltin(memberKey(af.key, agent), checked)
} }
/> />
<Button onClick={() => draftFrom(af.key, agent)}>
customize {agent}
</Button>
</span>
))} ))}
</div> </div>
<FlowGraph <FlowGraph

View file

@ -6,6 +6,7 @@ import {
blankNode, blankNode,
DEFAULT_LIMITS, DEFAULT_LIMITS,
depthOf, depthOf,
disabledOnSave,
duplicateFlow, duplicateFlow,
heightBelow, heightBelow,
isDirty, isDirty,
@ -428,6 +429,55 @@ test("seedFlow copies an archetype flow by its key, sending to its one member",
assert.ok(recipients(members).every((to) => to === "members")); assert.ok(recipients(members).every((to) => 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", () => { test("savedAs takes the id from the name, clear of every saved id", () => {
const view = { const view = {
revision: 1, revision: 1,

View file

@ -1869,4 +1869,25 @@ mod tests {
// A stale key leaves the built-ins as they were. // A stale key leaves the built-ins as they were.
assert_eq!(with(&[&flow]).active().0.len(), builtins().len()); 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());
}
} }