Watch
0
0
Fork
You've already forked hyperhive
0

archetype flows behave like the built-ins: switch off per archetype or per agent, board cards, duplicate

The document's disabled list takes archetype:<archetype>/<flow> (off for every member) and the same with @<agent> (off for one member); a key naming nothing is ignored. GET /api/notify/flows lists the archetype flows with their members, the board draws a card for each with a switch, a checkbox per agent and duplicate, and the dry run counts their hits. The flows keep following the archetype.
This commit is contained in:
atlas 2026-11-11 19:08:19 +01:00 • committed by mara
commit 68c24643f6
6 changed files with 908 additions and 102 deletions

View file

@ -368,12 +368,20 @@ 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 or custom flow `from`. 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.
export function seedFlow(view: FlowsView, from: string | null): Flow | null {
const taken = savedIds(view);
if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID };
const source = [...view.builtins, ...view.flows].find((f) => f.id === from);
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.
const source = archetype
? withMembersAs(
archetype.flow,
archetype.members.length === 1 ? archetype.members[0] : "",
)
: [...view.builtins, ...view.flows].find((f) => f.id === from);
if (!source) return null;
return {
...duplicateFlow(source, `${source.name} (copy)`, taken),
@ -453,3 +461,16 @@ export function saveOutcome(status: number): SaveOutcome {
if (status === 400) return "invalid";
return "error";
}
// `flow` with each todo to "members" (an archetype's) sent to the agent `who`
// instead, which is what the editor's recipients can hold.
export function withMembersAs(flow: Flow, who: string): Flow {
return {
...flow,
nodes: flow.nodes.map((n) =>
n.kind === "todo" && (n.to as unknown) === "members"
? { ...n, to: { agent: who } }
: n,
),
};
}

View file

@ -36,10 +36,29 @@ export interface FlowsView {
disabled_builtins: string[];
builtins: Flow[];
flows: Flow[];
// The flows archetypes carry. Absent from a controller that predates them.
archetype_flows?: ArchetypeFlow[];
// Absent from a controller that predates it.
schema?: FlowSchema;
}
// A flow an archetype carries, with the agents of that archetype. `key` is
// what names it in `disabled_builtins` (off for every member); `key@agent`
// is off for that one member. In `flow` a todo's recipient may be the string
// "members", which the editor's `Recipients` does not model: use
// `withMembersAs` (FlowDraft.ts) before drawing or editing it.
export interface ArchetypeFlow {
archetype: string;
key: string;
flow: Flow;
members: string[];
}
// The `disabled_builtins` entry that switches `key` off for one member.
export function memberKey(key: string, agent: string): string {
return `${key}@${agent}`;
}
export function describeRecipients(to: Recipients): string {
return "field" in to ? `{${to.field}}` : to.agent;
}

View file

@ -281,3 +281,11 @@
.notify-failed {
color: var(--red);
}
.notify-flow-agents {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4em 1em;
margin-top: 0.4em;
}

View file

@ -4,7 +4,9 @@
// every few seconds.
//
// A flow draws as its graph, read-only. A built-in flow has a switch and can
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
// be duplicated; an archetype's flow has the same switch, one for each of its
// agents, and can be duplicated but not edited, since it follows its archetype;
// a custom flow is edited on its own page (<FlowEditorPage>).
// A new flow and a duplicate both open that page on a draft: nothing is created
// until it is saved there.
import { useState } from "preact/hooks";
@ -22,9 +24,9 @@ import {
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Deliveries, DeliveriesSkeleton } from "./Deliveries.js";
import { FlowListSkeleton } from "./FlowListSkeleton.js";
import { NEW_FLOW_PATH } from "./FlowDraft.js";
import { NEW_FLOW_PATH, withMembersAs } from "./FlowDraft.js";
import { FlowGraph } from "./FlowGraph.js";
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
import { memberKey, withBuiltin, type FlowsView } from "./FlowTypes.js";
import {
DELIVERIES_URL,
DRY_RUN_URL,
@ -72,6 +74,8 @@ export function NotificationRouter() {
// Switch one built-in on or off: the whole document goes back with the
// revision this page read (0 for none), so a change made elsewhere since is refused
// rather than overwritten.
// The switches of built-ins and of archetype flows are one list, see
// `ArchetypeFlow`.
async function setBuiltin(id: string, enabled: boolean) {
if (!view) return;
setPending(true);
@ -161,6 +165,55 @@ export function NotificationRouter() {
</Card>
);
})}
{(view.archetype_flows ?? []).length > 0 ? (
<h3>archetype flows</h3>
) : null}
{(view.archetype_flows ?? []).map((af) => {
const on = !view.disabled_builtins.includes(af.key);
return (
<Card key={af.key} class={on ? "" : "notify-off"}>
<div class="notify-flow-head">
<span class="notify-flow-name">{af.flow.name}</span>
<span class="notify-flow-state">
archetype {af.archetype}
</span>
<CheckboxField
id={`archetype-${af.key}`}
label={on ? "on" : "off"}
checked={on}
disabled={pending}
onChange={(checked) => setBuiltin(af.key, checked)}
/>
<ActionRow class="notify-flow-actions">
<Button onClick={() => draftFrom(af.key)}>duplicate</Button>
</ActionRow>
</div>
<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)
}
/>
))}
</div>
<FlowGraph
flow={withMembersAs(af.flow, `members of ${af.archetype}`)}
hits={hitsOf(shown, af.key)}
/>
</Card>
);
})}
<h3>custom flows</h3>
{view.flows.length === 0 ? <p>no custom flows</p> : null}
{view.flows.map((flow) => (

View file

@ -387,6 +387,47 @@ test("seedFlow opens an empty or copied draft under the placeholder id", () => {
assert.equal(view.flows.length, 1, "nothing is added to the view");
});
test("seedFlow copies an archetype flow by its key, sending to its one member", () => {
const members = {
...flow(),
id: "new-issues",
name: "New issues",
nodes: flow().nodes.map((n) =>
n.kind === "todo" ? { ...n, to: "members" } : n,
),
};
const archetype = (agents) => ({
revision: 1,
disabled_builtins: [],
builtins: [],
flows: [],
archetype_flows: [
{
archetype: "triage",
key: "archetype:triage/new-issues",
flow: members,
members: agents,
},
],
});
const recipients = (f) =>
f.nodes.filter((n) => n.kind === "todo").map((n) => n.to);
const one = seedFlow(archetype(["alice"]), "archetype:triage/new-issues");
assert.equal(one.id, NEW_FLOW_ID);
assert.equal(one.name, "New issues (copy)");
assert.ok(recipients(one).length > 0, "control: the flow has a todo");
assert.ok(recipients(one).every((to) => to.agent === "alice"));
// Two members: the operator names the agent in the editor.
const two = seedFlow(
archetype(["alice", "bob"]),
"archetype:triage/new-issues",
);
assert.ok(recipients(two).every((to) => to.agent === ""));
assert.equal(seedFlow(archetype(["alice"]), "archetype:triage/gone"), null);
// The archetype's own flow keeps "members".
assert.ok(recipients(members).every((to) => to === "members"));
});
test("savedAs takes the id from the name, clear of every saved id", () => {
const view = {
revision: 1,

View file

@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
describeNode,
memberKey,
kindValues,
ordinal,
withBuiltin,
@ -114,3 +115,15 @@ test("describeNode says what a reviewer request and an unknown kind are", () =>
);
assert.match(describeNode({ id: "x", kind: "future" }), /future/);
});
test("memberKey names a flow's switch for one agent", () => {
assert.equal(
memberKey("archetype:triage/new-issues", "alice"),
"archetype:triage/new-issues@alice",
);
// The per-agent key joins the same list as a built-in's id.
assert.deepEqual(
withBuiltin(["closed"], memberKey("archetype:t/f", "a"), false),
["archetype:t/f@a", "closed"],
);
});