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