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
5 changed files with 153 additions and 34 deletions

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,