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
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

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
// 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();
}

View file

@ -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<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(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<boolean> {
// Send `flows` and `disabled` as the document; true when the controller took it.
async function put(
flows: Flow[],
disabled: string[] = view?.disabled_builtins ?? [],
): Promise<boolean> {
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,

View file

@ -289,3 +289,9 @@
gap: 0.4em 1em;
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`.
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() {
<div class="notify-flow-agents">
<span>agents:</span>
{af.members.map((agent) => (
<CheckboxField
key={agent}
id={`archetype-${af.key}-${agent}`}
label={agent}
checked={
!view.disabled_builtins.includes(
memberKey(af.key, agent),
)
}
disabled={pending}
onChange={(checked) =>
setBuiltin(memberKey(af.key, agent), checked)
}
/>
<span key={agent} class="notify-flow-agent">
<CheckboxField
id={`archetype-${af.key}-${agent}`}
label={agent}
checked={
!view.disabled_builtins.includes(
memberKey(af.key, agent),
)
}
disabled={pending}
onChange={(checked) =>
setBuiltin(memberKey(af.key, agent), checked)
}
/>
<Button onClick={() => draftFrom(af.key, agent)}>
customize {agent}
</Button>
</span>
))}
</div>
<FlowGraph

View file

@ -6,6 +6,7 @@ import {
blankNode,
DEFAULT_LIMITS,
depthOf,
disabledOnSave,
duplicateFlow,
heightBelow,
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"));
});
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,

View file

@ -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());
}
}