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:
parent
1efbb72fdd
commit
68c24643f6
6 changed files with 908 additions and 102 deletions
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue