From 9efd3fe177eac7136285893fa4cfb3bcfc0e961e Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 08:42:05 -0600
Subject: [PATCH 01/20] docs(workflows): establish capability and UI handoff
contract
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
docs/workflows.md | 85 +++++++++++++++++++++
package.json | 3 +-
pnpm-lock.yaml | 34 ++++++---
src/features/workflows/types.ts | 130 ++++++++++++++++++++++++++++++++
4 files changed, 240 insertions(+), 12 deletions(-)
create mode 100644 docs/workflows.md
create mode 100644 src/features/workflows/types.ts
diff --git a/docs/workflows.md b/docs/workflows.md
new file mode 100644
index 00000000..6d94e7b4
--- /dev/null
+++ b/docs/workflows.md
@@ -0,0 +1,85 @@
+# Workflows capability and bundled UI handoff
+
+Status: implementation contract, not a shipped or live-validated feature.
+Wes approved session FOUNDATION wiring and workflow-only relay save/delete repair
+on 2026-09-12 (Buzz event `768f982eb3295e1bcbc69614d61b38deb3dc608e62864a5c58df9d8453f7fac9`).
+
+## Ownership and base
+
+App baseline: `17f90c18fff6b86bc029e710401fb2b60bc385ea`.
+Brain owns `src/features/workflows/**`, relay transport/outbox/session integration,
+`dev/` host adapters, catalogs, dependencies, this document, and the separate
+legacy relay repair. Pinky owns `src/bundled/workflows/**` and adjacent UI/helper
+tests in a separate worktree. No shared live-tree mutations.
+
+The type contract is [types.ts](../src/features/workflows/types.ts).
+UI imports that capability by type and receives the captured session's
+`workflows` property once integration lands; build/test UI compositions against
+explicit fixture capabilities meanwhile. Do not implement an alternate host in
+bundled code. Use the existing `pages` + `relay` injection and
+`useRelayConnection`, not new plugins/author API or a router.
+
+## First complete UI slice
+
+- Channel-scoped **Saved configurations** list and raw YAML detail. Definitions
+ expose canonical author/channel/UUID, signed event revision, timestamp and raw
+ text. They do not fabricate runtime status or execution authority. `partial`
+ signals the bounded query limit, not lifecycle verification.
+- New drafts explicitly disabled. Message/reaction triggers; Send Message/Delay.
+ Reuse pure legacy YAML/form/duration/schedule/condition/template helpers and
+ their tests selectively. Use shared design-system components and read its
+ stewardship instructions before composition. Preserve unsupported YAML and
+ incomplete header edits; no rewrite merely on opening or changing selection.
+- Save with existing definition for compare-and-swap; failed/conflicted/unknown
+ writes retain drafts. Show accepted delivery separately from domain success.
+ Delete requires confirmation and host availability; an old relay's generic
+ accepted kind-5 receipt does not prove deletion.
+- Manual run and bounded real run/trace next, before advanced editor polish.
+ Only returned run ID correlates a run; never choose newest run as recovery.
+ Approval rows are read-only; their hash is not an approval token.
+- Form and YAML share restrictions. Webhook create/transition cannot bypass the
+ one-time-secret capability. Never write secret into drafts, logs, ordinary
+ journal, messages or clipboard automatically. Secret reveal is optional until
+ its display lifecycle is tested; otherwise keep those saves unavailable.
+
+## Read and write lifetime
+
+Each read view starts idle; the UI subscribes and calls refresh on interest,
+then disposes on unmount/selection change. It owns no background poll unless an
+active-run detail is visible; pause on hidden and terminal state. Runs return one
+20-row page with an opaque exact `(before,beforeId)` pair; dispose the prior page
+before opening another. Never reconstruct the cursor from second-granularity rows.
+Failures show retry and do not become empty, deleted or permission-denied guesses.
+
+Views and operations purge before access-change callbacks. Remount by captured
+scope + generation. Draft keys use stable community/viewer/coordinate, never
+just channel/UUID; generation is not a durable key. Disable/unmount releases UI
+interest but neither disables server workflows nor discards accepted intent.
+
+`save`, `delete`, `trigger` return local signed-intent IDs synchronously; subscribe
+to operations for outcome. The shared outbox journals intent/signature before
+send. Event echo cannot cancel the only result-bearing receipt. Restored signed
+intent never auto-runs; retry uses exactly that event ID and does not promise
+recovery of lost secret/run receipts. Unknown outcome is actionable information,
+not permission to automatically submit a new trigger. Bounded ephemeral result
+state is separate from ordinary delivery persistence.
+
+## Relay compatibility and unresolved historical state
+
+The approved forward repair includes workflow save runtime/event transaction
+consistency and timestamp-ordered atomic deletion using coordinate serialization.
+It does not authorize generic command refactoring, blind old-delete replay,
+destructive historical reconciliation, or a new lifecycle endpoint. Historical
+configuration rows remain unverified; authorized runs reads prove presence only
+at the read. A repaired forward-delete capability must be positively identified
+before Delete is enabled. The exact host compatibility signal is implemented and
+tested with that relay change; do not infer it from version strings or kind lists.
+
+## Acceptance
+
+Production-seam receipt ordering/duplicate/unknown tests; revision and permission
+checks; access purge and A->B->A fencing; persistence and secret isolation; real
+DB rollback/stale/concurrent deletion tests in the relay; UI keyboard/focus,
+dirty-close/conflict drafts, narrow layouts and YAML ownership tests. Fixture
+feedback can precede final package gates. Live identity/signing/destructive
+workflow trials require a separate consented test, not this implementation approval.
diff --git a/package.json b/package.json
index ec19fb25..42cb1655 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,8 @@
"react-markdown": "10.1.0",
"remark-breaks": "4.0.0",
"remark-gfm": "4.0.1",
- "virtua": "0.51.0"
+ "virtua": "0.51.0",
+ "yaml": "2.8.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.12",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bad39e68..b6ca9e40 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
virtua:
specifier: 0.51.0
version: 0.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ yaml:
+ specifier: 2.8.3
+ version: 2.8.3
devDependencies:
'@biomejs/biome':
specifier: 2.5.12
@@ -98,7 +101,7 @@ importers:
version: 19.2.7(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 6.1.1
- version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))
+ version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))
postcss:
specifier: 8.5.28
version: 8.5.28
@@ -113,10 +116,10 @@ importers:
version: 7.16.0
vite:
specifier: 8.2.2
- version: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
+ version: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)
vitest:
specifier: 4.1.11
- version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))
+ version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))
packages:
@@ -184,6 +187,7 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.5.12':
resolution: {integrity: sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==}
@@ -1587,6 +1591,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ yaml@2.8.3:
+ resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -2036,10 +2045,10 @@ snapshots:
'@ungap/structured-clone@1.4.0': {}
- '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))':
+ '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)
'@vitest/expect@4.1.11':
dependencies:
@@ -2050,13 +2059,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))':
+ '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -2951,7 +2960,7 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0):
+ vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -2962,11 +2971,12 @@ snapshots:
'@types/node': 24.13.3
fsevents: 2.3.3
jiti: 2.7.0
+ yaml: 2.8.3
- vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)):
+ vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.11
- '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0))
+ '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -2983,7 +2993,7 @@ snapshots:
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
@@ -2995,4 +3005,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ yaml@2.8.3: {}
+
zwitch@2.0.4: {}
diff --git a/src/features/workflows/types.ts b/src/features/workflows/types.ts
new file mode 100644
index 00000000..37cf0711
--- /dev/null
+++ b/src/features/workflows/types.ts
@@ -0,0 +1,130 @@
+import type { Delivery } from "../relay/outbox";
+
+/** Canonical signed coordinate, bound to the owning community session. */
+export type WorkflowReference = Readonly<{
+ id: string;
+ owner: string;
+ channelId: string;
+}>;
+
+/** Configuration intent, not proof of runtime presence, enabled state or authority. */
+export type WorkflowDefinition = WorkflowReference &
+ Readonly<{
+ revision: string;
+ createdAt: number;
+ yaml: string;
+ }>;
+
+export type WorkflowDefinitions = Readonly<{
+ items: readonly WorkflowDefinition[];
+ /** A bounded configuration snapshot is not a complete runtime inventory. */
+ partial: boolean;
+}>;
+
+/** Views belong to a captured session; dispose only releases this read interest. */
+export interface WorkflowView {
+ snapshot(): Readonly<{
+ status: "idle" | "loading" | "ready" | "error" | "unavailable";
+ data: T;
+ error?: string;
+ }>;
+ subscribe(listener: () => void): () => void;
+ refresh(): Promise;
+ dispose(): void;
+}
+
+/** Preserve the relay cursor pair verbatim, including fractional timestamp precision. */
+export type WorkflowRunCursor = Readonly<{ before: string; beforeId: string }>;
+export type WorkflowRun = Readonly<{
+ id: string;
+ workflowId: string;
+ status:
+ | "pending"
+ | "running"
+ | "waiting_approval"
+ | "completed"
+ | "failed"
+ | "cancelled";
+ currentStep: number;
+ trace: readonly unknown[];
+ startedAt: number | null;
+ completedAt: number | null;
+ createdAt: number;
+ errorCode: string | null;
+ errorMessage: string | null;
+}>;
+export type WorkflowRunPage = Readonly<{
+ runs: readonly WorkflowRun[];
+ next: WorkflowRunCursor | null;
+}>;
+export type WorkflowApproval = Readonly<{
+ /** Hashed reference, NEVER an actionable approval token. */
+ reference: string;
+ runId: string;
+ stepId: string;
+ status: "pending" | "granted" | "denied" | "expired";
+ note: string | null;
+ createdAt: number;
+}>;
+
+/** Delivery evidence and domain outcome are deliberately separate. No secret here. */
+export type WorkflowOperation = Readonly<{
+ eventId: string;
+ workflow: WorkflowReference;
+ action: "save" | "delete" | "trigger";
+ delivery: Delivery;
+ outcome: "pending" | "succeeded" | "rejected" | "unknown";
+ error?: string;
+ runId?: string;
+ /** A secret can be consumed once, never journaled or automatically copied. */
+ secretAvailable: boolean;
+}>;
+
+/** Host availability, NOT per-row permission; the relay remains authoritative. */
+export type WorkflowAvailability = Readonly<{
+ definitions: boolean;
+ history: boolean;
+ save: boolean;
+ trigger: boolean;
+ /** Requires the repaired relay lifecycle contract, not merely kind-5 support. */
+ delete: boolean;
+ webhookSecrets: boolean;
+}>;
+
+/** Bundled UI contract. No socket, signer, arbitrary HTTP, scheduler or approval writes. */
+export interface WorkflowCapability {
+ readonly availability: WorkflowAvailability;
+ definitions(channelId: string): WorkflowView;
+ runs(
+ workflow: WorkflowReference,
+ cursor?: WorkflowRunCursor,
+ ): WorkflowView;
+ approvals(
+ workflow: WorkflowReference,
+ runId: string,
+ ): WorkflowView;
+ /** Synchronous local intent ID; follow operations for delivery and domain completion.
+ * Existing definitions preserve author/channel/id and use their signed revision.
+ * New definitions get a new UUID. YAML mode uses the same host restrictions.
+ */
+ save(
+ input: Readonly<{
+ channelId: string;
+ yaml: string;
+ existing?: WorkflowDefinition;
+ }>,
+ ): string;
+ delete(workflow: WorkflowDefinition): string;
+ trigger(workflow: WorkflowDefinition): string;
+ operations: Readonly<{
+ snapshot(): readonly WorkflowOperation[];
+ subscribe(listener: () => void): () => void;
+ /** Exact signed replay only; never creates a new event or recovers a lost receipt. */
+ retry(eventId: string): void;
+ dismiss(eventId: string): Promise;
+ }>;
+ /** Consume in response to explicit reveal; caller must clear display on scope/access loss.
+ * Returns undefined after consumption, clear-cache, access revocation or disposal.
+ */
+ takeWebhookSecret(eventId: string): string | undefined;
+}
From ced79b9b9416b8475dc94219c33e4505a6fc9376 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 08:57:00 -0600
Subject: [PATCH 02/20] feat(workflows): add session history and receipt-safe
command foundation
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
dev/relay-broker.mjs | 73 +++-
dev/workflow-broker.test.mjs | 223 ++++++++++
docs/workflows.md | 26 ++
src/features/relay/outbox-receipts.test.ts | 231 +++++++++++
src/features/relay/outbox.ts | 114 +++--
src/features/relay/receipt.ts | 21 +
src/features/relay/session.ts | 35 +-
src/features/relay/transport.ts | 75 +++-
src/features/workflows/capability.test.ts | 208 ++++++++++
src/features/workflows/capability.ts | 460 +++++++++++++++++++++
src/features/workflows/host.ts | 13 +
src/features/workflows/http.test.ts | 94 +++++
src/features/workflows/http.ts | 97 +++++
src/features/workflows/protocol.test.ts | 126 ++++++
src/features/workflows/protocol.ts | 286 +++++++++++++
src/features/workflows/session.test.ts | 195 +++++++++
16 files changed, 2220 insertions(+), 57 deletions(-)
create mode 100644 dev/workflow-broker.test.mjs
create mode 100644 src/features/relay/outbox-receipts.test.ts
create mode 100644 src/features/relay/receipt.ts
create mode 100644 src/features/workflows/capability.test.ts
create mode 100644 src/features/workflows/capability.ts
create mode 100644 src/features/workflows/host.ts
create mode 100644 src/features/workflows/http.test.ts
create mode 100644 src/features/workflows/http.ts
create mode 100644 src/features/workflows/protocol.test.ts
create mode 100644 src/features/workflows/protocol.ts
create mode 100644 src/features/workflows/session.test.ts
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index 9ec90c45..f2d15596 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -1,3 +1,8 @@
+import {
+ workflowReadPath,
+ workflowReadText,
+} from "../src/features/workflows/http.ts";
+import { readReceiptText } from "../src/features/relay/receipt.ts";
import {
decodeReadState,
signReadState,
@@ -507,6 +512,7 @@ export function relayBrokerPlugin({
...(await getAuthority(relay)),
relayUrl: relay,
writeKinds: [9],
+ workflowReads: true,
sidebarPreferences: true,
readState: true,
agentLibrary: true,
@@ -697,6 +703,8 @@ export function relayBrokerPlugin({
"/api/relay/claim",
"/api/relay/accept-policy",
"/api/relay/gifs",
+ "/api/relay/workflow-runs",
+ "/api/relay/workflow-approvals",
].includes(route) ||
req.method !== "POST"
)
@@ -713,6 +721,25 @@ export function relayBrokerPlugin({
} catch {
return json(res, 400, { error: "Filter body is not JSON" });
}
+ let workflowPath;
+ if (
+ [
+ "/api/relay/workflow-runs",
+ "/api/relay/workflow-approvals",
+ ].includes(route)
+ ) {
+ try {
+ workflowPath = workflowReadPath(
+ route.slice("/api/relay/".length),
+ filters,
+ );
+ } catch {
+ return json(res, 400, {
+ error: "Invalid workflow read",
+ sent: false,
+ });
+ }
+ }
const profile = route === "/api/relay/profile";
const claim = route === "/api/relay/claim";
const policy = route === "/api/relay/accept-policy";
@@ -836,6 +863,7 @@ export function relayBrokerPlugin({
!claim &&
!policy &&
!gifs &&
+ !workflowPath &&
!readPublishing &&
!snapshot &&
!validFilters(filters)
@@ -844,15 +872,18 @@ export function relayBrokerPlugin({
const gifSearchPath = gifs ? await getGifSearchPath(relay) : null;
if (gifs && !gifSearchPath)
return json(res, 404, { error: "GIF search is unavailable" });
- const upstreamPath = gifs
- ? gifSearchPath
- : profile || publishing || readPublishing
- ? "/events"
- : claim
- ? "/api/invites/claim"
- : policy
- ? "/api/invites/accept-policy"
- : "/query";
+ const upstreamPath =
+ workflowPath ??
+ (gifs
+ ? gifSearchPath
+ : profile || publishing || readPublishing
+ ? "/events"
+ : claim
+ ? "/api/invites/claim"
+ : policy
+ ? "/api/invites/accept-policy"
+ : "/query");
+ const method = workflowPath ? "GET" : "POST";
if (inflight >= MAX_INFLIGHT)
return json(res, 429, {
error: "Query concurrency limit",
@@ -861,7 +892,7 @@ export function relayBrokerPlugin({
inflight++;
try {
const lane = admissions(relay, viewer).api;
- const body = JSON.stringify(filters);
+ const body = workflowPath ? undefined : JSON.stringify(filters);
// A browser that gave up (the client's ten-second deadline) must also release
// this upstream request, or hung requests exhaust the inflight budget.
const cancel = new AbortController();
@@ -891,11 +922,15 @@ export function relayBrokerPlugin({
content: "",
tags: [
["u", `${relay}${upstreamPath}`],
- ["method", "POST"],
- [
- "payload",
- createHash("sha256").update(body).digest("hex"),
- ],
+ ["method", method],
+ ...(body === undefined
+ ? []
+ : [
+ [
+ "payload",
+ createHash("sha256").update(body).digest("hex"),
+ ],
+ ]),
["nonce", randomBytes(16).toString("hex")],
],
},
@@ -907,7 +942,7 @@ export function relayBrokerPlugin({
connectsBefore = upstream.connects();
upstreamStart = performance.now();
return fetchUpstream(`${relay}${upstreamPath}`, {
- method: "POST",
+ method,
headers: {
"Content-Type": "application/json",
Authorization:
@@ -933,7 +968,11 @@ export function relayBrokerPlugin({
const text =
snapshot && response.ok
? await readSnapshotText(response)
- : await response.text();
+ : workflowPath && response.ok
+ ? await workflowReadText(response)
+ : publishing && response.ok
+ ? await readReceiptText(response)
+ : await response.text();
// The relay's own service time separates server work from network time.
const relayMs = Number(
response.headers.get("x-envoy-upstream-service-time"),
diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs
new file mode 100644
index 00000000..702dbb1f
--- /dev/null
+++ b/dev/workflow-broker.test.mjs
@@ -0,0 +1,223 @@
+import { createServer } from "node:http";
+import { setTimeout as delay } from "node:timers/promises";
+import { expect, it } from "vitest";
+import { getPublicKey, verifyEvent } from "nostr-tools";
+import { relayBrokerPlugin } from "./relay-broker.mjs";
+import { connectBrokerTransport } from "../src/features/relay/transport.ts";
+import { WORKFLOW_READ_BYTES } from "../src/features/workflows/http.ts";
+
+const id = "11111111-1111-4111-8111-111111111111";
+const runId = "22222222-2222-4222-8222-222222222222";
+const cursor = { before: "2026-09-12T14:44:19.123456+00:00", beforeId: runId };
+async function harness(
+ respond = () => Response.json({ runs: [], next: null }),
+) {
+ const key = new Uint8Array(32);
+ key[31] = 8;
+ const viewer = getPublicKey(key),
+ calls = [],
+ logs = [];
+ let handler;
+ const server = createServer((req, res) => {
+ if (!req.headers.origin) req.headers.origin = `http://${req.headers.host}`;
+ handler(req, res);
+ });
+ await relayBrokerPlugin({
+ relayUrl: "https://a.workflow.test",
+ communityAliases: JSON.stringify({ secondary: "https://b.workflow.test" }),
+ identity: () => key,
+ authority: async () => ({ relayAuthor: viewer }),
+ upstreamFetch: async (url, init) => {
+ const auth = JSON.parse(
+ Buffer.from(init.headers.Authorization.slice(6), "base64").toString(),
+ );
+ expect(verifyEvent(auth)).toBe(true);
+ expect(auth.pubkey).toBe(viewer);
+ const call = { url: String(url), init, auth };
+ calls.push(call);
+ return respond(call);
+ },
+ }).configureServer({
+ httpServer: server,
+ config: {
+ logger: {
+ info() {},
+ error(text) {
+ logs.push(text);
+ },
+ },
+ },
+ middlewares: {
+ use(fn) {
+ handler = fn;
+ },
+ },
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ return {
+ base,
+ calls,
+ logs,
+ post: (route, body, headers = {}) =>
+ fetch(`${base}/api/relay/${route}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify(body),
+ }),
+ async close() {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(resolve));
+ },
+ };
+}
+const signal = () => new AbortController().signal;
+it("real broker scoped history signs exact GET path/cursor and captured principal without startup reads or workflow writes", async () => {
+ const h = await harness(({ url }) =>
+ Response.json(
+ url.endsWith("approvals") ? { approvals: [] } : { runs: [], next: null },
+ ),
+ );
+ try {
+ const first = await connectBrokerTransport(h.base);
+ const other = await connectBrokerTransport(h.base, undefined, "secondary");
+ expect(h.calls).toHaveLength(0);
+ expect(first.writer.kinds).toEqual([9]);
+ expect(first.workflows.lifecycleVersion).toBeUndefined();
+ await first.workflows.runs(id, cursor, signal());
+ await other.workflows.approvals(id, runId, signal());
+ await first.workflows.runs(id, undefined, signal());
+ expect(h.calls.map((call) => call.url)).toEqual([
+ `https://a.workflow.test/workflows/${id}/runs?limit=20&before=2026-09-12T14%3A44%3A19.123456%2B00%3A00&before_id=${runId}`,
+ `https://b.workflow.test/workflows/${id}/runs/${runId}/approvals`,
+ `https://a.workflow.test/workflows/${id}/runs?limit=20`,
+ ]);
+ for (const { url, init, auth } of h.calls) {
+ expect(init.method).toBe("GET");
+ expect(init.body).toBeUndefined();
+ expect(init.redirect).toBe("error");
+ expect(auth.tags).toContainEqual(["u", url]);
+ expect(auth.tags).toContainEqual(["method", "GET"]);
+ expect(auth.tags.some(([name]) => name === "payload")).toBe(false);
+ expect(auth.tags.some(([name]) => name === "nonce")).toBe(true);
+ }
+ expect(new Set(h.calls.map(({ auth }) => auth.id)).size).toBe(3);
+ } finally {
+ await h.close();
+ }
+});
+it("broker rejects arbitrary targets, cursors, limits and untrusted origin before upstream dispatch", async () => {
+ const h = await harness();
+ try {
+ for (const body of [
+ null,
+ { id: "../secret" },
+ { id, limit: 100 },
+ { id, url: "https://evil.test" },
+ { id, cursor: { before: cursor.before } },
+ { id, cursor: { ...cursor, before: "not a date" } },
+ { id, cursor: { ...cursor, beforeId: "../" } },
+ { id, cursor: null },
+ ]) {
+ expect((await h.post("workflow-runs", body)).status).toBe(400);
+ }
+ expect(
+ (await h.post("workflow-approvals", { id, runId: "../" })).status,
+ ).toBe(400);
+ expect(
+ (await h.post("workflow-runs", { id }, { Origin: "https://evil.test" }))
+ .status,
+ ).toBe(403);
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+});
+it("workflow quota gates the existing query lane while another community remains independent", async () => {
+ const h = await harness(({ url }) =>
+ url.startsWith("https://a.")
+ ? Response.json(
+ { error: "rate-limited: quota exceeded; retry in 0s" },
+ { status: 429 },
+ )
+ : Response.json([]),
+ );
+ try {
+ const transport = await connectBrokerTransport(h.base);
+ await expect(
+ transport.workflows.runs(id, undefined, signal()),
+ ).rejects.toMatchObject({ status: 429, retryAfterMs: 1000 });
+ await expect(
+ transport.query([{ kinds: [0], limit: 1 }]),
+ ).rejects.toMatchObject({ status: 429 });
+ expect(h.calls).toHaveLength(1);
+ const other = await connectBrokerTransport(h.base, undefined, "secondary");
+ await other.query([{ kinds: [0], limit: 1 }]);
+ expect(h.calls).toHaveLength(2);
+ } finally {
+ await h.close();
+ }
+});
+it("workflow 404 remains unavailable, over-budget body is cancelled without leaking bytes", async () => {
+ let large = false,
+ cancelled = false;
+ const h = await harness(() =>
+ large
+ ? new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ new TextEncoder().encode(
+ "PRIVATE".repeat(Math.ceil(WORKFLOW_READ_BYTES / 7) + 1),
+ ),
+ );
+ },
+ cancel() {
+ cancelled = true;
+ },
+ }),
+ )
+ : Response.json({ error: "PRIVATE missing workflow" }, { status: 404 }),
+ );
+ try {
+ const transport = await connectBrokerTransport(h.base);
+ await expect(
+ transport.workflows.runs(id, undefined, signal()),
+ ).rejects.toMatchObject({ status: 404 });
+ large = true;
+ await expect(
+ transport.workflows.runs(id, undefined, signal()),
+ ).rejects.toThrow();
+ expect(cancelled).toBe(true);
+ expect(h.logs.join(" ")).not.toContain("PRIVATE");
+ } finally {
+ await h.close();
+ }
+});
+it("closing workflow interest aborts the actual broker upstream request", async () => {
+ const h = await harness(
+ ({ init }) =>
+ new Promise((_resolve, reject) => {
+ init.signal.addEventListener(
+ "abort",
+ () => reject(init.signal.reason),
+ { once: true },
+ );
+ }),
+ );
+ try {
+ const transport = await connectBrokerTransport(h.base),
+ cancel = new AbortController();
+ const pending = transport.workflows.runs(id, undefined, cancel.signal);
+ const rejection = expect(pending).rejects.toThrow();
+ for (let i = 0; i < 100 && !h.calls.length; i++) await delay(5);
+ expect(h.calls).toHaveLength(1);
+ cancel.abort();
+ await rejection;
+ for (let i = 0; i < 100 && !h.calls[0].init.signal.aborted; i++)
+ await delay(5);
+ expect(h.calls[0].init.signal.aborted).toBe(true);
+ } finally {
+ await h.close();
+ }
+});
diff --git a/docs/workflows.md b/docs/workflows.md
index 6d94e7b4..0463a817 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -83,3 +83,29 @@ DB rollback/stale/concurrent deletion tests in the relay; UI keyboard/focus,
dirty-close/conflict drafts, narrow layouts and YAML ownership tests. Fixture
feedback can precede final package gates. Live identity/signing/destructive
workflow trials require a separate consented test, not this implementation approval.
+
+## Host checkpoint (2026-09-12)
+
+The app implements lazy structured history reads in both signed and dev-broker
+hosts. The broker exposes only `workflow-runs` / `workflow-approvals` POST inputs,
+constructs fixed upstream GET routes, preserves the exact cursor pair, and shares
+the captured principal's API admission. History capability means the adapter
+exists, not that an older relay serves the endpoint: failures remain explicit.
+Responses are stream-bounded to 1 MiB before parsing; command receipts to 16 KiB.
+
+All workflow writes remain unavailable in real host connections at this
+checkpoint. Fixtures may supply `WorkflowHost.lifecycleVersion = 1` to exercise
+commands. No real host advertises that evidence until the forward relay repair
+and compatibility handshake are implemented and reviewed. Receipt tests do not
+prove a deployed database transaction.
+
+Revocation puts existing and newly opened denied views in `unavailable`, purges
+all data before callbacks, and cancels late results. A regrant requires explicit
+fresh interest; it never revives an old snapshot. UI must not reopen a recovered
+private draft from an unavailable view. Secret reveal remains disabled.
+
+A save receipt does not populate a read view. Refresh explicitly and match both
+`operation.workflow` and `operation.eventId === definition.revision` before
+permitting resave. A coordinate-only old/concurrent head is not save readback.
+Generic kind-5 deletes retain their previous behavior: only workflow-coordinate
+kind-5 operations use workflow validation and receipt semantics.
diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts
new file mode 100644
index 00000000..a463a10e
--- /dev/null
+++ b/src/features/relay/outbox-receipts.test.ts
@@ -0,0 +1,231 @@
+import { assert, afterEach, expect, it, vi } from "vitest";
+import { createOutbox, PublishRejected, type OutgoingEvent } from "./outbox";
+import type { RelayEvent } from "./events";
+import { flush, keypair, signed } from "./testing";
+import { readReceiptText } from "./receipt";
+
+const owners: ReturnType[] = [];
+afterEach(() => {
+ for (const owner of owners.splice(0)) owner.dispose();
+ vi.useRealTimers();
+});
+function setup(timeoutMs = 10000) {
+ const key = keypair();
+ let saved: readonly OutgoingEvent[] = [];
+ let settle: ((message: string) => void) | undefined;
+ let reject: ((error: Error) => void) | undefined;
+ let published: RelayEvent | undefined;
+ let signal: AbortSignal | undefined;
+ const onReceipt = vi.fn();
+ const sign = vi.fn(async (template) =>
+ signed(key, structuredClone(template)),
+ );
+ const publish = vi.fn((event: RelayEvent, abort: AbortSignal) => {
+ expect(saved.find((row) => row.signed?.id === event.id)).toBeDefined();
+ published = event;
+ signal = abort;
+ return new Promise((resolve, fail) => {
+ settle = resolve;
+ reject = fail;
+ });
+ });
+ const storage = {
+ load: () => saved,
+ save: (rows: readonly OutgoingEvent[]) => {
+ saved = structuredClone(rows);
+ },
+ };
+ const owner = createOutbox(key.pubkey, { sign, publish }, storage, {
+ needsReceipt: (event) => [30620, 46020].includes(event.kind),
+ onReceipt,
+ timeoutMs,
+ });
+ owners.push(owner);
+ return {
+ ...owner,
+ key,
+ storage,
+ sign,
+ publish,
+ onReceipt,
+ saved: () => saved,
+ published: () => {
+ assert.exists(published);
+ return published;
+ },
+ signal: () => {
+ assert.exists(signal);
+ return signal;
+ },
+ settle: (message: string) => {
+ assert.exists(settle);
+ settle(message);
+ },
+ reject: (error: Error) => {
+ assert.exists(reject);
+ reject(error);
+ },
+ send: (kind = 30620) =>
+ owner.outbox.send({
+ kind,
+ content: "disabled workflow",
+ tags: [
+ ["h", "channel"],
+ ["d", "workflow"],
+ ],
+ }),
+ };
+}
+it("preserves command receipt after echo, without persisting secret or aborting publication", async () => {
+ const h = setup();
+ const id = h.send();
+ await flush();
+ h.observe([h.published()]);
+ expect(h.signal().aborted).toBe(false);
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("seen");
+ expect(h.onReceipt).not.toHaveBeenCalled();
+ h.settle('response:{"webhook_secret":"one-time-fixture"}');
+ await flush();
+ expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith(
+ h.published(),
+ 'response:{"webhook_secret":"one-time-fixture"}',
+ );
+ expect(h.outbox.snapshot()).toEqual([]);
+ expect(h.local.snapshot()[0]).toMatchObject({
+ event: { id },
+ delivery: "seen",
+ });
+ expect(JSON.stringify(h.saved())).not.toContain("one-time-fixture");
+});
+it("receipt before echo is retained once and message echo keeps its existing cancellation behavior", async () => {
+ const h = setup();
+ h.send();
+ await flush();
+ h.settle("response:{}");
+ await flush();
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("accepted");
+ h.observe([h.published()]);
+ expect(h.onReceipt).toHaveBeenCalledTimes(1);
+ const message = setup();
+ message.send(9);
+ await flush();
+ message.observe([message.published()]);
+ expect(message.signal().aborted).toBe(true);
+ message.settle("irrelevant");
+ await flush();
+ expect(message.onReceipt).not.toHaveBeenCalled();
+});
+it("echo plus lost receipt remains seen but settles command result as unavailable", async () => {
+ const h = setup();
+ h.send();
+ await flush();
+ h.observe([h.published()]);
+ h.reject(new Error("connection lost"));
+ await flush();
+ expect(h.local.snapshot()[0]?.delivery).toBe("seen");
+ expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith(h.published(), undefined);
+});
+it("receipt waits are bounded and late results after disposal never publish", async () => {
+ vi.useFakeTimers();
+ const h = setup(100);
+ h.send();
+ await vi.advanceTimersByTimeAsync(0);
+ h.observe([h.published()]);
+ await vi.advanceTimersByTimeAsync(101);
+ expect(h.signal().aborted).toBe(true);
+ expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith(h.published(), undefined);
+ const late = setup();
+ late.send();
+ await vi.advanceTimersByTimeAsync(0);
+ late.dispose();
+ late.settle("secret");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(late.onReceipt).not.toHaveBeenCalled();
+});
+it("restores signed command intent without sending, exact retry receives only duplicate outcome", async () => {
+ const h = setup();
+ const id = h.send(46020);
+ await flush();
+ h.dispose();
+ const receipt = vi.fn();
+ const publish = vi.fn(
+ async (_event: RelayEvent) => "duplicate: already processed",
+ );
+ const sign = vi.fn(async () => {
+ throw new Error("must not sign again");
+ });
+ const restored = createOutbox(h.key.pubkey, { sign, publish }, h.storage, {
+ needsReceipt: (event) => event.kind === 46020,
+ onReceipt: receipt,
+ });
+ owners.push(restored);
+ await restored.ready;
+ expect(publish).not.toHaveBeenCalled();
+ restored.outbox.retry(id);
+ await flush();
+ expect(sign).not.toHaveBeenCalled();
+ expect(publish.mock.calls[0]?.[0]).toEqual(h.published());
+ expect(receipt).toHaveBeenCalledExactlyOnceWith(
+ h.published(),
+ "duplicate: already processed",
+ );
+});
+it("bounds receipt bytes while streaming before JSON decoding", async () => {
+ const cancel = vi.fn();
+ const body = new ReadableStream({
+ start(c) {
+ c.enqueue(new Uint8Array(17000));
+ },
+ cancel,
+ });
+ await expect(readReceiptText(new Response(body))).rejects.toThrow(
+ "size limit",
+ );
+ expect(cancel).toHaveBeenCalled();
+ expect(await readReceiptText(new Response("response:{}"))).toBe(
+ "response:{}",
+ );
+});
+
+it("seen commands can retry the exact signed event and dismiss without losing observation evidence", async () => {
+ const h = setup();
+ const id = h.send();
+ await flush();
+ h.observe([h.published()]);
+ h.reject(new Error("lost"));
+ await flush();
+ const original = h.published();
+ expect(h.outbox.snapshot()).toEqual([]);
+ h.outbox.retry(id);
+ await flush();
+ expect(h.sign).toHaveBeenCalledTimes(1);
+ expect(h.publish).toHaveBeenCalledTimes(2);
+ expect(h.published()).toEqual(original);
+ h.reject(new PublishRejected("response:{secret:PRIVATE}"));
+ await flush();
+ expect(h.local.snapshot()[0]?.delivery).toBe("seen");
+ expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
+ await h.outbox.dismiss(id);
+ expect(h.local.snapshot()).toEqual([]);
+ expect(h.saved()).toEqual([]);
+});
+it("rejection text never journals command secrets; successful seen retry stays seen", async () => {
+ const h = setup();
+ const id = h.send();
+ await flush();
+ h.reject(new PublishRejected("PRIVATE"));
+ await flush();
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed");
+ expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
+ h.outbox.retry(id);
+ await flush();
+ h.observe([h.published()]);
+ h.settle("response:{}");
+ await flush();
+ h.outbox.retry(id);
+ await flush();
+ h.settle("duplicate:");
+ await flush();
+ expect(h.local.snapshot()[0]?.delivery).toBe("seen");
+ expect(h.outbox.snapshot()).toEqual([]);
+});
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 24ff813e..90a40b50 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -45,8 +45,13 @@ export function createOutbox(
profiling = createRelayProfiler(),
notifyListener = (listener: () => void) => listener(),
preparePublish,
+ needsReceipt = () => false,
+ onReceipt = (_event: EventData, _message: string | undefined) => {},
}: {
timeoutMs?: number;
+ /** Commands await their receipt even after a verified echo. Never persisted. */
+ needsReceipt?: (event: EventData) => boolean;
+ onReceipt?: (event: EventData, message: string | undefined) => void;
onAccepted?: (event: RelayEvent) => void;
profiling?: RelayProfiler;
notifyListener?: (listener: () => void) => void;
@@ -58,6 +63,7 @@ export function createOutbox(
) => Promise<(() => void) | undefined>;
} = {},
) {
+ const awaitsReceipt = needsReceipt;
let snapshot: readonly OutgoingEvent[] = Object.freeze([]);
let visible: readonly OutgoingEvent[] = snapshot;
let finalSnapshot: readonly OutgoingEvent[] | undefined;
@@ -223,17 +229,20 @@ export function createOutbox(
clearTimeout(attempt.timer);
attempts.delete(id);
const item = find(id);
- if (!closed && item)
+ if (!closed && item) {
+ if (awaitsReceipt(item.event)) onReceipt(item.event, undefined);
saveStatus({
...item,
delivery: failedDelivery(attempt),
error: error.message,
});
+ }
}
}
function failedDelivery(attempt: Attempt): Delivery {
return attempt.previousDelivery === "unknown" ||
- attempt.previousDelivery === "accepted"
+ attempt.previousDelivery === "accepted" ||
+ attempt.previousDelivery === "seen"
? attempt.previousDelivery
: "failed";
}
@@ -308,38 +317,55 @@ export function createOutbox(
// transport publisher crosses that boundary, including for signed retries.
if (closed || !find(id)) return;
signal.throwIfAborted();
- await profiling.measureAsync("send.publish", id, () => {
+ const receipt = await profiling.measureAsync("send.publish", id, () => {
check?.();
publishing = true;
return Promise.race([writer.publish(signed, signal), aborted]);
});
if (closed || signal.aborted) return;
+ if (awaitsReceipt(signed))
+ onReceipt(signed, typeof receipt === "string" ? receipt : undefined);
const latest = find(id);
if (latest)
- saveStatus({ ...latest, delivery: "accepted", error: undefined });
+ saveStatus({
+ ...latest,
+ delivery:
+ latest.delivery === "seen" || attempt.previousDelivery === "seen"
+ ? "seen"
+ : "accepted",
+ error: undefined,
+ });
onAccepted(signed);
} catch (error) {
const latest = find(id);
// A verified observation ends the attempt even if its HTTP ACK never arrives.
total(!closed && !latest ? "ok" : "error");
if (closed) return;
+ if (latest && awaitsReceipt(latest.event))
+ onReceipt(latest.signed ?? latest.event, undefined);
if (latest)
saveStatus({
...latest,
delivery:
- attempt.previousDelivery === "accepted"
- ? "accepted"
- : publishing && !(error instanceof PublishRejected)
- ? "unknown"
- : failedDelivery(attempt),
- error: `${
- attempt.previousDelivery === "unknown" ||
- attempt.previousDelivery === "accepted"
- ? error instanceof PublishRejected
- ? "Retry blocked: "
- : "Retry failed: "
- : ""
- }${error instanceof Error ? error.message : String(error)}`,
+ latest.delivery === "seen" || attempt.previousDelivery === "seen"
+ ? "seen"
+ : attempt.previousDelivery === "accepted"
+ ? "accepted"
+ : publishing && !(error instanceof PublishRejected)
+ ? "unknown"
+ : failedDelivery(attempt),
+ error: awaitsReceipt(latest.event)
+ ? publishing && !(error instanceof PublishRejected)
+ ? "Workflow delivery could not be confirmed; retain this operation to retry."
+ : "Workflow command rejected; retain the draft and refresh before retrying."
+ : `${
+ attempt.previousDelivery === "unknown" ||
+ attempt.previousDelivery === "accepted"
+ ? error instanceof PublishRejected
+ ? "Retry blocked: "
+ : "Retry failed: "
+ : ""
+ }${error instanceof Error ? error.message : String(error)}`,
});
if (publishing && latest?.signed && !(error instanceof PublishRejected))
onAccepted(latest.signed);
@@ -347,6 +373,15 @@ export function createOutbox(
total();
clearTimeout(attempt.timer);
if (attempts.get(id) === attempt) attempts.delete(id);
+ const observed = find(id);
+ if (!closed && observed?.delivery === "seen") {
+ completed.set(id, observed);
+ snapshot = Object.freeze(
+ snapshot.filter((item) => item.event.id !== id),
+ );
+ notify();
+ void persist(id).catch(() => {});
+ }
if (!closed)
for (const queued of snapshot)
if (queued.delivery === "sending") void deliver(queued.event.id);
@@ -414,15 +449,28 @@ export function createOutbox(
return event.id;
},
retry(id: string) {
- const item = find(id);
+ const retained = completed.peek(id);
+ const item =
+ find(id) ??
+ (retained && awaitsReceipt(retained.event) ? retained : undefined);
if (!closed && item && !attempts.has(id)) {
+ if (!find(id)) {
+ if (snapshot.length >= MAX_PENDING)
+ throw new Error("Too many outstanding operations");
+ completed.delete(id);
+ snapshot = Object.freeze([...snapshot, item]);
+ }
replace({ ...item, delivery: "sending", error: undefined });
schedule(id, undefined, item.delivery);
}
},
async dismiss(id: string) {
if (closed || attempts.has(id)) return;
- const previous = find(id);
+ const retained = completed.peek(id);
+ const previous =
+ find(id) ??
+ (retained && awaitsReceipt(retained.event) ? retained : undefined);
+ if (previous && awaitsReceipt(previous.event)) completed.delete(id);
snapshot = Object.freeze(snapshot.filter((item) => item.event.id !== id));
notify();
try {
@@ -467,15 +515,33 @@ export function createOutbox(
const [first] = confirmed;
if (!first) return;
for (const event of confirmed) {
- completed.set(
- event.id,
- Object.freeze({ event, signed: event, delivery: "seen" }),
- );
+ if (awaitsReceipt(event) && attempts.has(event.id)) {
+ snapshot = Object.freeze(
+ snapshot.map((item) =>
+ item.event.id === event.id
+ ? Object.freeze({
+ ...item,
+ signed: event,
+ delivery: "seen" as const,
+ })
+ : item,
+ ),
+ );
+ } else
+ completed.set(
+ event.id,
+ Object.freeze({ event, signed: event, delivery: "seen" }),
+ );
}
snapshot = Object.freeze(
- snapshot.filter((item) => !byId.has(item.event.id)),
+ snapshot.filter(
+ (item) =>
+ !byId.has(item.event.id) ||
+ (awaitsReceipt(item.event) && attempts.has(item.event.id)),
+ ),
);
for (const event of confirmed) {
+ if (awaitsReceipt(event) && attempts.has(event.id)) continue;
const attempt = attempts.get(event.id);
attempt?.controller?.abort();
clearTimeout(attempt?.timer);
diff --git a/src/features/relay/receipt.ts b/src/features/relay/receipt.ts
new file mode 100644
index 00000000..35dd5896
--- /dev/null
+++ b/src/features/relay/receipt.ts
@@ -0,0 +1,21 @@
+/** Bound command receipt bytes before parsing or retaining secret-bearing text. */
+export async function readReceiptText(response: Response): Promise {
+ if (!response.body) throw new Error("Relay delivery receipt body missing");
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ let bytes = 0;
+ let text = "";
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) return text + decoder.decode();
+ bytes += value.byteLength;
+ if (bytes > 16 * 1024)
+ throw new Error("Relay delivery receipt exceeds the size limit");
+ text += decoder.decode(value, { stream: true });
+ }
+ } finally {
+ await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+}
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index abbac089..0411cea2 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1,4 +1,6 @@
// FOUNDATION: One relay session owns reads, local intent, delivery and shared views.
+import { createWorkflows } from "../workflows/capability";
+import { isWorkflowOperation } from "../workflows/protocol";
import {
createRelayReader,
type ReadOptions,
@@ -99,6 +101,11 @@ export function createRelaySession(
...writer,
async sign(template, signal) {
validateMentionEvent(template);
+ workflows.validate({
+ ...template,
+ id: "",
+ pubkey: transport.viewer,
+ });
return writer.sign(template, signal);
},
},
@@ -113,7 +120,19 @@ export function createRelaySession(
profiling,
notifyListener: notify,
onAccepted: (event) => confirm(event),
- preparePublish: prepareMentionPublication,
+ needsReceipt: isWorkflowOperation,
+ onReceipt: (event, message) => workflows.receipt(event, message),
+ preparePublish: async (event, signal) => {
+ workflows.validate(event);
+ const checkMentions = await prepareMentionPublication(
+ event,
+ signal,
+ );
+ return () => {
+ workflows.validate(event);
+ checkMentions?.();
+ };
+ },
},
)
: undefined;
@@ -171,6 +190,7 @@ export function createRelaySession(
emoji.clear();
agentLibrary.clear();
archives.clear();
+ workflows.clear();
for (const purge of views.values()) purge();
commit();
unread.purge();
@@ -315,6 +335,15 @@ export function createRelaySession(
},
);
canAccess = channels.canAccess;
+ const workflows = createWorkflows({
+ reader: transport ? verified : undefined,
+ viewer: transport?.viewer ?? "",
+ outbox: writes?.outbox,
+ local: localViews,
+ host: transport?.workflows,
+ canAccess: (channelId) => canAccess(channelId),
+ notify,
+ });
const readScope = `${transport?.scope ?? transport?.relayAuthor ?? "offline"}:${transport?.viewer ?? ""}`;
const reads = createReadState({
viewer: transport?.viewer ?? "",
@@ -614,6 +643,7 @@ export function createRelaySession(
profiles: profiles.queries,
emoji: emoji.queries,
agentLibrary: agentLibrary.queries,
+ workflows: workflows.capability,
archives: archives.queries,
media: (url: string) => transport?.media(url),
/** A plugin may request writes from this same interface when the host supports them. */
@@ -895,6 +925,7 @@ export function createRelaySession(
requests.invalidate();
agentLibrary.clear();
archives.clear();
+ workflows.clear();
channels.staleHeads();
unread.stale();
}
@@ -959,6 +990,7 @@ export function createRelaySession(
emoji.clear();
agentLibrary.clear();
archives.clear();
+ workflows.clear();
await channels.clearCache();
publishLive();
},
@@ -978,6 +1010,7 @@ export function createRelaySession(
channels.dispose();
profiles.dispose();
emoji.dispose();
+ workflows.dispose();
agentLibrary.dispose();
archives.dispose();
},
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index d7b0738a..5868ef96 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -1,3 +1,6 @@
+import { workflowHost, workflowReadPath } from "../workflows/http";
+import type { WorkflowHost } from "../workflows/host";
+import { readReceiptText } from "./receipt";
import type { ReadStateHost, ReadStateSigning } from "./read-state-host";
import {
parseReadSnapshot,
@@ -31,9 +34,14 @@ import { eventDto, type ReadFilter, type RelayEvent } from "./events";
export interface RelayWriter {
readonly kinds?: readonly number[];
sign(event: EventTemplate, signal: AbortSignal): Promise;
- publish(event: RelayEvent, signal: AbortSignal): Promise;
+ /** Accepted receipt text is ephemeral; callers must never journal it. */
+ publish(
+ event: RelayEvent,
+ signal: AbortSignal,
+ ): Promise | Promise;
}
export interface ReadTransport {
+ readonly workflows?: WorkflowHost;
/** Host-projected local library; display only, never relay authority. */
readonly readAgentLibrary?: AgentLibraryReader;
/** Host-only decoder of the viewer's two signed sidebar preference coordinates. */
@@ -141,6 +149,7 @@ export async function connectBrokerTransport(
relayAuthor?: unknown;
archiveAuthority?: unknown;
writeKinds?: number[];
+ workflowReads?: boolean;
relayUrl?: string;
live?: boolean;
sidebarPreferences?: boolean;
@@ -174,6 +183,19 @@ export async function connectBrokerTransport(
...(typeof session.archiveAuthority === "string"
? { archiveAuthority: session.archiveAuthority }
: {}),
+ ...(session.workflowReads === true
+ ? {
+ workflows: workflowHost((route, body, signal) =>
+ fetch(`${endpoint}/${route}`, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal,
+ }),
+ ),
+ }
+ : {}),
...(session.agentLibrary
? {
readAgentLibrary: async (signal: AbortSignal) => {
@@ -312,7 +334,7 @@ export async function connectBrokerTransport(
signal,
});
recordServerTiming(result, profiling, event.id);
- await acceptPublish(result, event.id);
+ return acceptPublish(result, event.id);
},
},
}
@@ -398,6 +420,19 @@ export async function connectSignedTransport(
},
};
},
+ workflows: workflowHost((route, body, signal) =>
+ signedRequest(
+ signer,
+ `${httpOrigin}${workflowReadPath(route, body)}`,
+ undefined,
+ signal,
+ profiling,
+ route,
+ principal().api,
+ "foreground",
+ "GET",
+ ),
+ ),
scope: httpOrigin,
viewer,
relayAuthor,
@@ -405,8 +440,8 @@ export async function connectSignedTransport(
writer: {
sign: (event) => signer.signEvent(event),
async publish(event, signal) {
- await acceptPublish(
- await signedPost(
+ return acceptPublish(
+ await signedRequest(
signer,
`${httpOrigin}/events`,
event,
@@ -424,7 +459,7 @@ export async function connectSignedTransport(
},
},
async query(filters, signal, requestId = "read", priority = "foreground") {
- const result = await signedPost(
+ const result = await signedRequest(
signer,
`${httpOrigin}/query`,
filters,
@@ -453,7 +488,7 @@ export async function connectSignedTransport(
};
}
-async function signedPost(
+async function signedRequest(
signer: Signer,
url: string,
value: unknown,
@@ -462,13 +497,20 @@ async function signedPost(
id: string,
admission: Parameters[0],
priority: "foreground" | "background" = "foreground",
+ method: "POST" | "GET" = "POST",
) {
signal?.throwIfAborted();
return admission.prepare(async () => {
- const body = JSON.stringify(value);
- const payload = hex(
- await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
- );
+ const body = method === "POST" ? JSON.stringify(value) : undefined;
+ const payload =
+ body === undefined
+ ? undefined
+ : hex(
+ await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(body),
+ ),
+ );
if (signal?.aborted) throw signal.reason;
const auth = await profiling.measureAsync("http.auth", id, () =>
signer.signEvent({
@@ -477,8 +519,8 @@ async function signedPost(
content: "",
tags: [
["u", url],
- ["method", "POST"],
- ["payload", payload],
+ ["method", method],
+ ...(payload === undefined ? [] : [["payload", payload]]),
["nonce", crypto.randomUUID()],
],
}),
@@ -499,12 +541,13 @@ async function signedPost(
);
return profiling.measureAsync("http.fetch", id, () =>
fetch(url, {
- method: "POST",
+ method,
+ redirect: "error",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(auth))}`,
"Content-Type": "application/json",
},
- body,
+ ...(body === undefined ? {} : { body }),
signal: signal ?? null,
}),
);
@@ -533,7 +576,8 @@ async function acceptPublish(response: Response, id: string) {
`Relay delivery could not be confirmed (${response.status})`,
);
}
- const result = (await response.json()) as {
+ const text = await readReceiptText(response);
+ const result = JSON.parse(text) as {
accepted?: unknown;
event_id?: unknown;
message?: unknown;
@@ -546,6 +590,7 @@ async function acceptPublish(response: Response, id: string) {
? result.message
: "Relay rejected the message",
);
+ return typeof result.message === "string" ? result.message : "";
}
function recordServerTiming(
diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts
new file mode 100644
index 00000000..ab3a317e
--- /dev/null
+++ b/src/features/workflows/capability.test.ts
@@ -0,0 +1,208 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createWorkflows } from "./capability";
+import {
+ createOutbox,
+ PublishRejected,
+ type OutgoingEvent,
+} from "../relay/outbox";
+import { keypair, signed, flush } from "../relay/testing";
+import type { RelayEvent } from "../relay/events";
+const id = "11111111-1111-4111-8111-111111111111",
+ channelId = "22222222-2222-4222-8222-222222222222",
+ runId = "33333333-3333-4333-8333-333333333333";
+const yaml =
+ "name: Fixture\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: send\n action: send_message\n text: Hi\n";
+const disposers: (() => void)[] = [];
+afterEach(() => {
+ for (const dispose of disposers.splice(0)) dispose();
+});
+function setup() {
+ const key = keypair();
+ let saved: readonly OutgoingEvent[] = [],
+ allowed = true;
+ let settle!: (value: string) => void, reject!: (error: Error) => void;
+ const sign = vi.fn(async (template: Parameters[1]) =>
+ signed(key, template),
+ );
+ const publish = vi.fn(
+ (_event: RelayEvent, _signal: AbortSignal) =>
+ new Promise((resolve, fail) => {
+ settle = resolve;
+ reject = fail;
+ }),
+ );
+ const outbox = createOutbox(
+ key.pubkey,
+ { kinds: [30620, 46020, 5], sign, publish },
+ {
+ load: () => [],
+ save: (rows) => {
+ saved = structuredClone(rows);
+ },
+ },
+ {
+ needsReceipt: (event) => [30620, 46020, 5].includes(event.kind),
+ onReceipt: (event, message) => workflows.receipt(event, message),
+ },
+ );
+ const read = vi.fn(async () => [] as RelayEvent[]);
+ const workflows = createWorkflows({
+ viewer: key.pubkey,
+ reader: { read },
+ outbox: outbox.outbox,
+ local: outbox.local,
+ host: {
+ lifecycleVersion: 1,
+ runs: async () => ({ runs: [], next: null }),
+ approvals: async () => ({ approvals: [] }),
+ },
+ canAccess: () => allowed,
+ });
+ disposers.push(() => {
+ workflows.dispose();
+ outbox.dispose();
+ });
+ const definition = {
+ id,
+ channelId,
+ owner: key.pubkey,
+ revision: "a".repeat(64),
+ createdAt: 1,
+ yaml,
+ };
+ return {
+ ...workflows,
+ outbox,
+ read,
+ sign,
+ publish,
+ definition,
+ saved: () => saved,
+ settle: (value: string) => settle(value),
+ reject: (error: Error) => reject(error),
+ revoke: () => {
+ allowed = false;
+ workflows.clear();
+ },
+ };
+}
+it("save preserves exact YAML/coordinate/revision, receipt success is distinct from signed head readback", async () => {
+ const h = setup();
+ const view = h.capability.definitions(channelId);
+ const operation = h.capability.save({
+ channelId,
+ yaml,
+ existing: h.definition,
+ });
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ expect(event).toBeDefined();
+ expect(event?.content).toBe(yaml);
+ expect(event?.tags).toContainEqual([
+ "expected-revision",
+ h.definition.revision,
+ ]);
+ expect(event?.tags).toContainEqual(["d", id]);
+ h.settle(`response:${JSON.stringify({ workflow_id: id })}`);
+ await flush();
+ expect(h.capability.operations.snapshot()[0]).toMatchObject({
+ eventId: operation,
+ outcome: "succeeded",
+ });
+ // Local echo and receipt never masquerade as a freshly read committed head.
+ expect(view.snapshot()).toMatchObject({
+ status: "idle",
+ data: { items: [] },
+ });
+ await view.refresh();
+ expect(view.snapshot().data.items).toEqual([]);
+});
+it.each([
+ [JSON.stringify({ run_id: runId }), "succeeded", runId],
+ [JSON.stringify({ workflow_id: id, run_id: runId }), "succeeded", runId],
+ [JSON.stringify({ workflow_id: runId, run_id: runId }), "unknown", undefined],
+ [JSON.stringify({ run_id: "bad" }), "unknown", undefined],
+ [
+ JSON.stringify({ run_id: runId, webhook_secret: "PRIVATE" }),
+ "unknown",
+ undefined,
+ ],
+ ["{PRIVATE malformed", "unknown", undefined],
+])(
+ "manual run only correlates validated returned run ID: %s",
+ async (payload, outcome, expectedRun) => {
+ const h = setup();
+ h.capability.trigger(h.definition);
+ await flush();
+ h.settle(`response:${payload}`);
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe(outcome);
+ expect(h.capability.operations.snapshot()[0]?.runId).toBe(expectedRun);
+ expect(h.read).not.toHaveBeenCalled();
+ expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
+ expect(JSON.stringify(h.capability.operations.snapshot())).not.toContain(
+ "PRIVATE",
+ );
+ },
+);
+it("lost receipt plus echo stays unknown; same signed retry and dismiss preserve operation identity", async () => {
+ const h = setup();
+ const operation = h.capability.trigger(h.definition);
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ if (!event) throw new Error("missing publication");
+ h.outbox.observe([event]);
+ h.reject(new Error("PRIVATE lost"));
+ await flush();
+ expect(h.capability.operations.snapshot()[0]).toMatchObject({
+ eventId: operation,
+ delivery: "seen",
+ outcome: "unknown",
+ });
+ h.capability.operations.retry(operation);
+ await flush();
+ expect(h.publish).toHaveBeenCalledTimes(2);
+ expect(h.sign).toHaveBeenCalledTimes(1);
+ expect(h.publish.mock.calls[1]?.[0]).toEqual(event);
+ h.settle("duplicate: already processed");
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown");
+ await h.capability.operations.dismiss(operation);
+ expect(h.capability.operations.snapshot()).toEqual([]);
+});
+it("explicit rejection is rejected, not unknown; revocation fences late receipts without discarding durable intent", async () => {
+ const h = setup();
+ h.capability.trigger(h.definition);
+ await flush();
+ h.reject(new PublishRejected("PRIVATE rejection"));
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("rejected");
+ const id = h.capability.trigger(h.definition);
+ await flush();
+ h.revoke();
+ h.settle(`response:${JSON.stringify({ run_id: runId })}`);
+ await flush();
+ expect(h.capability.operations.snapshot()).toEqual([]);
+ expect(h.saved().some((row) => row.event.id === id)).toBe(true);
+ expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
+});
+it("webhook saves are blocked through raw YAML; stale/legacy deletion receipt never proves deletion", async () => {
+ const h = setup();
+ expect(() =>
+ h.capability.save({
+ channelId,
+ yaml: yaml.replace("message_posted", "webhook"),
+ }),
+ ).toThrow("secret");
+ expect(h.publish).not.toHaveBeenCalled();
+ h.capability.delete(h.definition);
+ await flush();
+ h.settle("");
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown");
+ h.capability.delete(h.definition);
+ await flush();
+ h.settle(`response:${JSON.stringify({ workflow_id: id, deleted: true })}`);
+ await flush();
+ expect(h.capability.operations.snapshot()[1]?.outcome).toBe("succeeded");
+});
diff --git a/src/features/workflows/capability.ts b/src/features/workflows/capability.ts
new file mode 100644
index 00000000..19ad608b
--- /dev/null
+++ b/src/features/workflows/capability.ts
@@ -0,0 +1,460 @@
+import type { EventData } from "../relay/events";
+import type { RelayReader } from "../relay/reader";
+import type { Outbox, LocalEvents } from "../relay/outbox";
+import type { WorkflowHost } from "./host";
+import type {
+ WorkflowCapability,
+ WorkflowOperation,
+ WorkflowReference,
+ WorkflowView,
+ WorkflowDefinition,
+} from "./types";
+import {
+ definition,
+ isWorkflowOperation,
+ parseApprovals,
+ parseRuns,
+ record,
+ validateReference,
+ validateWorkflowEvent,
+ workflowReference,
+ UUID,
+} from "./protocol";
+
+/** Session-owned configuration snapshots and bounded result state; never an engine. */
+export function createWorkflows({
+ reader,
+ viewer,
+ outbox,
+ local,
+ host,
+ canAccess,
+ notify = (listener: () => void) => listener(),
+}: {
+ reader: RelayReader | undefined;
+ viewer: string;
+ outbox: Outbox | undefined;
+ local: LocalEvents | undefined;
+ host: WorkflowHost | undefined;
+ canAccess(channel: string): boolean;
+ notify?: (listener: () => void) => void;
+}) {
+ let closed = false;
+ const views = new Set<{ clear(): void; emit(): void; dispose(): void }>();
+ const listeners = new Set<() => void>();
+ type Result = {
+ outcome: WorkflowOperation["outcome"];
+ runId?: string;
+ error?: string;
+ };
+ const results = new Map();
+ const receiptInterest = new Set();
+ // Webhook save/reveal stays unavailable until the explicit UI secret lifetime is integrated.
+ const availability = Object.freeze({
+ definitions: !!reader,
+ history: !!host,
+ save: host?.lifecycleVersion === 1 && !!outbox?.supports(30620),
+ trigger: host?.lifecycleVersion === 1 && !!outbox?.supports(46020),
+ delete: host?.lifecycleVersion === 1 && !!outbox?.supports(5),
+ webhookSecrets: false,
+ });
+ let operations: readonly WorkflowOperation[] = Object.freeze([]);
+ function rebuild() {
+ operations = closed
+ ? Object.freeze([])
+ : Object.freeze(
+ (local?.snapshot() ?? [])
+ .flatMap((item): WorkflowOperation[] => {
+ if (!isWorkflowOperation(item.event)) return [];
+ let workflow: WorkflowReference;
+ try {
+ workflow = workflowReference(item.event);
+ } catch {
+ return [];
+ }
+ if (workflow.owner !== viewer || !canAccess(workflow.channelId))
+ return [];
+ const result = results.get(item.event.id);
+ const outcome =
+ result?.outcome ??
+ (item.delivery === "sending"
+ ? "pending"
+ : item.delivery === "failed"
+ ? "rejected"
+ : "unknown");
+ return [
+ Object.freeze({
+ eventId: item.event.id,
+ workflow,
+ action:
+ item.event.kind === 30620
+ ? "save"
+ : item.event.kind === 5
+ ? "delete"
+ : "trigger",
+ delivery: item.delivery,
+ outcome,
+ secretAvailable: false,
+ ...(result?.runId ? { runId: result.runId } : {}),
+ ...((result?.error ?? item.error) !== undefined
+ ? { error: (result?.error ?? item.error) as string }
+ : {}),
+ }),
+ ];
+ })
+ .slice(-256),
+ );
+ const active = new Set(operations.map((op) => op.eventId));
+ for (const id of results.keys()) if (!active.has(id)) results.delete(id);
+ for (const listener of listeners) notify(listener);
+ }
+ const stop = local?.subscribe(rebuild);
+ rebuild();
+ function assertAccess(reference: WorkflowReference) {
+ validateReference(reference);
+ if (closed || !canAccess(reference.channelId))
+ throw new Error(
+ "Workflow access unavailable; refresh channel membership",
+ );
+ }
+ function view(
+ channelId: string,
+ available: boolean,
+ empty: T,
+ load: (signal: AbortSignal) => Promise,
+ ): WorkflowView {
+ if (!UUID.test(channelId)) throw new Error("Invalid workflow channel");
+ if (views.size >= 16)
+ throw new Error("Too many workflow views; close another detail first");
+ let disposed = false;
+ let controller: AbortController | undefined;
+ let pending: Promise | undefined;
+ const subscribers = new Set<() => void>();
+ type Snapshot = ReturnType["snapshot"]>;
+ let snapshot: Snapshot = Object.freeze({
+ status:
+ available && !closed && canAccess(channelId) ? "idle" : "unavailable",
+ data: empty,
+ });
+ const emit = () => {
+ for (const listener of subscribers) notify(listener);
+ };
+ function clear() {
+ controller?.abort();
+ controller = undefined;
+ pending = undefined;
+ snapshot = Object.freeze({
+ status:
+ available && !closed && !disposed && canAccess(channelId)
+ ? "idle"
+ : "unavailable",
+ data: empty,
+ });
+ }
+ const owner = {
+ clear,
+ emit,
+ dispose() {
+ disposed = true;
+ clear();
+ subscribers.clear();
+ views.delete(owner);
+ },
+ };
+ views.add(owner);
+ return Object.freeze({
+ snapshot: () => snapshot,
+ subscribe(listener: () => void) {
+ if (closed || disposed) return () => {};
+ subscribers.add(listener);
+ return () => {
+ subscribers.delete(listener);
+ };
+ },
+ refresh() {
+ if (closed || disposed || !available) return Promise.resolve();
+ if (!canAccess(channelId)) {
+ clear();
+ emit();
+ return Promise.resolve();
+ }
+ if (pending) return pending;
+ const owned = new AbortController();
+ controller = owned;
+ const signal = AbortSignal.any([
+ owned.signal,
+ AbortSignal.timeout(10000),
+ ]);
+ snapshot = Object.freeze({ status: "loading", data: snapshot.data });
+ pending = Promise.resolve()
+ .then(() => {
+ signal.throwIfAborted();
+ if (!canAccess(channelId))
+ throw new Error("Workflow channel access unavailable");
+ return load(signal);
+ })
+ .then((data) => {
+ if (
+ closed ||
+ disposed ||
+ controller !== owned ||
+ signal.aborted ||
+ !canAccess(channelId)
+ )
+ return;
+ snapshot = Object.freeze({ status: "ready", data });
+ emit();
+ })
+ .catch(() => {
+ if (
+ closed ||
+ disposed ||
+ controller !== owned ||
+ owned.signal.aborted
+ )
+ return;
+ snapshot = Object.freeze({
+ status: "error",
+ data: empty,
+ error:
+ "Workflow read unavailable. Retry; this is not proof of deletion.",
+ });
+ emit();
+ })
+ .finally(() => {
+ if (controller === owned) {
+ controller = undefined;
+ pending = undefined;
+ }
+ });
+ const started = pending;
+ emit();
+ return started;
+ },
+ dispose: owner.dispose,
+ });
+ }
+ function assertOperation(kind: number) {
+ const enabled =
+ kind === 30620
+ ? availability.save
+ : kind === 46020
+ ? availability.trigger
+ : availability.delete;
+ if (!enabled)
+ throw new Error("Reliable workflow writes are unavailable on this relay");
+ }
+ function send(
+ kind: 30620 | 46020 | 5,
+ workflow: WorkflowReference,
+ yaml = "",
+ revision?: string,
+ ) {
+ assertAccess(workflow);
+ assertOperation(kind);
+ const tags = [
+ ["h", workflow.channelId],
+ ...(kind === 5
+ ? [["a", `30620:${workflow.owner}:${workflow.id}`]]
+ : [["d", workflow.id]]),
+ ...(revision ? [["expected-revision", revision]] : []),
+ ];
+ const input = { kind, content: yaml, tags };
+ validateWorkflowEvent(
+ { ...input, pubkey: viewer, id: "", created_at: 0 },
+ viewer,
+ availability,
+ );
+ if (!outbox) throw new Error("Workflow publishing unavailable");
+ if (receiptInterest.size >= 256)
+ throw new Error("Too many unresolved workflow commands");
+ const id = outbox.send(input);
+ receiptInterest.add(id);
+ return id;
+ }
+ const capability = Object.freeze({
+ availability,
+ definitions(channelId) {
+ return view(
+ channelId,
+ !!reader,
+ Object.freeze({
+ items: Object.freeze([] as WorkflowDefinition[]),
+ partial: false,
+ }),
+ async (signal) => {
+ if (!reader) throw new Error("Workflow definitions unavailable");
+ const events = await reader.read(
+ [{ kinds: [30620], "#h": [channelId], limit: 100 }],
+ { signal, fresh: true },
+ );
+ const coordinates = new Map();
+ for (const event of events) {
+ const row = definition(event);
+ if (row.channelId !== channelId)
+ throw new Error("Mismatched workflow channel");
+ const key = `${row.owner}:${row.id}`;
+ const old = coordinates.get(key);
+ if (
+ !old ||
+ row.createdAt > old.createdAt ||
+ (row.createdAt === old.createdAt && row.revision < old.revision)
+ )
+ coordinates.set(key, row);
+ }
+ return Object.freeze({
+ items: Object.freeze([...coordinates.values()]),
+ partial: events.length >= 100,
+ });
+ },
+ );
+ },
+ runs(workflow, cursor) {
+ assertAccess(workflow);
+ return view(
+ workflow.channelId,
+ !!host,
+ Object.freeze({ runs: Object.freeze([]), next: null }),
+ async (signal) => {
+ if (!host) throw new Error("Workflow history unavailable");
+ return parseRuns(
+ await host.runs(workflow.id, cursor, signal),
+ workflow.id,
+ );
+ },
+ );
+ },
+ approvals(workflow, runId) {
+ assertAccess(workflow);
+ return view(
+ workflow.channelId,
+ !!host,
+ Object.freeze([]),
+ async (signal) => {
+ if (!host) throw new Error("Workflow history unavailable");
+ return parseApprovals(
+ await host.approvals(workflow.id, runId, signal),
+ workflow.id,
+ runId,
+ );
+ },
+ );
+ },
+ save({ channelId, yaml, existing }) {
+ if (existing && existing.channelId !== channelId)
+ throw new Error("Workflow channel cannot change");
+ return send(
+ 30620,
+ existing ?? { channelId, owner: viewer, id: crypto.randomUUID() },
+ yaml,
+ existing?.revision,
+ );
+ },
+ delete(workflow) {
+ return send(5, workflow);
+ },
+ trigger(workflow) {
+ return send(46020, workflow);
+ },
+ operations: Object.freeze({
+ snapshot: () => operations,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ retry(id) {
+ const op = operations.find((row) => row.eventId === id);
+ if (!op || op.outcome === "succeeded" || op.delivery === "sending")
+ return;
+ assertAccess(op.workflow);
+ receiptInterest.add(id);
+ results.delete(id);
+ outbox?.retry(id);
+ },
+ async dismiss(id) {
+ await outbox?.dismiss(id);
+ results.delete(id);
+ receiptInterest.delete(id);
+ rebuild();
+ },
+ }),
+ takeWebhookSecret() {
+ return undefined;
+ },
+ });
+ return {
+ capability,
+ validate(event: EventData) {
+ if (!isWorkflowOperation(event)) return;
+ assertOperation(event.kind);
+ const reference = validateWorkflowEvent(event, viewer, availability);
+ assertAccess(reference);
+ },
+ receipt(event: EventData, message: string | undefined) {
+ if (closed || !receiptInterest.delete(event.id)) return;
+ if (message === undefined) {
+ results.delete(event.id);
+ rebuild();
+ return;
+ }
+ let result: Result = {
+ outcome: "unknown",
+ error:
+ "Delivery may have succeeded, but its result is unavailable. Do not submit a new command to retry.",
+ };
+ try {
+ assertAccess(workflowReference(event));
+ if (message?.startsWith("response:")) {
+ const value: unknown = JSON.parse(message.slice(9));
+ const reference = workflowReference(event);
+ if (
+ record(value) &&
+ (event.kind === 46020
+ ? value.workflow_id === undefined ||
+ value.workflow_id === reference.id
+ : value.workflow_id === reference.id) &&
+ value.webhook_secret === undefined
+ ) {
+ if (event.kind === 30620) result = { outcome: "succeeded" };
+ else if (
+ event.kind === 46020 &&
+ typeof value.run_id === "string" &&
+ UUID.test(value.run_id)
+ )
+ result = { outcome: "succeeded", runId: value.run_id };
+ else if (
+ event.kind === 5 &&
+ host?.lifecycleVersion === 1 &&
+ value.deleted === true
+ )
+ result = { outcome: "succeeded" };
+ }
+ }
+ } catch {
+ /* Do not leak receipt text into errors/journal. */
+ }
+ results.set(event.id, result);
+ rebuild();
+ },
+ clear() {
+ results.clear();
+ receiptInterest.clear();
+ for (const owned of views) owned.clear();
+ rebuild();
+ for (const owned of views) owned.emit();
+ // Individual view snapshots were cleared before any view callbacks.
+ // Views refresh on explicit UI interest; no startup/background fanout.
+ },
+ dispose() {
+ closed = true;
+ for (const owned of [...views]) owned.dispose();
+ results.clear();
+ receiptInterest.clear();
+ stop?.();
+ rebuild();
+ listeners.clear();
+ },
+ };
+}
diff --git a/src/features/workflows/host.ts b/src/features/workflows/host.ts
new file mode 100644
index 00000000..5f486596
--- /dev/null
+++ b/src/features/workflows/host.ts
@@ -0,0 +1,13 @@
+import type { WorkflowRunCursor } from "./types";
+
+/** Host-owned authenticated reads on the captured relay principal/admission lane. */
+export interface WorkflowHost {
+ /** Positive forward lifecycle contract evidence, never inferred from kind support. */
+ readonly lifecycleVersion?: 1;
+ runs(
+ id: string,
+ cursor: WorkflowRunCursor | undefined,
+ signal: AbortSignal,
+ ): Promise;
+ approvals(id: string, runId: string, signal: AbortSignal): Promise;
+}
diff --git a/src/features/workflows/http.test.ts b/src/features/workflows/http.test.ts
new file mode 100644
index 00000000..4cfb4e10
--- /dev/null
+++ b/src/features/workflows/http.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { verifyEvent } from "nostr-tools";
+import { connectSignedTransport } from "../relay/transport";
+import { keypair, signed } from "../relay/testing";
+import { WORKFLOW_READ_BYTES, workflowReadText } from "./http";
+const id = "11111111-1111-4111-8111-111111111111";
+const runId = "22222222-2222-4222-8222-222222222222";
+afterEach(() => vi.unstubAllGlobals());
+it("direct signed transport history uses exact GET URL, no payload and the existing principal quota lane", async () => {
+ const key = keypair();
+ const signer = {
+ getPublicKey: async () => key.pubkey,
+ signEvent: async (template: Parameters[1]) =>
+ signed(key, template),
+ };
+ const fetcher = vi.fn(async (url: string, init?: RequestInit) => {
+ const auth = JSON.parse(
+ atob(new Headers(init?.headers).get("Authorization")?.slice(6) ?? ""),
+ );
+ expect(verifyEvent(auth)).toBe(true);
+ expect(auth.pubkey).toBe(key.pubkey);
+ expect(auth.tags).toContainEqual(["u", url]);
+ expect(auth.tags).toContainEqual(["method", "GET"]);
+ expect(auth.tags.some(([name]: string[]) => name === "payload")).toBe(
+ false,
+ );
+ expect(init?.body).toBeUndefined();
+ expect(init?.redirect).toBe("error");
+ return Response.json(
+ { error: "rate-limited: quota exceeded; retry in 0s" },
+ { status: 429 },
+ );
+ });
+ vi.stubGlobal("fetch", fetcher);
+ const transport = await connectSignedTransport(
+ signer,
+ "https://workflow-direct.test",
+ key.pubkey,
+ );
+ expect(fetcher).not.toHaveBeenCalled();
+ const cursor = {
+ before: "2026-09-12T14:44:19.123456+00:00",
+ beforeId: runId,
+ };
+ await expect(
+ transport.workflows?.runs(id, cursor, new AbortController().signal),
+ ).rejects.toMatchObject({ status: 429 });
+ expect(fetcher.mock.calls[0]?.[0]).toBe(
+ `https://workflow-direct.test/workflows/${id}/runs?limit=20&before=2026-09-12T14%3A44%3A19.123456%2B00%3A00&before_id=${runId}`,
+ );
+ await expect(
+ transport.query([{ kinds: [0], limit: 1 }]),
+ ).rejects.toMatchObject({ status: 429 });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+});
+it("structured body budget counts stream bytes, cancels overflow, rejects invalid UTF8", async () => {
+ const cancel = vi.fn();
+ const response = new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(WORKFLOW_READ_BYTES + 1));
+ },
+ cancel,
+ }),
+ );
+ await expect(workflowReadText(response)).rejects.toThrow("size limit");
+ expect(cancel).toHaveBeenCalledTimes(1);
+ await expect(
+ workflowReadText(new Response(new Uint8Array([0xff]))),
+ ).rejects.toThrow();
+});
+it("direct workflow cancellation/invalid arguments never sign or dispatch", async () => {
+ const key = keypair(),
+ signEvent = vi.fn(async (template: Parameters[1]) =>
+ signed(key, template),
+ );
+ const fetcher = vi.fn();
+ vi.stubGlobal("fetch", fetcher);
+ const transport = await connectSignedTransport(
+ { getPublicKey: async () => key.pubkey, signEvent },
+ "https://workflow-cancel.test",
+ key.pubkey,
+ );
+ const cancel = new AbortController();
+ cancel.abort();
+ await expect(
+ transport.workflows?.runs(id, undefined, cancel.signal),
+ ).rejects.toThrow();
+ await expect(
+ transport.workflows?.approvals(id, "../", new AbortController().signal),
+ ).rejects.toThrow();
+ expect(signEvent).not.toHaveBeenCalled();
+ expect(fetcher).not.toHaveBeenCalled();
+});
diff --git a/src/features/workflows/http.ts b/src/features/workflows/http.ts
new file mode 100644
index 00000000..c9c20326
--- /dev/null
+++ b/src/features/workflows/http.ts
@@ -0,0 +1,97 @@
+import { ReadError } from "../relay/errors";
+import { readApiFailure } from "../relay/http-admission";
+import type { WorkflowHost } from "./host";
+import { approvalsPath, record, runsPath } from "./protocol";
+
+export const WORKFLOW_READ_BYTES = 1024 * 1024;
+
+/** Fixed routes only: browser input can never select an upstream URL or page size. */
+export function workflowReadPath(route: string, body: unknown): string {
+ if (!record(body) || typeof body.id !== "string")
+ throw new Error("Invalid workflow read");
+ if (route === "workflow-runs") {
+ if (Object.keys(body).some((key) => !["id", "cursor"].includes(key)))
+ throw new Error("Invalid workflow read fields");
+ const cursor = body.cursor;
+ if (
+ cursor !== undefined &&
+ (!record(cursor) ||
+ typeof cursor.before !== "string" ||
+ typeof cursor.beforeId !== "string" ||
+ Object.keys(cursor).some(
+ (key) => !["before", "beforeId"].includes(key),
+ ))
+ )
+ throw new Error("Invalid workflow cursor");
+ return runsPath(body.id, cursor as Parameters[1]);
+ }
+ if (
+ route === "workflow-approvals" &&
+ typeof body.runId === "string" &&
+ Object.keys(body).every((key) => ["id", "runId"].includes(key))
+ )
+ return approvalsPath(body.id, body.runId);
+ throw new Error("Invalid workflow read route");
+}
+
+/** Stream-bound before parsing. Never include upstream text in an error/log. */
+export async function workflowReadText(response: Response): Promise {
+ if (!response.body) throw new Error("Workflow response body missing");
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ let bytes = 0,
+ text = "";
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) return text + decoder.decode();
+ bytes += value.byteLength;
+ if (bytes > WORKFLOW_READ_BYTES)
+ throw new Error("Workflow response exceeds the size limit");
+ text += decoder.decode(value, { stream: true });
+ }
+ } finally {
+ await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+}
+
+/** Adapters supply authentication/admission; the capability validates domain rows. */
+export function workflowHost(
+ request: (
+ route: string,
+ body: unknown,
+ signal: AbortSignal,
+ ) => Promise,
+): WorkflowHost {
+ async function read(route: string, body: unknown, signal: AbortSignal) {
+ workflowReadPath(route, body);
+ signal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
+ signal.throwIfAborted();
+ const response = await request(route, body, signal);
+ if (!response.ok) {
+ const failure = await readApiFailure(response);
+ throw new ReadError(
+ response.status === 401 || response.status === 403
+ ? "denied"
+ : "unavailable",
+ failure.error,
+ response.status,
+ failure.retryAfterMs,
+ );
+ }
+ const text = await workflowReadText(response);
+ signal.throwIfAborted();
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ throw new Error("Invalid workflow response");
+ }
+ }
+ return Object.freeze({
+ runs: (id, cursor, signal) =>
+ read("workflow-runs", { id, ...(cursor ? { cursor } : {}) }, signal),
+ approvals: (id, runId, signal) =>
+ read("workflow-approvals", { id, runId }, signal),
+ } satisfies WorkflowHost);
+}
diff --git a/src/features/workflows/protocol.test.ts b/src/features/workflows/protocol.test.ts
new file mode 100644
index 00000000..cf0ad582
--- /dev/null
+++ b/src/features/workflows/protocol.test.ts
@@ -0,0 +1,126 @@
+import { expect, it } from "vitest";
+import {
+ isWorkflowOperation,
+ validateWorkflowEvent,
+ parseRuns,
+ parseApprovals,
+ workflowYaml,
+} from "./protocol";
+const owner = "a".repeat(64),
+ id = "11111111-1111-4111-8111-111111111111",
+ runId = "22222222-2222-4222-8222-222222222222";
+const yaml =
+ "name: Fixture\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: 1_send\n action: send_message\n text: Hi\n";
+const base = {
+ id: "b".repeat(64),
+ pubkey: owner,
+ created_at: 1,
+ kind: 30620,
+ tags: [
+ ["h", id],
+ ["d", id],
+ ],
+ content: yaml,
+};
+it("strict authoring boundary rejects malformed coordinates/tags and webhook raw bypass, without taking generic deletions", () => {
+ expect(
+ validateWorkflowEvent(base, owner, { delete: true, webhookSecrets: false }),
+ ).toEqual({ id, owner, channelId: id });
+ for (const event of [
+ { ...base, pubkey: "c".repeat(64) },
+ {
+ ...base,
+ tags: [
+ ["h", "../"],
+ ["d", id],
+ ],
+ },
+ { ...base, tags: [...base.tags, ["d", id]] },
+ { ...base, tags: [...base.tags, ["client-id", "x"], ["client-id", "y"]] },
+ { ...base, tags: [...base.tags, ["expected-revision", "bad"]] },
+ { ...base, content: yaml.replace("message_posted", "webhook") },
+ { ...base, kind: 46020 },
+ { ...base, created_at: Infinity },
+ ])
+ expect(() =>
+ validateWorkflowEvent(event, owner, {
+ delete: true,
+ webhookSecrets: false,
+ }),
+ ).toThrow();
+ expect(isWorkflowOperation({ kind: 5, tags: [["e", "b".repeat(64)]] })).toBe(
+ false,
+ );
+ expect(
+ isWorkflowOperation({ kind: 5, tags: [["a", `30620:${owner}:${id}`]] }),
+ ).toBe(true);
+ expect(() =>
+ validateWorkflowEvent(
+ {
+ ...base,
+ kind: 5,
+ content: "",
+ tags: [
+ ["h", id],
+ ["a", `30620:${owner}:${id}`],
+ ],
+ },
+ owner,
+ { delete: false, webhookSecrets: false },
+ ),
+ ).toThrow("deletion");
+});
+it("YAML remains unchanged and malformed input/duplicate steps are rejected", () => {
+ expect(workflowYaml(yaml)).toMatchObject({ enabled: false });
+ for (const text of [
+ "[]",
+ yaml.replace("enabled: false", ""),
+ yaml.replace("1_send", "my-step"),
+ `${yaml} - id: 1_send\n action: delay\n duration: 1s\n`,
+ "x".repeat(24001),
+ "name: [bad",
+ ]) {
+ expect(() => workflowYaml(text)).toThrow();
+ }
+});
+it("run/approval rows require matching identities and preserve exact cursor precision", () => {
+ const row = {
+ id: runId,
+ workflow_id: id,
+ status: "completed",
+ current_step: 1,
+ execution_trace: [],
+ started_at: 1,
+ completed_at: 2,
+ created_at: 1,
+ error_code: null,
+ error_message: null,
+ };
+ const before = "2026-09-12T14:44:19.123456+00:00";
+ const raw = { runs: [row], next: { before, before_id: runId } };
+ expect(parseRuns(raw, id).next).toEqual({ before, beforeId: runId });
+ for (const value of [
+ { ...raw, runs: [row, row] },
+ { ...raw, runs: [{ ...row, workflow_id: runId }] },
+ { ...raw, runs: [{ ...row, status: "imaginary" }] },
+ { ...raw, next: { before } },
+ { ...raw, runs: Array(21).fill(row) },
+ ]) {
+ expect(() => parseRuns(value, id)).toThrow();
+ }
+ const approval = {
+ workflow_id: id,
+ run_id: runId,
+ approval_ref: owner,
+ step_id: "a",
+ status: "pending",
+ created_at: 1,
+ note: null,
+ };
+ expect(
+ parseApprovals({ approvals: [approval] }, id, runId)[0]?.reference,
+ ).toBe(owner);
+ expect(() =>
+ parseApprovals({ approvals: [{ ...approval, run_id: id }] }, id, runId),
+ ).toThrow();
+});
diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts
new file mode 100644
index 00000000..4f0fcff2
--- /dev/null
+++ b/src/features/workflows/protocol.ts
@@ -0,0 +1,286 @@
+import { parseDocument } from "yaml";
+import type { EventData } from "../relay/events";
+import type {
+ WorkflowDefinition,
+ WorkflowReference,
+ WorkflowRunCursor,
+ WorkflowRunPage,
+ WorkflowApproval,
+} from "./types";
+
+export const WORKFLOW_KINDS = [30620, 46020, 5] as const;
+export function isWorkflowOperation(
+ event: Pick,
+): boolean {
+ return (
+ event.kind === 30620 ||
+ event.kind === 46020 ||
+ (event.kind === 5 &&
+ event.tags.some(
+ ([name, value]) => name === "a" && value?.startsWith("30620:"),
+ ))
+ );
+}
+export const UUID =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+const HEX = /^[0-9a-f]{64}$/;
+export function record(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+export function validateReference(value: WorkflowReference) {
+ if (
+ !UUID.test(value.id) ||
+ !UUID.test(value.channelId) ||
+ !HEX.test(value.owner)
+ )
+ throw new Error("Invalid workflow coordinate");
+}
+function one(event: Pick, name: string): string {
+ const tags = event.tags.filter((tag) => tag[0] === name);
+ if (tags.length !== 1 || tags[0]?.length !== 2 || !tags[0][1])
+ throw new Error(`Invalid workflow ${name} tag`);
+ return tags[0][1];
+}
+export function workflowReference(event: EventData): WorkflowReference {
+ const channelId = one(event, "h");
+ let id: string,
+ owner = event.pubkey;
+ if (event.kind === 5) {
+ const parts = one(event, "a").split(":");
+ if (parts.length !== 3 || parts[0] !== "30620")
+ throw new Error("Invalid workflow deletion coordinate");
+ owner = parts[1] ?? "";
+ id = parts[2] ?? "";
+ } else id = one(event, "d");
+ const reference = { id, owner, channelId };
+ validateReference(reference);
+ return reference;
+}
+export function definition(event: EventData): WorkflowDefinition {
+ if (event.kind !== 30620 || !HEX.test(event.id))
+ throw new Error("Invalid workflow definition");
+ return Object.freeze({
+ ...workflowReference(event),
+ revision: event.id,
+ createdAt: event.created_at,
+ yaml: event.content,
+ });
+}
+/** Structural authoring checks, not a replacement for relay language/role validation. */
+export function workflowYaml(text: string) {
+ if (new TextEncoder().encode(text).byteLength > 24000)
+ throw new Error("Workflow YAML exceeds 24 KB");
+ const doc = parseDocument(text);
+ if (doc.errors.length) throw new Error("Workflow YAML is invalid");
+ const value: unknown = doc.toJS({ maxAliasCount: 50 });
+ if (
+ !record(value) ||
+ typeof value.name !== "string" ||
+ !value.name.trim() ||
+ typeof value.enabled !== "boolean" ||
+ !record(value.trigger) ||
+ typeof value.trigger.on !== "string" ||
+ !Array.isArray(value.steps) ||
+ !value.steps.length ||
+ value.steps.length > 100
+ )
+ throw new Error(
+ "Workflow needs a name, explicit enabled state, trigger and 1–100 steps",
+ );
+ const ids = new Set();
+ for (const step of value.steps) {
+ if (
+ !record(step) ||
+ typeof step.id !== "string" ||
+ !/^[A-Za-z0-9_]{1,64}$/.test(step.id) ||
+ ids.has(step.id) ||
+ typeof step.action !== "string"
+ )
+ throw new Error("Workflow steps need unique identifiers and actions");
+ ids.add(step.id);
+ }
+ return {
+ webhook: value.trigger.on === "webhook",
+ enabled: value.enabled,
+ name: value.name,
+ };
+}
+/** Shared session/broker signing boundary. Only canonical workflow operations, never generic kind 5. */
+export function validateWorkflowEvent(
+ event: EventData,
+ viewer: string,
+ options: { delete: boolean; webhookSecrets: boolean },
+) {
+ if (
+ typeof event.content !== "string" ||
+ !Number.isSafeInteger(event.created_at) ||
+ event.created_at < 0 ||
+ !Array.isArray(event.tags) ||
+ event.tags.length > 8 ||
+ event.tags.some(
+ (tag) =>
+ !Array.isArray(tag) ||
+ tag.length !== 2 ||
+ tag.some((value) => typeof value !== "string" || value.length > 256),
+ )
+ )
+ throw new Error("Malformed workflow command");
+ if (!WORKFLOW_KINDS.includes(event.kind as 30620 | 46020 | 5))
+ throw new Error("Unsupported workflow operation");
+ const reference = workflowReference(event);
+ if (reference.owner !== viewer || event.pubkey !== viewer)
+ throw new Error("Only the workflow author can manage it");
+ const allowed =
+ event.kind === 5
+ ? ["h", "a", "client-id"]
+ : ["h", "d", "expected-revision", "client-id"];
+ if (
+ new Set(event.tags.map(([name]) => name)).size !== event.tags.length ||
+ event.tags.some((tag) => !allowed.includes(tag[0] ?? ""))
+ )
+ throw new Error("Unsupported workflow command tag");
+ if (event.kind === 5 && !options.delete)
+ throw new Error("Reliable workflow deletion is unavailable on this relay");
+ if (event.kind !== 30620 && event.content !== "")
+ throw new Error("Workflow command content must be empty");
+ if (event.kind === 30620) {
+ const revisions = event.tags.filter(
+ ([name]) => name === "expected-revision",
+ );
+ if (revisions.length && !HEX.test(one(event, "expected-revision")))
+ throw new Error("Invalid expected workflow revision");
+ if (workflowYaml(event.content).webhook && !options.webhookSecrets)
+ throw new Error("Webhook saves require secure one-time-secret handling");
+ } else if (event.tags.some(([name]) => name === "expected-revision"))
+ throw new Error("Unexpected workflow revision tag");
+ return reference;
+}
+export function runsPath(id: string, cursor?: WorkflowRunCursor) {
+ if (!UUID.test(id)) throw new Error("Invalid workflow ID");
+ const query = new URLSearchParams({ limit: "20" });
+ if (cursor) {
+ validateCursor(cursor);
+ query.set("before", cursor.before);
+ query.set("before_id", cursor.beforeId);
+ }
+ return `/workflows/${id}/runs?${query}`;
+}
+export function approvalsPath(id: string, runId: string) {
+ if (!UUID.test(id) || !UUID.test(runId))
+ throw new Error("Invalid workflow/run ID");
+ return `/workflows/${id}/runs/${runId}/approvals`;
+}
+function validateCursor(cursor: WorkflowRunCursor) {
+ if (
+ !UUID.test(cursor.beforeId) ||
+ cursor.before.length > 40 ||
+ !/^\d{4}-\d{2}-\d{2}T/.test(cursor.before) ||
+ !Number.isFinite(Date.parse(cursor.before))
+ )
+ throw new Error("Invalid workflow run cursor");
+}
+const number = (v: unknown): v is number =>
+ Number.isSafeInteger(v) && (v as number) >= 0;
+const nullableNumber = (v: unknown): v is number | null =>
+ v === null || number(v);
+const nullableText = (v: unknown): v is string | null =>
+ v === null || (typeof v === "string" && v.length <= 16000);
+export function parseRuns(raw: unknown, workflowId: string): WorkflowRunPage {
+ if (!record(raw) || !Array.isArray(raw.runs) || raw.runs.length > 20)
+ throw new Error("Invalid workflow run response");
+ const ids = new Set();
+ const runs = raw.runs.map((run) => {
+ if (
+ !record(run) ||
+ typeof run.id !== "string" ||
+ !UUID.test(run.id) ||
+ ids.has(run.id) ||
+ run.workflow_id !== workflowId ||
+ ![
+ "pending",
+ "running",
+ "waiting_approval",
+ "completed",
+ "failed",
+ "cancelled",
+ ].includes(String(run.status)) ||
+ !number(run.current_step) ||
+ !number(run.created_at) ||
+ !nullableNumber(run.started_at) ||
+ !nullableNumber(run.completed_at) ||
+ !Array.isArray(run.execution_trace) ||
+ run.execution_trace.length > 1000 ||
+ !nullableText(run.error_code) ||
+ !nullableText(run.error_message)
+ )
+ throw new Error("Invalid workflow run row");
+ ids.add(run.id);
+ return Object.freeze({
+ id: run.id,
+ workflowId,
+ status: run.status as WorkflowRunPage["runs"][number]["status"],
+ currentStep: run.current_step,
+ trace: Object.freeze(run.execution_trace),
+ startedAt: run.started_at,
+ completedAt: run.completed_at,
+ createdAt: run.created_at,
+ errorCode: run.error_code,
+ errorMessage: run.error_message,
+ });
+ });
+ let next: WorkflowRunCursor | null = null;
+ if (raw.next !== null) {
+ if (
+ !record(raw.next) ||
+ typeof raw.next.before !== "string" ||
+ typeof raw.next.before_id !== "string" ||
+ !runs.length
+ )
+ throw new Error("Invalid workflow run cursor");
+ next = Object.freeze({
+ before: raw.next.before,
+ beforeId: raw.next.before_id,
+ });
+ validateCursor(next);
+ }
+ return Object.freeze({ runs: Object.freeze(runs), next });
+}
+export function parseApprovals(
+ raw: unknown,
+ workflowId: string,
+ runId: string,
+): readonly WorkflowApproval[] {
+ if (
+ !record(raw) ||
+ !Array.isArray(raw.approvals) ||
+ raw.approvals.length > 1000
+ )
+ throw new Error("Invalid workflow approvals response");
+ return Object.freeze(
+ raw.approvals.map((row) => {
+ if (
+ !record(row) ||
+ row.workflow_id !== workflowId ||
+ row.run_id !== runId ||
+ typeof row.approval_ref !== "string" ||
+ !HEX.test(row.approval_ref) ||
+ typeof row.step_id !== "string" ||
+ row.step_id.length > 256 ||
+ !["pending", "granted", "denied", "expired"].includes(
+ String(row.status),
+ ) ||
+ !nullableText(row.note) ||
+ !number(row.created_at)
+ )
+ throw new Error("Invalid workflow approval row");
+ return Object.freeze({
+ reference: row.approval_ref,
+ runId,
+ stepId: row.step_id,
+ status: row.status as WorkflowApproval["status"],
+ note: row.note,
+ createdAt: row.created_at,
+ });
+ }),
+ );
+}
diff --git a/src/features/workflows/session.test.ts b/src/features/workflows/session.test.ts
new file mode 100644
index 00000000..428a9eac
--- /dev/null
+++ b/src/features/workflows/session.test.ts
@@ -0,0 +1,195 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createRelaySession } from "../relay/session";
+import type { RelayEvent } from "../relay/events";
+import { keypair, roster, signed, scriptedTransport } from "../relay/testing";
+const channelId = "11111111-1111-4111-8111-111111111111";
+const id = "22222222-2222-4222-8222-222222222222";
+const relay = keypair(),
+ viewer = keypair();
+const definition = signed(viewer, {
+ kind: 30620,
+ created_at: 10,
+ content: "PRIVATE yaml",
+ tags: [
+ ["h", channelId],
+ ["d", id],
+ ],
+});
+const reference = { id, channelId, owner: viewer.pubkey };
+const owners: ReturnType[] = [];
+afterEach(() => {
+ for (const owner of owners.splice(0)) owner.dispose();
+});
+function setup() {
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let incoming!: (events: readonly RelayEvent[]) => void;
+ let resolveRuns!: (value: unknown) => void;
+ const runs = vi.fn(
+ (_id: string, _cursor: unknown, _signal: AbortSignal) =>
+ new Promise((resolve) => {
+ resolveRuns = resolve;
+ }),
+ );
+ const owner = createRelaySession({
+ ...wire.transport,
+ workflows: { runs, approvals: async () => ({ approvals: [] }) },
+ subscribe(callbacks) {
+ incoming = callbacks.receive;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ owners.push(owner);
+ return {
+ ...wire,
+ ...owner,
+ runs,
+ emit: (events: readonly RelayEvent[]) => incoming(events),
+ resolveRuns: (value: unknown) => resolveRuns(value),
+ };
+}
+it("workflow views start lazy and purge to unavailable before any channel/operation/view observer runs", async () => {
+ const h = setup();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 1)]);
+ const definitions = h.session.workflows.definitions(channelId),
+ history = h.session.workflows.runs(reference);
+ expect(h.pending).toHaveLength(0);
+ expect(h.runs).not.toHaveBeenCalled();
+ const loading = definitions.refresh();
+ await vi.waitFor(() => expect(h.pending).toHaveLength(1));
+ const request = h.next();
+ expect(request.filters).toEqual([
+ { kinds: [30620], "#h": [channelId], limit: 100 },
+ ]);
+ // A second refresh shares its pending read.
+ expect(definitions.refresh()).toBe(loading);
+ request.respond([definition]);
+ await loading;
+ expect(definitions.snapshot().status).toBe("ready");
+ expect(history.snapshot().status).toBe("idle");
+});
+it("authoritative revocation clears all saved and structured data before callbacks, rejects late results and denies fresh views", async () => {
+ const h = setup();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 1)]);
+ const definitions = h.session.workflows.definitions(channelId),
+ history = h.session.workflows.runs(reference);
+ const loading = definitions.refresh();
+ await vi.waitFor(() => expect(h.pending).toHaveLength(1));
+ h.next().respond([definition]);
+ await loading;
+ expect(definitions.snapshot().data.items[0]?.revision).toBe(definition.id);
+ const runRead = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(1));
+ const checked = vi.fn(() => {
+ expect(definitions.snapshot()).toMatchObject({
+ status: "unavailable",
+ data: { items: [] },
+ });
+ expect(history.snapshot()).toMatchObject({
+ status: "unavailable",
+ data: { runs: [] },
+ });
+ expect(h.session.workflows.operations.snapshot()).toEqual([]);
+ });
+ definitions.subscribe(checked);
+ history.subscribe(checked);
+ h.session.workflows.operations.subscribe(checked);
+ h.session.channels.subscribeList(checked);
+ h.emit([roster(relay, channelId, [], 2)]);
+ expect(checked).toHaveBeenCalled();
+ expect(h.runs.mock.calls[0]?.[2].aborted).toBe(true);
+ const denied = h.session.workflows.definitions(channelId);
+ expect(denied.snapshot().status).toBe("unavailable");
+ await denied.refresh();
+ expect(h.pending).toHaveLength(0);
+ h.resolveRuns({ runs: [], next: null });
+ await runRead;
+ expect(history.snapshot().status).toBe("unavailable");
+});
+it("regrant cannot resurrect stale history; clear-cache and dispose cancel interest", async () => {
+ const h = setup();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 1)]);
+ const history = h.session.workflows.runs(reference);
+ const first = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(1));
+ h.emit([roster(relay, channelId, [], 2)]);
+ h.emit([roster(relay, channelId, [viewer.pubkey], 3)]);
+ h.resolveRuns({ runs: [], next: null });
+ await first;
+ expect(history.snapshot().status).toBe("unavailable");
+ const fresh = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(2));
+ h.resolveRuns({ runs: [], next: null });
+ await fresh;
+ expect(history.snapshot().status).toBe("ready");
+ const next = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(3));
+ await h.clearCache();
+ expect(h.runs.mock.calls[2]?.[2].aborted).toBe(true);
+ expect(history.snapshot()).toMatchObject({
+ status: "idle",
+ data: { runs: [] },
+ });
+ h.resolveRuns({ runs: [], next: null });
+ await next;
+ expect(history.snapshot().status).toBe("idle");
+ h.dispose();
+ expect(history.snapshot().status).toBe("unavailable");
+});
+it("a loading observer can revoke without leaving a wedged pending read", async () => {
+ const h = setup();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 1)]);
+ const history = h.session.workflows.runs(reference);
+ const stop = history.subscribe(() => {
+ if (history.snapshot().status === "loading")
+ h.emit([roster(relay, channelId, [], 2)]);
+ });
+ await history.refresh();
+ expect(h.runs).not.toHaveBeenCalled();
+ expect(history.snapshot().status).toBe("unavailable");
+ stop();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 3)]);
+ const fresh = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(1));
+ h.resolveRuns({ runs: [], next: null });
+ await fresh;
+ expect(history.snapshot().status).toBe("ready");
+});
+it("old host keeps every workflow command unavailable, even through direct session outbox", async () => {
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey),
+ sign = vi.fn(async (template: Parameters[1]) =>
+ signed(viewer, template),
+ );
+ const owner = createRelaySession(
+ { ...wire.transport, writer: { sign, publish: async () => "" } },
+ {
+ outboxStorage: { load: async () => [], save: async () => {} },
+ },
+ );
+ owners.push(owner);
+ expect(owner.session.workflows.availability).toMatchObject({
+ save: false,
+ delete: false,
+ trigger: false,
+ });
+ const workflow = {
+ ...reference,
+ yaml: definition.content,
+ revision: definition.id,
+ createdAt: 10,
+ };
+ expect(() => owner.session.workflows.trigger(workflow)).toThrow(
+ "unavailable",
+ );
+ owner.session.outbox?.send({
+ kind: 46020,
+ tags: [
+ ["h", channelId],
+ ["d", id],
+ ],
+ content: "",
+ });
+ await vi.waitFor(() =>
+ expect(owner.session.outbox?.snapshot()[0]?.delivery).toBe("failed"),
+ );
+ expect(sign).not.toHaveBeenCalled();
+});
From 23dda527fc6f2dc3e43b4909b4126ea565a5dc32 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 08:58:56 -0600
Subject: [PATCH 03/20] test(workflows): route editor journey through browser
lanes
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
tests/browser/workflows.spec.mjs | 1 +
1 file changed, 1 insertion(+)
create mode 100644 tests/browser/workflows.spec.mjs
diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs
new file mode 100644
index 00000000..af5a0e4e
--- /dev/null
+++ b/tests/browser/workflows.spec.mjs
@@ -0,0 +1 @@
+import "../../src/bundled/workflows/workflows.journey.mjs";
From 7dcb4db41015bab36d46d39beda3072be23e9c23 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 09:00:47 -0600
Subject: [PATCH 04/20] fix(workflows): preserve legacy enabled default in
signing checks
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/features/workflows/protocol.test.ts | 22 +++++++++++++++++++++-
src/features/workflows/protocol.ts | 7 ++++---
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/src/features/workflows/protocol.test.ts b/src/features/workflows/protocol.test.ts
index cf0ad582..aabc83d6 100644
--- a/src/features/workflows/protocol.test.ts
+++ b/src/features/workflows/protocol.test.ts
@@ -74,7 +74,9 @@ it("YAML remains unchanged and malformed input/duplicate steps are rejected", ()
expect(workflowYaml(yaml)).toMatchObject({ enabled: false });
for (const text of [
"[]",
- yaml.replace("enabled: false", ""),
+ yaml.replace("enabled: false", "enabled: null"),
+ yaml.replace("enabled: false", 'enabled: "false"'),
+ yaml.replace("enabled: false", "enabled: 0"),
yaml.replace("1_send", "my-step"),
`${yaml} - id: 1_send\n action: delay\n duration: 1s\n`,
"x".repeat(24001),
@@ -83,6 +85,24 @@ it("YAML remains unchanged and malformed input/duplicate steps are rejected", ()
expect(() => workflowYaml(text)).toThrow();
}
});
+it("legacy omitted enabled and explicit toggles cross the real event boundary unchanged", () => {
+ for (const [line, enabled] of [
+ ["", true],
+ ["enabled: true\n", true],
+ ["enabled: false\n", false],
+ ] as const) {
+ const content = yaml.replace("enabled: false\n", line);
+ const event = { ...base, content };
+ expect(workflowYaml(content).enabled).toBe(enabled);
+ expect(
+ validateWorkflowEvent(event, owner, {
+ delete: false,
+ webhookSecrets: false,
+ }),
+ ).toEqual({ id, owner, channelId: id });
+ expect(event.content).toBe(content);
+ }
+});
it("run/approval rows require matching identities and preserve exact cursor precision", () => {
const row = {
id: runId,
diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts
index 4f0fcff2..f666d6d3 100644
--- a/src/features/workflows/protocol.ts
+++ b/src/features/workflows/protocol.ts
@@ -77,7 +77,7 @@ export function workflowYaml(text: string) {
!record(value) ||
typeof value.name !== "string" ||
!value.name.trim() ||
- typeof value.enabled !== "boolean" ||
+ (value.enabled !== undefined && typeof value.enabled !== "boolean") ||
!record(value.trigger) ||
typeof value.trigger.on !== "string" ||
!Array.isArray(value.steps) ||
@@ -85,7 +85,7 @@ export function workflowYaml(text: string) {
value.steps.length > 100
)
throw new Error(
- "Workflow needs a name, explicit enabled state, trigger and 1–100 steps",
+ "Workflow needs a name, boolean enabled state if present, trigger and 1–100 steps",
);
const ids = new Set();
for (const step of value.steps) {
@@ -101,7 +101,8 @@ export function workflowYaml(text: string) {
}
return {
webhook: value.trigger.on === "webhook",
- enabled: value.enabled,
+ // Match legacy WorkflowDef: omission means enabled; do not rewrite YAML.
+ enabled: value.enabled !== false,
name: value.name,
};
}
From 90b2becfa3c9452f429bb26f4d5a464ad143e678 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 09:09:27 -0600
Subject: [PATCH 05/20] fix(workflows): make broker dependencies native-loader
compatible
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/features/workflows/host.ts | 2 +-
src/features/workflows/http.ts | 8 ++++----
src/features/workflows/protocol.ts | 4 ++--
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/features/workflows/host.ts b/src/features/workflows/host.ts
index 5f486596..308dae53 100644
--- a/src/features/workflows/host.ts
+++ b/src/features/workflows/host.ts
@@ -1,4 +1,4 @@
-import type { WorkflowRunCursor } from "./types";
+import type { WorkflowRunCursor } from "./types.ts";
/** Host-owned authenticated reads on the captured relay principal/admission lane. */
export interface WorkflowHost {
diff --git a/src/features/workflows/http.ts b/src/features/workflows/http.ts
index c9c20326..0cc46320 100644
--- a/src/features/workflows/http.ts
+++ b/src/features/workflows/http.ts
@@ -1,7 +1,7 @@
-import { ReadError } from "../relay/errors";
-import { readApiFailure } from "../relay/http-admission";
-import type { WorkflowHost } from "./host";
-import { approvalsPath, record, runsPath } from "./protocol";
+import { ReadError } from "../relay/errors.ts";
+import { readApiFailure } from "../relay/http-admission.ts";
+import type { WorkflowHost } from "./host.ts";
+import { approvalsPath, record, runsPath } from "./protocol.ts";
export const WORKFLOW_READ_BYTES = 1024 * 1024;
diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts
index f666d6d3..4154dea3 100644
--- a/src/features/workflows/protocol.ts
+++ b/src/features/workflows/protocol.ts
@@ -1,12 +1,12 @@
import { parseDocument } from "yaml";
-import type { EventData } from "../relay/events";
+import type { EventData } from "../relay/events.ts";
import type {
WorkflowDefinition,
WorkflowReference,
WorkflowRunCursor,
WorkflowRunPage,
WorkflowApproval,
-} from "./types";
+} from "./types.ts";
export const WORKFLOW_KINDS = [30620, 46020, 5] as const;
export function isWorkflowOperation(
From 6167504f20ec20825ea80cea9d42e133e4429f36 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 08:59:06 -0600
Subject: [PATCH 06/20] feat(workflows): add guarded configuration editor and
run history UI
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/bundled/workflows/ConfirmAction.tsx | 46 ++
src/bundled/workflows/WorkflowChannel.tsx | 441 ++++++++++++
src/bundled/workflows/WorkflowEditor.tsx | 191 +++++
src/bundled/workflows/WorkflowForm.tsx | 217 ++++++
src/bundled/workflows/WorkflowOperations.tsx | 73 ++
src/bundled/workflows/WorkflowRuns.tsx | 190 +++++
src/bundled/workflows/WorkflowsPage.tsx | 138 ++++
src/bundled/workflows/cronExpression.test.mjs | 54 ++
src/bundled/workflows/cronExpression.ts | 157 +++++
src/bundled/workflows/editor-model.test.ts | 45 ++
src/bundled/workflows/editor-model.ts | 119 ++++
src/bundled/workflows/fixture.html | 1 +
src/bundled/workflows/fixture.tsx | 63 ++
src/bundled/workflows/fixtures.ts | 203 ++++++
src/bundled/workflows/index.tsx | 12 +
src/bundled/workflows/manifest.json | 1 +
src/bundled/workflows/useWorkflowView.ts | 16 +
.../workflowActivationWarning.test.mjs | 107 +++
.../workflows/workflowActivationWarning.ts | 161 +++++
.../workflows/workflowDuration.test.mjs | 55 ++
src/bundled/workflows/workflowDuration.ts | 129 ++++
.../workflows/workflowFormTypes.test.mjs | 322 +++++++++
src/bundled/workflows/workflowFormTypes.ts | 657 ++++++++++++++++++
.../workflows/workflowYamlDocument.test.mjs | 152 ++++
src/bundled/workflows/workflowYamlDocument.ts | 115 +++
src/bundled/workflows/workflows.css | 129 ++++
src/bundled/workflows/workflows.journey.mjs | 122 ++++
.../workflows/workflows.playwright.config.mjs | 24 +
28 files changed, 3940 insertions(+)
create mode 100644 src/bundled/workflows/ConfirmAction.tsx
create mode 100644 src/bundled/workflows/WorkflowChannel.tsx
create mode 100644 src/bundled/workflows/WorkflowEditor.tsx
create mode 100644 src/bundled/workflows/WorkflowForm.tsx
create mode 100644 src/bundled/workflows/WorkflowOperations.tsx
create mode 100644 src/bundled/workflows/WorkflowRuns.tsx
create mode 100644 src/bundled/workflows/WorkflowsPage.tsx
create mode 100644 src/bundled/workflows/cronExpression.test.mjs
create mode 100644 src/bundled/workflows/cronExpression.ts
create mode 100644 src/bundled/workflows/editor-model.test.ts
create mode 100644 src/bundled/workflows/editor-model.ts
create mode 100644 src/bundled/workflows/fixture.html
create mode 100644 src/bundled/workflows/fixture.tsx
create mode 100644 src/bundled/workflows/fixtures.ts
create mode 100644 src/bundled/workflows/index.tsx
create mode 100644 src/bundled/workflows/manifest.json
create mode 100644 src/bundled/workflows/useWorkflowView.ts
create mode 100644 src/bundled/workflows/workflowActivationWarning.test.mjs
create mode 100644 src/bundled/workflows/workflowActivationWarning.ts
create mode 100644 src/bundled/workflows/workflowDuration.test.mjs
create mode 100644 src/bundled/workflows/workflowDuration.ts
create mode 100644 src/bundled/workflows/workflowFormTypes.test.mjs
create mode 100644 src/bundled/workflows/workflowFormTypes.ts
create mode 100644 src/bundled/workflows/workflowYamlDocument.test.mjs
create mode 100644 src/bundled/workflows/workflowYamlDocument.ts
create mode 100644 src/bundled/workflows/workflows.css
create mode 100644 src/bundled/workflows/workflows.journey.mjs
create mode 100644 src/bundled/workflows/workflows.playwright.config.mjs
diff --git a/src/bundled/workflows/ConfirmAction.tsx b/src/bundled/workflows/ConfirmAction.tsx
new file mode 100644
index 00000000..206857e1
--- /dev/null
+++ b/src/bundled/workflows/ConfirmAction.tsx
@@ -0,0 +1,46 @@
+import { AlertDialog } from "@base-ui/react/alert-dialog";
+import { Button } from "../../shared/design-system/ui/Button";
+
+export function ConfirmAction({
+ title,
+ description,
+ action,
+ onConfirm,
+ onCancel,
+}: {
+ title: string;
+ description: string;
+ action: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+ {
+ if (!open) onCancel();
+ }}
+ >
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ Keep editing
+
+ {action}
+
+
+
+
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
new file mode 100644
index 00000000..953acfc6
--- /dev/null
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -0,0 +1,441 @@
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import type {
+ WorkflowCapability,
+ WorkflowDefinition,
+} from "../../features/workflows/types";
+import { Button } from "../../shared/design-system/ui/Button";
+import { ConfirmAction } from "./ConfirmAction";
+import { WorkflowEditor } from "./WorkflowEditor";
+import { WorkflowOperations } from "./WorkflowOperations";
+import { WorkflowRuns } from "./WorkflowRuns";
+import { exactSaveReadback } from "./editor-model";
+import { DEFAULT_FORM_STATE, formStateToYaml } from "./workflowFormTypes";
+import { readWorkflowDocumentFields } from "./workflowYamlDocument";
+import { useWorkflowView } from "./useWorkflowView";
+
+type Draft = {
+ original: WorkflowDefinition | undefined;
+ yaml: string;
+ initial: string;
+ operationId?: string;
+};
+
+export function WorkflowChannel({
+ capability,
+ channelId,
+ channelName,
+ viewer,
+ onDraftRiskChange,
+}: {
+ capability: WorkflowCapability;
+ channelId: string;
+ channelName: string;
+ viewer: string;
+ onDraftRiskChange?: (atRisk: boolean) => void;
+}) {
+ const view = useMemo(
+ () => capability.definitions(channelId),
+ [capability, channelId],
+ );
+ const snapshot = useWorkflowView(view);
+ const operations = useSyncExternalStore(
+ capability.operations.subscribe,
+ capability.operations.snapshot,
+ capability.operations.snapshot,
+ );
+ const submission = useRef(null);
+ const [draft, setDraft] = useState(null);
+ const [pendingSelection, setPendingSelection] = useState<
+ WorkflowDefinition | "new" | "close" | null
+ >(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+ const [readRuns, setReadRuns] = useState(false);
+ const operation = draft?.operationId
+ ? operations.find((item) => item.eventId === draft.operationId)
+ : undefined;
+ const ownOperations = operations.filter(
+ (item) => item.workflow.channelId === channelId,
+ );
+ const busy =
+ !!draft?.operationId && (!operation || operation.outcome === "pending");
+ const readonly = !!draft?.original && draft.original.owner !== viewer;
+ const dirty = !!draft && draft.yaml !== draft.initial;
+ const atRisk = dirty || !!draft?.operationId;
+ const unresolvedWrite = ownOperations.some(
+ (item) =>
+ (item.outcome === "pending" || item.outcome === "unknown") &&
+ (draft?.original
+ ? item.workflow.id === draft.original.id &&
+ item.workflow.owner === draft.original.owner
+ : item.action === "save"),
+ );
+ useEffect(() => {
+ onDraftRiskChange?.(atRisk);
+ return () => onDraftRiskChange?.(false);
+ }, [atRisk, onDraftRiskChange]);
+ useEffect(() => {
+ if (!atRisk) return;
+ const warn = (event: BeforeUnloadEvent) => event.preventDefault();
+ window.addEventListener("beforeunload", warn);
+ return () => window.removeEventListener("beforeunload", warn);
+ }, [atRisk]);
+ const open = (next: WorkflowDefinition | "new" | "close") => {
+ const yaml =
+ next === "new"
+ ? formStateToYaml({ ...DEFAULT_FORM_STATE, name: "Untitled workflow" })
+ : next === "close"
+ ? ""
+ : next.yaml;
+ setDraft(
+ next === "close"
+ ? null
+ : {
+ original: typeof next === "string" ? undefined : next,
+ yaml,
+ initial: yaml,
+ },
+ );
+ submission.current = null;
+ setError(null);
+ setReadRuns(false);
+ setPendingSelection(null);
+ setConfirmDelete(false);
+ };
+ const select = (next: WorkflowDefinition | "new" | "close") => {
+ if (atRisk) setPendingSelection(next);
+ else open(next);
+ };
+ useEffect(() => {
+ if (operation?.eventId && operation.outcome === "succeeded")
+ void view.refresh();
+ }, [operation?.eventId, operation?.outcome, view]);
+ useEffect(() => {
+ if (!operation || !draft || snapshot.status !== "ready") return;
+ const saved = exactSaveReadback(operation, snapshot.data.items);
+ if (saved) {
+ submission.current = null;
+ setDraft({ original: saved, yaml: saved.yaml, initial: saved.yaml });
+ }
+ }, [operation, snapshot, draft]);
+ // A cleared/unavailable view withdraws the saved private definition from display.
+ // Unsaved user-authored drafts never become a second retained definition cache.
+ useEffect(() => {
+ if (snapshot.status === "unavailable" || snapshot.status === "idle") {
+ submission.current = null;
+ setDraft(null);
+ setPendingSelection(null);
+ setConfirmDelete(false);
+ setReadRuns(false);
+ setError(null);
+ }
+ }, [snapshot.status]);
+ const save = () => {
+ if (
+ !draft ||
+ submission.current ||
+ busy ||
+ unresolvedWrite ||
+ readonly ||
+ !capability.availability.save ||
+ draft.operationId
+ )
+ return;
+ try {
+ submission.current = "submitting";
+ const operationId = capability.save({
+ channelId,
+ yaml: draft.yaml,
+ ...(draft.original ? { existing: draft.original } : {}),
+ });
+ submission.current = operationId;
+ setDraft({ ...draft, operationId });
+ setError(null);
+ } catch (cause) {
+ submission.current = null;
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "The workflow could not be submitted. Your draft is retained.",
+ );
+ }
+ };
+ const remove = () => {
+ if (
+ !draft?.original ||
+ submission.current ||
+ readonly ||
+ unresolvedWrite ||
+ !capability.availability.delete ||
+ draft.operationId
+ )
+ return;
+ try {
+ submission.current = "submitting";
+ const operationId = capability.delete(draft.original);
+ submission.current = operationId;
+ setDraft({ ...draft, operationId });
+ setConfirmDelete(false);
+ setError(null);
+ } catch (cause) {
+ submission.current = null;
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Deletion could not be submitted. Your draft is retained.",
+ );
+ }
+ };
+ const trigger = () => {
+ if (
+ !draft?.original ||
+ submission.current ||
+ dirty ||
+ unresolvedWrite ||
+ draft.operationId ||
+ readonly ||
+ !capability.availability.trigger
+ )
+ return;
+ try {
+ submission.current = "submitting";
+ submission.current = capability.trigger(draft.original);
+ setError(null);
+ } catch (cause) {
+ submission.current = null;
+ setError(
+ cause instanceof Error ? cause.message : "Run could not be submitted.",
+ );
+ }
+ };
+ useEffect(() => {
+ const active = operations.find(
+ (item) => item.eventId === submission.current,
+ );
+ if (
+ active?.action === "trigger" &&
+ (active.outcome === "succeeded" || active.outcome === "rejected")
+ )
+ submission.current = null;
+ }, [operations]);
+ let blocked: string | undefined;
+ if (!capability.availability.save)
+ blocked = "Saving is unavailable from this host.";
+ else if (unresolvedWrite && !draft?.operationId)
+ blocked =
+ "An operation for this workflow is unresolved. Review its retained identity below; do not submit a replacement.";
+ else if (draft?.operationId)
+ blocked =
+ operation?.outcome === "succeeded"
+ ? operation.action === "delete"
+ ? "Deletion completed. Close this draft; the configuration list is being refreshed."
+ : "Save completed; waiting for a readback of this exact signed revision. A different head must be reviewed before editing again."
+ : operation?.outcome === "rejected"
+ ? "Save rejected. Your draft is retained; review the error before retrying."
+ : "This operation has not been resolved. Your draft and operation identity are retained.";
+ return (
+
+
+
Saved configurations
+ void view.refresh()}
+ >
+ Refresh configurations
+
+ select("new")}
+ >
+ New workflow
+
+
+
+ Configured state is not runtime health. Historical configurations may no
+ longer have a runtime workflow.
+
+ {snapshot.status === "loading" && (
+ Reading configurations…
+ )}
+ {snapshot.status === "idle" && (
+ Configurations cleared. Refresh to read again.
+ )}
+ {snapshot.status === "unavailable" && (
+ Workflow definitions are unavailable.
+ )}
+ {snapshot.status === "error" && (
+
+ {snapshot.error ??
+ "Configurations could not be read. Use Refresh configurations to retry."}
+
+ )}
+ {snapshot.data.partial && (
+ This is a bounded, partial list.
+ )}
+ {snapshot.status === "ready" && !snapshot.data.items.length && (
+
+ No saved configurations returned for this channel. Create a disabled
+ draft to start.
+
+ )}
+
+ {snapshot.data.items.map((definition) => {
+ const header = readWorkflowDocumentFields(definition.yaml);
+ return (
+
+ select(definition)}>
+ {header.name || "Unnamed or malformed workflow"}
+
+
+ {header.editable
+ ? header.enabled === false
+ ? "Configured disabled"
+ : "Configured enabled"
+ : "Unreadable configuration"}
+ {definition.owner !== viewer ? " · Read-only" : ""}
+
+
+ );
+ })}
+
+ {draft &&
+ snapshot.status !== "unavailable" &&
+ snapshot.status !== "idle" && (
+
+
+
+ {draft.original ? "Workflow details" : "New workflow"}
+
+ select("close")}>Close editor
+
+ {draft.original && (
+ <>
+
+ Owner: {draft.original.owner}
+
+
+ Revision: {draft.original.revision}
+
+ >
+ )}
+ {readonly && (
+
+ This definition belongs to another identity. Only its author can
+ manage it here.
+
+ )}
+
setDraft({ ...draft, yaml })}
+ onSave={save}
+ readOnly={readonly}
+ busy={busy}
+ locked={!!draft.operationId}
+ blocked={blocked}
+ />
+
+ Drafts stay in this editor only. Leaving the Workflows page or
+ reloading discards unsaved text, but does not cancel submitted
+ operations.
+
+ {error && (
+
+ {error}
+
+ )}
+ {operation?.outcome === "rejected" && (
+ {
+ submission.current = null;
+ const { operationId: _, ...rest } = draft;
+ setDraft(rest);
+ }}
+ >
+ Continue editing retained draft
+
+ )}
+ {draft.original && (
+
+ setReadRuns((value) => !value)}
+ >
+ {readRuns ? "Hide runs" : "Read runs"}
+
+ {!readonly && (
+ <>
+
+ Run now
+
+ setConfirmDelete(true)}
+ >
+ Delete workflow
+
+ >
+ )}
+
+ )}
+ {draft.original && !capability.availability.delete && !readonly && (
+
+ Delete is unavailable until the relay proves support for
+ consistent workflow deletion.
+
+ )}
+ {draft.original && readRuns && (
+
+ )}
+ {confirmDelete && (
+ setConfirmDelete(false)}
+ />
+ )}
+
+ )}
+
+ {pendingSelection &&
+ snapshot.status !== "idle" &&
+ snapshot.status !== "unavailable" && (
+ open(pendingSelection)}
+ onCancel={() => setPendingSelection(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
new file mode 100644
index 00000000..00907ffc
--- /dev/null
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -0,0 +1,191 @@
+import { Input } from "@base-ui/react/input";
+import { useId, useState } from "react";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Switch } from "../../shared/design-system/ui/Switch";
+import { Tabs } from "../../shared/design-system/ui/Tabs";
+import { ConfirmAction } from "./ConfirmAction";
+import { WorkflowForm } from "./WorkflowForm";
+import { draftError, hasWebhookTrigger, visualForm } from "./editor-model";
+import { getWorkflowActivationWarning } from "./workflowActivationWarning";
+import { formStateToYaml, type WorkflowFormState } from "./workflowFormTypes";
+import {
+ readWorkflowDocumentFields,
+ yamlWithWorkflowEnabled,
+ yamlWithWorkflowName,
+} from "./workflowYamlDocument";
+
+export function WorkflowEditor({
+ yaml,
+ onChange,
+ onSave,
+ readOnly = false,
+ blocked,
+ busy = false,
+ locked = false,
+}: {
+ yaml: string;
+ onChange: (yaml: string) => void;
+ onSave: () => void;
+ readOnly?: boolean;
+ blocked?: string | undefined;
+ busy?: boolean;
+ locked?: boolean;
+}) {
+ const id = useId();
+ const [mode, setMode] = useState<"form" | "yaml">(() =>
+ visualForm(yaml).ok ? "form" : "yaml",
+ );
+ const [formDraft, setFormDraft] = useState(null);
+ const [formYaml, setFormYaml] = useState(yaml);
+ const [activating, setActivating] = useState(false);
+ const [modeError, setModeError] = useState(null);
+ const fields = readWorkflowDocumentFields(yaml);
+ const parsed = visualForm(yaml);
+ // An incomplete form is still an editable draft. An external YAML change must
+ // be reparsed instead of reviving stale form state.
+ const form =
+ formYaml === yaml && formDraft
+ ? formDraft
+ : parsed.ok
+ ? parsed.state
+ : null;
+ const error = draftError(yaml);
+ const secretGate = hasWebhookTrigger(yaml)
+ ? "Webhook-trigger saves are unavailable until secure one-time-secret display is supported."
+ : undefined;
+ const unavailable = blocked || secretGate;
+ const mutateForm = (state: WorkflowFormState) => {
+ const next = formStateToYaml(state);
+ setFormDraft(state);
+ setFormYaml(next);
+ onChange(next);
+ };
+ const changeHeader = (
+ next: string | null,
+ patch: Partial,
+ ) => {
+ if (next === null) return;
+ if (form) {
+ setFormDraft({ ...form, ...patch });
+ setFormYaml(next);
+ }
+ onChange(next);
+ };
+ const warning = getWorkflowActivationWarning(yaml);
+ const submit = () => {
+ if (readOnly || busy || locked || unavailable || error) return;
+ if (fields.enabled !== false) setActivating(true);
+ else onSave();
+ };
+ return (
+
+
+
+ Workflow name
+
+ changeHeader(yamlWithWorkflowName(yaml, name), { name })
+ }
+ />
+
+
+ changeHeader(yamlWithWorkflowEnabled(yaml, enabled), { enabled })
+ }
+ />
+
+ {
+ if (next === "form" && !form) {
+ setModeError(parsed.ok ? null : parsed.error);
+ return;
+ }
+ setModeError(null);
+ setMode(next);
+ }}
+ />
+ {modeError && (
+
+ {modeError}
+
+ )}
+ {mode === "form" && form ? (
+
+ ) : (
+
+ Workflow YAML
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {unavailable && (
+
+ {unavailable}
+
+ )}
+ {!readOnly && (
+
+ {busy ? "Saving…" : "Save workflow"}
+
+ )}
+ {activating && (
+ setActivating(false)}
+ onConfirm={() => {
+ setActivating(false);
+ onSave();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowForm.tsx b/src/bundled/workflows/WorkflowForm.tsx
new file mode 100644
index 00000000..e66e0a40
--- /dev/null
+++ b/src/bundled/workflows/WorkflowForm.tsx
@@ -0,0 +1,217 @@
+import { useId } from "react";
+import { Input } from "@base-ui/react/input";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Select } from "../../shared/design-system/ui/Select";
+import { Switch } from "../../shared/design-system/ui/Switch";
+import { formWithStep } from "./editor-model";
+import {
+ ACTION_LABELS,
+ nextStepId,
+ withTriggerType,
+ type WorkflowFormState,
+} from "./workflowFormTypes";
+
+export function WorkflowForm({
+ state,
+ onChange,
+ disabled,
+}: {
+ state: WorkflowFormState;
+ onChange: (next: WorkflowFormState) => void;
+ disabled: boolean;
+}) {
+ const id = useId();
+ return (
+
+
+ Description
+ onChange({ ...state, description })}
+ />
+
+ {
+ if (
+ !disabled &&
+ (value === "message_posted" || value === "reaction_added")
+ )
+ onChange(withTriggerType(state, value));
+ }}
+ />
+ {state.trigger.on === "reaction_added" && (
+
+ Emoji (optional)
+
+ onChange({ ...state, trigger: { ...state.trigger, emoji } })
+ }
+ />
+
+ )}
+
+ Trigger condition (optional)
+
+ onChange({ ...state, trigger: { ...state.trigger, filter } })
+ }
+ />
+
+ An evalexpr expression; leave empty to match every event of this type.
+
+
+
+ {state.steps.map((step, index) => (
+
+
+
+ Step {index + 1}: {ACTION_LABELS[step.action]}
+
+
+ onChange({
+ ...state,
+ steps: state.steps.filter((item) => item.id !== step.id),
+ })
+ }
+ >
+ Remove step {index + 1}
+
+
+ {step.id}
+
+ Step name (optional)
+
+ onChange(formWithStep(state, step.id, { name }))
+ }
+ />
+
+ {step.action === "send_message" ? (
+ <>
+
+ Message text
+
+
+ Destination channel UUID (optional)
+
+ onChange(formWithStep(state, step.id, { channel }))
+ }
+ />
+
+ Blank uses this workflow’s channel. The relay checks
+ destination access.
+
+
+
+ onChange(formWithStep(state, step.id, { replyInThread }))
+ }
+ />
+ >
+ ) : (
+
+ Delay duration
+
+ onChange(formWithStep(state, step.id, { duration }))
+ }
+ />
+
+ )}
+
+ Step timeout (optional)
+
+ onChange(formWithStep(state, step.id, { timeoutSecs }))
+ }
+ />
+
+
+ ))}
+
+
+
+ onChange({
+ ...state,
+ steps: [
+ ...state.steps,
+ {
+ id: nextStepId(state.steps),
+ action: "send_message",
+ text: "",
+ },
+ ],
+ })
+ }
+ >
+ Add Send Message
+
+
+ onChange({
+ ...state,
+ steps: [
+ ...state.steps,
+ {
+ id: nextStepId(state.steps),
+ action: "delay",
+ duration: "5m",
+ },
+ ],
+ })
+ }
+ >
+ Add Delay
+
+
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowOperations.tsx b/src/bundled/workflows/WorkflowOperations.tsx
new file mode 100644
index 00000000..3bd99baa
--- /dev/null
+++ b/src/bundled/workflows/WorkflowOperations.tsx
@@ -0,0 +1,73 @@
+import { useState } from "react";
+import { Button } from "../../shared/design-system/ui/Button";
+import type {
+ WorkflowCapability,
+ WorkflowOperation,
+} from "../../features/workflows/types";
+
+export function WorkflowOperations({
+ operations,
+ capability,
+}: {
+ operations: readonly WorkflowOperation[];
+ capability: WorkflowCapability;
+}) {
+ const [error, setError] = useState(null);
+ if (!operations.length) return null;
+ return (
+
+ Recent operations
+ {error && {error}
}
+ {operations.map((operation) => (
+
+
+ {operation.action}: {operation.outcome} · delivery{" "}
+ {operation.delivery}
+
+
{operation.eventId}
+ {operation.error &&
{operation.error}
}
+ {operation.outcome === "unknown" && (
+
+ The outcome is unknown. Do not submit a new operation to repeat
+ it; its signed identity is retained by the host. Exact replay may
+ confirm delivery but cannot recover a lost run or secret receipt.
+
+ )}
+ {operation.outcome === "unknown" && (
+
{
+ try {
+ capability.operations.retry(operation.eventId);
+ setError(null);
+ } catch (cause) {
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Retry could not be requested.",
+ );
+ }
+ }}
+ >
+ Retry same signed operation
+
+ )}
+ {operation.runId && (
+
+ Returned run ID:{" "}
+
+ {operation.runId}
+
+
+ )}
+ {operation.secretAvailable && (
+
+ A one-time secret is available, but secure reveal is not supported
+ by this UI yet.
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowRuns.tsx b/src/bundled/workflows/WorkflowRuns.tsx
new file mode 100644
index 00000000..e70b44fe
--- /dev/null
+++ b/src/bundled/workflows/WorkflowRuns.tsx
@@ -0,0 +1,190 @@
+import { useMemo, useState } from "react";
+import type {
+ WorkflowCapability,
+ WorkflowDefinition,
+ WorkflowRunCursor,
+ WorkflowReference,
+} from "../../features/workflows/types";
+import { Button } from "../../shared/design-system/ui/Button";
+import { useWorkflowView } from "./useWorkflowView";
+
+export function WorkflowRuns({
+ capability,
+ workflow,
+}: {
+ capability: WorkflowCapability;
+ workflow: WorkflowDefinition;
+}) {
+ const [cursor, setCursor] = useState();
+ if (!capability.availability.history)
+ return (
+
+ Run history is unavailable from this host.
+
+ );
+ return (
+
+ );
+}
+function RunPage({
+ capability,
+ workflow,
+ cursor,
+ onPage,
+}: {
+ capability: WorkflowCapability;
+ workflow: WorkflowReference;
+ cursor: WorkflowRunCursor | undefined;
+ onPage: (cursor: WorkflowRunCursor | undefined) => void;
+}) {
+ const view = useMemo(
+ () => capability.runs(workflow, cursor),
+ [capability, workflow, cursor],
+ );
+ const snapshot = useWorkflowView(view);
+ return (
+
+
+
Runs
+ void view.refresh()}
+ >
+ Refresh runs
+
+
+ {snapshot.status === "loading" && Reading runs…
}
+ {snapshot.status === "unavailable" && (
+
+ Run history is unavailable. This does not mean the workflow was
+ deleted.
+
+ )}
+ {snapshot.status === "idle" && (
+ History cleared. Refresh to read it again.
+ )}
+ {snapshot.status === "error" && (
+
+ {snapshot.error ??
+ "Run history could not be read. Retry with Refresh runs."}
+
+ )}
+ {snapshot.status === "ready" && !snapshot.data.runs.length && (
+ No runs returned on this page.
+ )}
+ {snapshot.data.runs.map((run) => (
+
+
+ {run.status.replaceAll("_", " ")} ·{" "}
+ {new Date(run.createdAt * 1000).toISOString()}
+
+ {run.id}
+
+ Current step: {run.currentStep}
+
+ {(run.errorCode || run.errorMessage) && (
+
+ {run.errorCode}: {run.errorMessage}
+
+ )}
+
+ Execution trace
+
+ {JSON.stringify(run.trace, null, 2)}
+
+
+
+ Approval history (read-only)
+
+
+
+ ))}
+
+ {cursor && (
+ onPage(undefined)}>Newest runs
+ )}
+ {snapshot.status === "ready" && snapshot.data.next && (
+ {
+ if (snapshot.data.next) onPage(snapshot.data.next);
+ }}
+ >
+ Older runs
+
+ )}
+
+
+ );
+}
+function ApprovalHistory({
+ capability,
+ workflow,
+ runId,
+}: {
+ capability: WorkflowCapability;
+ workflow: WorkflowReference;
+ runId: string;
+}) {
+ const [opened, setOpened] = useState(false);
+ return opened ? (
+
+ ) : (
+ setOpened(true)}>
+ Read approvals
+
+ );
+}
+function ApprovalRows({
+ capability,
+ workflow,
+ runId,
+}: {
+ capability: WorkflowCapability;
+ workflow: WorkflowReference;
+ runId: string;
+}) {
+ const view = useMemo(
+ () => capability.approvals(workflow, runId),
+ [capability, workflow, runId],
+ );
+ const snapshot = useWorkflowView(view);
+ return (
+
+ {snapshot.status === "ready" ? (
+ snapshot.data.length ? (
+
+ {snapshot.data.map((row) => (
+
+ {row.stepId}: {row.status}
+ {row.note ? ` — ${row.note}` : ""}
+
+ ))}
+
+ ) : (
+
No approval history returned.
+ )
+ ) : (
+
+ {snapshot.error ?? `Approval history: ${snapshot.status}.`}
+
+ )}
+
void view.refresh()}
+ >
+ Refresh approvals
+
+
+ );
+}
diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx
new file mode 100644
index 00000000..bbaf73a2
--- /dev/null
+++ b/src/bundled/workflows/WorkflowsPage.tsx
@@ -0,0 +1,138 @@
+import { useState } from "react";
+import type { RelayData } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
+import { useChannelList, useRelayConnection } from "../../features/relay/react";
+import type { WorkflowCapability } from "../../features/workflows/types";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Panel } from "../../shared/design-system/ui/Panel";
+import { Select } from "../../shared/design-system/ui/Select";
+import { WorkflowChannel } from "./WorkflowChannel";
+import { ConfirmAction } from "./ConfirmAction";
+import "./workflows.css";
+
+export function WorkflowsPage({ relay }: { relay: RelayData }) {
+ const connection = useRelayConnection(relay);
+ return (
+
+
+
Workflows
+
+ Saved automation configurations for this community. The relay runs
+ workflows, even when this page is closed.
+
+ {connection.status === "ready" ? (
+
+ ) : (
+
+
+ {connection.status === "connecting"
+ ? "Connecting to your community…"
+ : "Connect to a community to browse workflows."}
+
+ {connection.status === "error" && (
+ <>
+
{connection.error}
+
relay.retry()}>Retry connection
+ >
+ )}
+
+ )}
+
+
+ );
+}
+export function WorkflowCommunity({
+ session,
+ viewer,
+}: {
+ session: RelaySession;
+ viewer: string;
+}) {
+ // Typed optional only during staged host integration; not an alternate capability.
+ const capability = (
+ session as RelaySession & { workflows?: WorkflowCapability }
+ ).workflows;
+ const channels = useChannelList(session.channels);
+ const [selected, setSelected] = useState("");
+ const [draftAtRisk, setDraftAtRisk] = useState(false);
+ const [pendingChannel, setPendingChannel] = useState(null);
+ const channel = channels.channels.find((item) => item.id === selected);
+ if (!capability)
+ return (
+ Workflow operations are unavailable from this host.
+ );
+ return (
+ <>
+ {channels.status === "loading" && Reading channels…
}
+ {channels.status === "error" && (
+
+
+ {channels.error ?? "Channels could not be read."}
+
+
session.channels.refreshList?.()}>
+ Retry channels
+
+
+ )}
+ {channels.channels.length > 0 && (
+ ({
+ value: item.id,
+ label: item.name,
+ })),
+ ],
+ },
+ ]}
+ onValueChange={(next) => {
+ if (next === selected) return;
+ if (draftAtRisk) setPendingChannel(next);
+ else setSelected(next);
+ }}
+ />
+ )}
+ {channels.status === "ready" && !channels.channels.length && (
+ No channels are available in this community.
+ )}
+ {channels.coverage === "partial" && (
+ The channel list is partial.
+ )}
+ {pendingChannel !== null && (
+ setPendingChannel(null)}
+ onConfirm={() => {
+ setSelected(pendingChannel);
+ setPendingChannel(null);
+ }}
+ />
+ )}
+ {channel ? (
+
+ ) : (
+ channels.channels.length > 0 && (
+ Choose a channel to read its saved configurations.
+ )
+ )}
+ >
+ );
+}
diff --git a/src/bundled/workflows/cronExpression.test.mjs b/src/bundled/workflows/cronExpression.test.mjs
new file mode 100644
index 00000000..9981c458
--- /dev/null
+++ b/src/bundled/workflows/cronExpression.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ CRON_FIELD_DEFINITIONS,
+ cronExpressionError,
+ cronExpressionFromFields,
+ cronFieldsFromPaste,
+ validateCronField,
+ validateCronFields,
+} from "./cronExpression.ts";
+
+test("accepts supported five-field cron syntax", () => {
+ for (const expression of [
+ "0 9 * * 1-5",
+ "*/15 * * * *",
+ "0 */2 1,15 JAN,MAR MON-FRI",
+ ]) {
+ const result = cronFieldsFromPaste(expression);
+ assert.equal(result.ok, true);
+ assert.deepEqual(validateCronFields(result.fields), [
+ null,
+ null,
+ null,
+ null,
+ null,
+ ]);
+ assert.equal(cronExpressionFromFields(result.fields), expression);
+ assert.equal(cronExpressionError(expression), null);
+ }
+});
+
+test("validates cron field ranges and structure locally", () => {
+ assert.equal(
+ validateCronField("60", CRON_FIELD_DEFINITIONS[0]),
+ "Minute must be between 0 and 59.",
+ );
+ assert.equal(
+ validateCronField("5-2", CRON_FIELD_DEFINITIONS[2]),
+ "Day range must go from lower to higher.",
+ );
+ assert.equal(
+ validateCronField("*/0", CRON_FIELD_DEFINITIONS[1]),
+ "Hour step must be a positive whole number.",
+ );
+});
+
+test("whole-expression validation requires exactly five fields", () => {
+ assert.deepEqual(cronFieldsFromPaste("0 9 * *"), {
+ error: "Paste a 5-field cron expression. Found 4 fields.",
+ ok: false,
+ });
+ assert.match(cronExpressionError("not-a-cron"), /Found 1 field/);
+});
diff --git a/src/bundled/workflows/cronExpression.ts b/src/bundled/workflows/cronExpression.ts
new file mode 100644
index 00000000..8d607ad4
--- /dev/null
+++ b/src/bundled/workflows/cronExpression.ts
@@ -0,0 +1,157 @@
+// Adapted from block/buzz desktop workflow helpers at b9392d9d.
+export const CRON_FIELD_DEFINITIONS = [
+ { label: "Minute", max: 59, min: 0 },
+ { label: "Hour", max: 23, min: 0 },
+ { label: "Day", max: 31, min: 1 },
+ {
+ aliases: [
+ "JAN",
+ "FEB",
+ "MAR",
+ "APR",
+ "MAY",
+ "JUN",
+ "JUL",
+ "AUG",
+ "SEP",
+ "OCT",
+ "NOV",
+ "DEC",
+ ],
+ label: "Month",
+ max: 12,
+ min: 1,
+ },
+ {
+ aliases: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"],
+ label: "Weekday",
+ max: 7,
+ min: 0,
+ },
+] as const;
+
+export type CronFields = [string, string, string, string, string];
+
+export function cronFieldsFromExpression(expression: string): CronFields {
+ const values = expression.trim() ? expression.trim().split(/\s+/) : [];
+ return [
+ values[0] ?? "",
+ values[1] ?? "",
+ values[2] ?? "",
+ values[3] ?? "",
+ values[4] ?? "",
+ ];
+}
+
+export function cronExpressionFromFields(fields: CronFields): string {
+ return fields.join(" ");
+}
+
+export function normalizeCronExpression(expression: string): string {
+ return expression.trim().replace(/\s+/g, " ");
+}
+
+export function cronFieldsFromPaste(
+ pastedValue: string,
+): { fields: CronFields; ok: true } | { error: string; ok: false } {
+ const values = pastedValue.trim().split(/\s+/);
+ if (values.length !== CRON_FIELD_DEFINITIONS.length) {
+ return {
+ error: `Paste a 5-field cron expression. Found ${values.length} field${values.length === 1 ? "" : "s"}.`,
+ ok: false,
+ };
+ }
+ return { fields: values as CronFields, ok: true };
+}
+
+type CronFieldDefinition = (typeof CRON_FIELD_DEFINITIONS)[number];
+
+function atomError(
+ atom: string,
+ definition: CronFieldDefinition,
+): string | null {
+ const upperAtom = atom.toUpperCase();
+ if (
+ "aliases" in definition &&
+ definition.aliases.includes(upperAtom as never)
+ ) {
+ return null;
+ }
+ if (!/^\d+$/.test(atom)) {
+ return `${definition.label} contains “${atom}”, which is not a supported value.`;
+ }
+
+ const value = Number(atom);
+ if (value < definition.min || value > definition.max) {
+ return `${definition.label} must be between ${definition.min} and ${definition.max}.`;
+ }
+ return null;
+}
+
+function segmentError(
+ segment: string,
+ definition: CronFieldDefinition,
+): string | null {
+ const stepParts = segment.split("/");
+ if (stepParts.length > 2 || stepParts.some((part) => !part)) {
+ return `${definition.label} has an invalid step.`;
+ }
+
+ const [base = "", step] = stepParts;
+ if (step !== undefined) {
+ if (!/^\d+$/.test(step) || Number(step) < 1) {
+ return `${definition.label} step must be a positive whole number.`;
+ }
+ }
+
+ if (base === "*") return null;
+
+ const rangeParts = base.split("-");
+ if (rangeParts.length > 2 || rangeParts.some((part) => !part)) {
+ return `${definition.label} has an invalid range.`;
+ }
+
+ const startError = atomError(rangeParts[0] ?? "", definition);
+ if (startError) return startError;
+ if (rangeParts.length === 1) return null;
+
+ const endError = atomError(rangeParts[1] ?? "", definition);
+ if (endError) return endError;
+
+ const start = Number(rangeParts[0]);
+ const end = Number(rangeParts[1]);
+ if (Number.isFinite(start) && Number.isFinite(end) && start > end) {
+ return `${definition.label} range must go from lower to higher.`;
+ }
+ return null;
+}
+
+export function validateCronField(
+ value: string,
+ definition: CronFieldDefinition,
+): string | null {
+ if (!value) return `${definition.label} is required.`;
+
+ const segments = value.split(",");
+ if (segments.some((segment) => !segment)) {
+ return `${definition.label} has an empty list item.`;
+ }
+
+ for (const segment of segments) {
+ const error = segmentError(segment, definition);
+ if (error) return error;
+ }
+ return null;
+}
+
+export function validateCronFields(fields: CronFields): Array {
+ return CRON_FIELD_DEFINITIONS.map((definition, index) =>
+ validateCronField(fields[index] ?? "", definition),
+ );
+}
+
+export function cronExpressionError(expression: string): string | null {
+ const parsed = cronFieldsFromPaste(expression);
+ if (!parsed.ok) return parsed.error;
+ return validateCronFields(parsed.fields).find(Boolean) ?? null;
+}
diff --git a/src/bundled/workflows/editor-model.test.ts b/src/bundled/workflows/editor-model.test.ts
new file mode 100644
index 00000000..030f9d4d
--- /dev/null
+++ b/src/bundled/workflows/editor-model.test.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "vitest";
+import { draftError, exactSaveReadback, visualForm } from "./editor-model";
+import {
+ createWorkflowFixture,
+ fixtureDefinition,
+ fixtureYaml,
+} from "./fixtures";
+
+test("advanced fields stay raw; opening does not transform source", () => {
+ expect(visualForm(fixtureYaml).ok).toBe(true);
+ expect(visualForm(`${fixtureYaml}future: retain-me\n`).ok).toBe(false);
+ expect(visualForm(fixtureYaml.replace("message_posted", "webhook")).ok).toBe(
+ false,
+ );
+ expect(
+ visualForm(fixtureYaml.replace(" text:", " if: true\n text:")).ok,
+ ).toBe(false);
+ expect(draftError(fixtureYaml)).toBeNull();
+ expect(
+ draftError(fixtureYaml.replace("text: Hello from a fixture", "text: ''")),
+ ).toMatch(/message text/);
+});
+test("save readback requires operation revision plus owner/channel/id", () => {
+ const fixture = createWorkflowFixture();
+ const eventId = fixture.capability.save({
+ channelId: fixtureDefinition.channelId,
+ yaml: fixtureYaml,
+ existing: fixtureDefinition,
+ });
+ fixture.finish("succeeded");
+ const operation = fixture.capability.operations.snapshot()[0];
+ if (!operation) throw new Error("fixture operation missing");
+ const exact = { ...fixtureDefinition, revision: eventId };
+ expect(exactSaveReadback(operation, [exact])).toEqual(exact);
+ for (const wrong of [
+ fixtureDefinition,
+ { ...exact, owner: "22".repeat(32) },
+ { ...exact, channelId: "other" },
+ { ...exact, id: "other" },
+ ])
+ expect(exactSaveReadback(operation, [wrong])).toBeUndefined();
+ expect(
+ exactSaveReadback({ ...operation, outcome: "unknown" }, [exact]),
+ ).toBeUndefined();
+});
diff --git a/src/bundled/workflows/editor-model.ts b/src/bundled/workflows/editor-model.ts
new file mode 100644
index 00000000..873e2c26
--- /dev/null
+++ b/src/bundled/workflows/editor-model.ts
@@ -0,0 +1,119 @@
+import { parseDocument, isMap } from "yaml";
+import type {
+ WorkflowDefinition,
+ WorkflowOperation,
+} from "../../features/workflows/types";
+import { yamlToFormState, type WorkflowFormState } from "./workflowFormTypes";
+
+export const EDITOR_TRIGGERS = ["message_posted", "reaction_added"] as const;
+export const EDITOR_ACTIONS = ["send_message", "delay"] as const;
+
+/** A smaller visual menu must not silently take ownership of advanced YAML. */
+export function visualForm(yaml: string): ReturnType {
+ const parsed = yamlToFormState(yaml);
+ if (!parsed.ok) return parsed;
+ if (
+ !EDITOR_TRIGGERS.some((on) => on === parsed.state.trigger.on) ||
+ parsed.state.steps.some(
+ (step) => !EDITOR_ACTIONS.some((action) => action === step.action),
+ )
+ ) {
+ return {
+ ok: false,
+ error:
+ "This definition uses advanced triggers or actions. Keep editing its original YAML.",
+ };
+ }
+ return parsed;
+}
+
+/** Draft shape validation is not relay authorization or a promise of execution. */
+export function draftError(yaml: string): string | null {
+ if (new TextEncoder().encode(yaml).length > 64 * 1024)
+ return "The draft is too large (64 KiB maximum).";
+ try {
+ const doc = parseDocument(yaml);
+ if (doc.errors.length)
+ return "The YAML cannot be parsed. Correct it before saving.";
+ if (!isMap(doc.contents)) return "The definition must be a YAML object.";
+ const data = doc.toJS() as Record;
+ if (typeof data.name !== "string" || !data.name.trim())
+ return "Give this workflow a name.";
+ if (data.enabled !== undefined && typeof data.enabled !== "boolean")
+ return "enabled must be true or false.";
+ if (
+ !data.trigger ||
+ typeof data.trigger !== "object" ||
+ Array.isArray(data.trigger) ||
+ typeof (data.trigger as Record).on !== "string"
+ )
+ return "Choose a trigger.";
+ if (!Array.isArray(data.steps) || !data.steps.length)
+ return "Add at least one step.";
+ const ids = new Set();
+ for (const step of data.steps) {
+ if (
+ !step ||
+ typeof step !== "object" ||
+ typeof step.id !== "string" ||
+ !/^[A-Za-z0-9_]{1,64}$/.test(step.id) ||
+ ids.has(step.id)
+ )
+ return "Step IDs must be unique, with 1–64 letters, digits or underscores.";
+ ids.add(step.id);
+ if (typeof step.action !== "string") return "Each step needs an action.";
+ if (
+ step.action === "send_message" &&
+ (typeof step.text !== "string" || !step.text.trim())
+ )
+ return "Each Send Message step needs message text.";
+ if (
+ step.action === "delay" &&
+ (typeof step.duration !== "string" || !step.duration.trim())
+ )
+ return "Each Delay step needs a duration.";
+ }
+ return null;
+ } catch {
+ return "The YAML cannot be parsed. Correct it before saving.";
+ }
+}
+
+/** No secret display exists in this slice: do not offer webhook-trigger writes. */
+export function hasWebhookTrigger(yaml: string): boolean {
+ try {
+ const data = parseDocument(yaml).toJS();
+ return data?.trigger?.on === "webhook";
+ } catch {
+ return false;
+ }
+}
+
+export function formWithStep(
+ state: WorkflowFormState,
+ id: string,
+ patch: Partial,
+): WorkflowFormState {
+ return {
+ ...state,
+ steps: state.steps.map((step) =>
+ step.id === id ? { ...step, ...patch } : step,
+ ),
+ };
+}
+
+/** Never adopt another editor's head as readback for our successful save. */
+export function exactSaveReadback(
+ operation: WorkflowOperation,
+ definitions: readonly WorkflowDefinition[],
+): WorkflowDefinition | undefined {
+ if (operation.action !== "save" || operation.outcome !== "succeeded")
+ return undefined;
+ return definitions.find(
+ (definition) =>
+ definition.revision === operation.eventId &&
+ definition.id === operation.workflow.id &&
+ definition.owner === operation.workflow.owner &&
+ definition.channelId === operation.workflow.channelId,
+ );
+}
diff --git a/src/bundled/workflows/fixture.html b/src/bundled/workflows/fixture.html
new file mode 100644
index 00000000..939bbd00
--- /dev/null
+++ b/src/bundled/workflows/fixture.html
@@ -0,0 +1 @@
+Offline workflow fixture
\ No newline at end of file
diff --git a/src/bundled/workflows/fixture.tsx b/src/bundled/workflows/fixture.tsx
new file mode 100644
index 00000000..b34046a1
--- /dev/null
+++ b/src/bundled/workflows/fixture.tsx
@@ -0,0 +1,63 @@
+import { createRoot } from "react-dom/client";
+import { useState } from "react";
+import "../../shared/styles/globals.css";
+import "./workflows.css";
+import { Panel } from "../../shared/design-system/ui/Panel";
+import { Button } from "../../shared/design-system/ui/Button";
+import { WorkflowChannel } from "./WorkflowChannel";
+import {
+ createWorkflowFixture,
+ fixtureChannel,
+ fixtureViewer,
+} from "./fixtures";
+
+const fixture = createWorkflowFixture();
+Object.assign(window, { workflowFixture: fixture });
+function Fixture() {
+ const [mounted, setMounted] = useState(true);
+ return (
+
+ Offline fixture — no relay or signing identity.
+
+ fixture.finish("succeeded")}>
+ Complete exact save
+
+ fixture.finish("succeeded", false)}>
+ Complete concurrent head
+
+ fixture.finish("rejected")}>
+ Reject operation
+
+ fixture.finish("unknown")}>
+ Unknown operation
+
+ fixture.revoke()}>Revoke access
+ setMounted(false)}>Unmount plugin
+ {
+ document.documentElement.dataset.colorMode =
+ document.documentElement.dataset.colorMode === "dark"
+ ? "light"
+ : "dark";
+ }}
+ >
+ Toggle appearance
+
+
+
+
+ {mounted && (
+
+ )}
+
+
+
+ );
+}
+const root = document.getElementById("root");
+if (root) createRoot(root).render( );
diff --git a/src/bundled/workflows/fixtures.ts b/src/bundled/workflows/fixtures.ts
new file mode 100644
index 00000000..f9c496e8
--- /dev/null
+++ b/src/bundled/workflows/fixtures.ts
@@ -0,0 +1,203 @@
+import type {
+ WorkflowCapability,
+ WorkflowDefinition,
+ WorkflowOperation,
+ WorkflowView,
+} from "../../features/workflows/types";
+
+export const fixtureViewer = "11".repeat(32);
+export const fixtureChannel = "44444444-4444-4444-8444-444444444444";
+export const fixtureYaml = `# Keep this comment on opening
+name: Message helper
+enabled: false
+trigger:
+ on: message_posted
+steps:
+ - id: notify
+ action: send_message
+ text: Hello from a fixture
+`;
+export const fixtureDefinition: WorkflowDefinition = {
+ id: "55555555-5555-4555-8555-555555555555",
+ owner: fixtureViewer,
+ channelId: fixtureChannel,
+ revision: "aa".repeat(32),
+ createdAt: 1_789_224_000,
+ yaml: fixtureYaml,
+};
+
+export function fixtureView(data: T) {
+ let current: ReturnType["snapshot"]> = {
+ status: "idle",
+ data,
+ };
+ const listeners = new Set<() => void>();
+ let disposed = false;
+ const update = (next: typeof current) => {
+ current = next;
+ for (const listener of listeners) listener();
+ };
+ const view: WorkflowView = {
+ snapshot: () => current,
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ async refresh() {
+ if (!disposed && current.status !== "unavailable")
+ update({ ...current, status: "ready" });
+ },
+ dispose() {
+ disposed = true;
+ listeners.clear();
+ },
+ };
+ return { view, update, disposed: () => disposed };
+}
+
+/** Explicit offline test capability; never used by the bundled plugin entry. */
+export function createWorkflowFixture() {
+ const definitions = fixtureView({
+ items: [fixtureDefinition] as readonly WorkflowDefinition[],
+ partial: false,
+ });
+ const listeners = new Set<() => void>();
+ let operations: readonly WorkflowOperation[] = [];
+ let counter = 0;
+ let savedInput: Parameters[0] | undefined;
+ const calls = { save: 0, delete: 0, trigger: 0, runs: 0, approvals: 0 };
+ const publish = (next: readonly WorkflowOperation[]) => {
+ operations = next;
+ for (const listener of listeners) listener();
+ };
+ const start = (
+ action: WorkflowOperation["action"],
+ workflow: WorkflowDefinition,
+ ) => {
+ const eventId = (++counter).toString(16).padStart(64, "0");
+ publish([
+ ...operations,
+ {
+ eventId,
+ workflow,
+ action,
+ delivery: "sending",
+ outcome: "pending",
+ secretAvailable: false,
+ },
+ ]);
+ return eventId;
+ };
+ const capability: WorkflowCapability = {
+ availability: {
+ definitions: true,
+ history: true,
+ save: true,
+ trigger: true,
+ delete: true,
+ webhookSecrets: false,
+ },
+ definitions: () => definitions.view,
+ runs: () => {
+ calls.runs++;
+ return fixtureView({ runs: [], next: null }).view;
+ },
+ approvals: () => {
+ calls.approvals++;
+ return fixtureView([]).view;
+ },
+ save(input) {
+ calls.save++;
+ savedInput = input;
+ return start(
+ "save",
+ input.existing ?? {
+ ...fixtureDefinition,
+ id: "66666666-6666-4666-8666-666666666666",
+ yaml: input.yaml,
+ },
+ );
+ },
+ delete(workflow) {
+ calls.delete++;
+ return start("delete", workflow);
+ },
+ trigger(workflow) {
+ calls.trigger++;
+ return start("trigger", workflow);
+ },
+ operations: {
+ snapshot: () => operations,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ retry() {},
+ async dismiss() {},
+ },
+ takeWebhookSecret() {
+ return undefined;
+ },
+ };
+ return {
+ capability,
+ definitions,
+ calls,
+ input: () => savedInput,
+ finish(outcome: WorkflowOperation["outcome"], exact = true) {
+ const operation = operations.at(-1);
+ if (!operation) throw new Error("No operation");
+ if (
+ outcome === "succeeded" &&
+ operation.action === "save" &&
+ savedInput
+ ) {
+ const revision = exact ? operation.eventId : "bb".repeat(32);
+ definitions.update({
+ status: "ready",
+ data: {
+ partial: false,
+ items: [
+ {
+ ...fixtureDefinition,
+ ...operation.workflow,
+ revision,
+ yaml: savedInput.yaml,
+ },
+ ],
+ },
+ });
+ }
+ if (outcome === "succeeded" && operation.action === "delete")
+ definitions.update({
+ status: "ready",
+ data: { partial: false, items: [] },
+ });
+ publish(
+ operations.map((item) =>
+ item.eventId === operation.eventId
+ ? {
+ ...item,
+ outcome,
+ delivery: outcome === "rejected" ? "failed" : "accepted",
+ ...(outcome === "rejected"
+ ? { error: "Fixture conflict" }
+ : {}),
+ }
+ : item,
+ ),
+ );
+ },
+ revoke() {
+ definitions.update({
+ status: "unavailable",
+ data: { items: [], partial: false },
+ });
+ publish([]);
+ },
+ };
+}
diff --git a/src/bundled/workflows/index.tsx b/src/bundled/workflows/index.tsx
new file mode 100644
index 00000000..2c1feafe
--- /dev/null
+++ b/src/bundled/workflows/index.tsx
@@ -0,0 +1,12 @@
+import type { PluginModule } from "../../plugins/api";
+import { WorkflowsPage } from "./WorkflowsPage";
+export const inject = ["pages", "relay"];
+export const apply: PluginModule["apply"] = (ctx) => {
+ const relay = ctx.relay;
+ ctx.pages.register({
+ id: "workflows",
+ title: "Workflows",
+ layout: "workspace",
+ component: () => ,
+ });
+};
diff --git a/src/bundled/workflows/manifest.json b/src/bundled/workflows/manifest.json
new file mode 100644
index 00000000..6c3656df
--- /dev/null
+++ b/src/bundled/workflows/manifest.json
@@ -0,0 +1 @@
+{ "id": "buzz.workflows", "name": "Workflows", "apiVersion": 1 }
diff --git a/src/bundled/workflows/useWorkflowView.ts b/src/bundled/workflows/useWorkflowView.ts
new file mode 100644
index 00000000..347b1c34
--- /dev/null
+++ b/src/bundled/workflows/useWorkflowView.ts
@@ -0,0 +1,16 @@
+import { useEffect, useSyncExternalStore } from "react";
+import type { WorkflowView } from "../../features/workflows/types";
+
+/** Host factories create idle interest; the mounted consumer owns start/stop. */
+export function useWorkflowView(view: WorkflowView) {
+ const snapshot = useSyncExternalStore(
+ view.subscribe,
+ view.snapshot,
+ view.snapshot,
+ );
+ useEffect(() => {
+ void view.refresh();
+ return () => view.dispose();
+ }, [view]);
+ return snapshot;
+}
diff --git a/src/bundled/workflows/workflowActivationWarning.test.mjs b/src/bundled/workflows/workflowActivationWarning.test.mjs
new file mode 100644
index 00000000..d307fdb1
--- /dev/null
+++ b/src/bundled/workflows/workflowActivationWarning.test.mjs
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import { getWorkflowActivationWarning } from "./workflowActivationWarning.ts";
+
+function workflowYaml(trigger) {
+ return `name: Test\ntrigger:\n${trigger}\nsteps:\n - id: notify\n action: send_message\n text: Hi\n`;
+}
+
+test("warns when a message trigger matches every message", () => {
+ assert.deepEqual(
+ getWorkflowActivationWarning(workflowYaml(" on: message_posted")),
+ {
+ description:
+ "It will run for every new message in this channel. Review the trigger before turning it on.",
+ title: "This workflow may run often",
+ },
+ );
+});
+
+test("does not warn when a message trigger is narrowed", () => {
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(' on: message_posted\n filter: text == "deploy"'),
+ ),
+ null,
+ );
+});
+
+test("warns for hourly-or-faster interval schedules with concrete copy", () => {
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(" on: schedule\n interval: 15m"),
+ )?.description,
+ "It is scheduled to run every 15 minutes. Review the schedule before turning it on.",
+ );
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(" on: schedule\n interval: 2h"),
+ ),
+ null,
+ );
+});
+
+test("warns for clear hourly-or-faster cron schedules", () => {
+ for (const cron of ["*/5 * * * *", "0 */5 * * * *", "0 */5 * * * * *"]) {
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(` on: schedule\n cron: "${cron}"`),
+ )?.description,
+ "It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
+ );
+ }
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(' on: schedule\n cron: "*/10 * * * * *"'),
+ )?.description,
+ "It is scheduled to run multiple times a minute. Review the schedule before turning it on.",
+ );
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(' on: schedule\n cron: "0 9 * * *"'),
+ ),
+ null,
+ );
+});
+
+test("warns for stepped, ranged, and listed hourly cron schedules", () => {
+ for (const cron of [
+ "0 0 */1 * * *",
+ "0 0 * * * *",
+ "0 0 0-23 * * *",
+ "0 0 0-11,12-23 * * *",
+ "0 0 */1 * * * *",
+ ]) {
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(` on: schedule\n cron: "${cron}"`),
+ )?.description,
+ "It is scheduled to run every hour. Review the schedule before turning it on.",
+ cron,
+ );
+ }
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(' on: schedule\n cron: "*/5 0-23 * * *"'),
+ )?.description,
+ "It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
+ );
+ for (const cron of ["0 */3 * * *", "0 0 0,12 * * *", "0 0 8-17 * * *"]) {
+ assert.equal(
+ getWorkflowActivationWarning(
+ workflowYaml(` on: schedule\n cron: "${cron}"`),
+ ),
+ null,
+ cron,
+ );
+ }
+});
+
+test("returns no warning for malformed or unrelated definitions", () => {
+ assert.equal(getWorkflowActivationWarning("not: [valid"), null);
+ assert.equal(
+ getWorkflowActivationWarning(workflowYaml(" on: reaction_added")),
+ null,
+ );
+});
diff --git a/src/bundled/workflows/workflowActivationWarning.ts b/src/bundled/workflows/workflowActivationWarning.ts
new file mode 100644
index 00000000..f4a556f6
--- /dev/null
+++ b/src/bundled/workflows/workflowActivationWarning.ts
@@ -0,0 +1,161 @@
+// Adapted from block/buzz desktop workflow helpers at b9392d9d.
+import { parse as yamlParse } from "yaml";
+
+import {
+ formatDurationSecondsVerbose,
+ parseDurationSeconds,
+} from "./workflowDuration";
+
+type WorkflowActivationWarning = {
+ description: string;
+ title: string;
+};
+
+const FREQUENT_SCHEDULE_THRESHOLD_SECONDS = 60 * 60;
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function nonEmptyString(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function frequentIntervalDescription(interval: string): string | null {
+ const seconds = parseDurationSeconds(interval);
+ if (
+ seconds === null ||
+ seconds <= 0 ||
+ seconds > FREQUENT_SCHEDULE_THRESHOLD_SECONDS
+ ) {
+ return null;
+ }
+ return `It is scheduled to run every ${formatDurationSecondsVerbose(seconds)}. Review the schedule before turning it on.`;
+}
+
+function normalizedCronFields(cron: string): string[] | null {
+ const fields = cron.trim().split(/\s+/);
+ if (fields.length === 5) return ["0", ...fields, "*"];
+ if (fields.length === 6) return [...fields, "*"];
+ return fields.length === 7 ? fields : null;
+}
+
+function repeatedFieldCount(field: string, maximum: number): number | null {
+ if (field === "*") return maximum + 1;
+ const step = /^\*\/(\d+)$/.exec(field);
+ if (step) {
+ const size = Number(step[1]);
+ return size >= 1 && size <= maximum + 1
+ ? Math.ceil((maximum + 1) / size)
+ : null;
+ }
+ if (field.includes(",") || field.includes("-")) return 2;
+ return /^\d+$/.test(field) ? 1 : null;
+}
+
+/**
+ * Reports whether an hour field selects every hour of the day, so schedules
+ * written as `*`, `*\/1`, `0-23`, or an equivalent list still classify as
+ * hourly. Unrecognized fields are treated as not matching every hour.
+ */
+function matchesEveryHour(field: string): boolean {
+ const HOURS = 24;
+ const selected = new Set();
+ for (const segment of field.split(",")) {
+ const [base = "", step] = segment.split("/");
+ if (step !== undefined && (!/^\d+$/.test(step) || Number(step) < 1)) {
+ return false;
+ }
+ const size = step === undefined ? 1 : Number(step);
+ let start: number, end: number;
+ if (base === "*") {
+ start = 0;
+ end = HOURS - 1;
+ } else {
+ const bounds = base.split("-");
+ if (bounds.length > 2 || bounds.some((part) => !/^\d+$/.test(part))) {
+ return false;
+ }
+ start = Number(bounds[0]);
+ end = bounds.length === 2 ? Number(bounds[1]) : start;
+ if (start > end || end >= HOURS) return false;
+ // A bare hour selects only itself; a stepped one runs to end of day.
+ if (bounds.length === 1) {
+ if (step === undefined) {
+ selected.add(start);
+ continue;
+ }
+ end = HOURS - 1;
+ }
+ }
+ for (let hour = start; hour <= end; hour += size) selected.add(hour);
+ }
+ return selected.size === HOURS;
+}
+
+function frequentCronDescription(cron: string): string | null {
+ const fields = normalizedCronFields(cron);
+ if (!fields) return null;
+ const [second = "", minute = "", hour = ""] = fields;
+ const secondRuns = repeatedFieldCount(second, 59);
+ const minuteRuns = repeatedFieldCount(minute, 59);
+ if (secondRuns === null || minuteRuns === null) return null;
+ if (!matchesEveryHour(hour)) return null;
+
+ if (secondRuns > 1) {
+ return "It is scheduled to run multiple times a minute. Review the schedule before turning it on.";
+ }
+ if (minute === "*") {
+ return "It is scheduled to run every minute. Review the schedule before turning it on.";
+ }
+ const steppedMinute = /^\*\/(\d+)$/.exec(minute);
+ if (steppedMinute) {
+ const minutes = Number(steppedMinute[1]);
+ return `It is scheduled to run every ${minutes} minute${minutes === 1 ? "" : "s"}. Review the schedule before turning it on.`;
+ }
+ if (minuteRuns === 1) {
+ return "It is scheduled to run every hour. Review the schedule before turning it on.";
+ }
+ if (minuteRuns > 1) {
+ return "It is scheduled to run multiple times an hour. Review the schedule before turning it on.";
+ }
+ return null;
+}
+
+export function getWorkflowActivationWarning(
+ yaml: string,
+): WorkflowActivationWarning | null {
+ let definition: Record | null;
+ try {
+ definition = asRecord(yamlParse(yaml));
+ } catch {
+ return null;
+ }
+ const trigger = asRecord(definition?.trigger);
+ const triggerType = nonEmptyString(trigger?.on);
+
+ if (triggerType === "message_posted" && !nonEmptyString(trigger?.filter)) {
+ return {
+ description:
+ "It will run for every new message in this channel. Review the trigger before turning it on.",
+ title: "This workflow may run often",
+ };
+ }
+
+ if (triggerType === "schedule") {
+ const interval = nonEmptyString(trigger?.interval);
+ const cron = nonEmptyString(trigger?.cron);
+ const description = interval
+ ? frequentIntervalDescription(interval)
+ : cron
+ ? frequentCronDescription(cron)
+ : null;
+ if (description) {
+ return { description, title: "This workflow may run often" };
+ }
+ }
+
+ return null;
+}
diff --git a/src/bundled/workflows/workflowDuration.test.mjs b/src/bundled/workflows/workflowDuration.test.mjs
new file mode 100644
index 00000000..f84016f8
--- /dev/null
+++ b/src/bundled/workflows/workflowDuration.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ DURATION_SLIDER_STOPS,
+ durationSliderIndex,
+ formatDurationSeconds,
+ formatDurationSecondsVerbose,
+ parseDurationSeconds,
+} from "./workflowDuration.ts";
+
+test("parseDurationSeconds accepts compact and combined whole-second durations", () => {
+ assert.equal(parseDurationSeconds("5s"), 5);
+ assert.equal(parseDurationSeconds("90m"), 5_400);
+ assert.equal(parseDurationSeconds("1h 2s"), 3_602);
+ assert.equal(parseDurationSeconds("1H2M3S"), 3_723);
+ assert.equal(parseDurationSeconds("2d"), 172_800);
+ assert.equal(parseDurationSeconds("2w 3d 4h 5m 6s"), 1_483_506);
+ assert.equal(parseDurationSeconds("42"), 42);
+ assert.equal(parseDurationSeconds("0s"), 0);
+});
+
+test("parseDurationSeconds rejects empty, malformed, and fractional values", () => {
+ assert.equal(parseDurationSeconds(""), null);
+ assert.equal(parseDurationSeconds("1.5m"), null);
+ assert.equal(parseDurationSeconds("1m later"), null);
+});
+
+test("formatDurationSeconds produces compact labels with significant units", () => {
+ assert.equal(formatDurationSeconds(0), "0s");
+ assert.equal(formatDurationSeconds(5), "5s");
+ assert.equal(formatDurationSeconds(62), "1m 2s");
+ assert.equal(formatDurationSeconds(3_602), "1h 2s");
+ assert.equal(formatDurationSeconds(7_323), "2h 2m 3s");
+ assert.equal(formatDurationSeconds(172_800), "2d");
+ assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
+});
+
+test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
+ assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
+ assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
+ assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
+ assert.equal(
+ formatDurationSecondsVerbose(1_483_506),
+ "2 weeks 3 days 4 hours 5 minutes 6 seconds",
+ );
+});
+
+test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
+ assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
+ assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
+ assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
+});
diff --git a/src/bundled/workflows/workflowDuration.ts b/src/bundled/workflows/workflowDuration.ts
new file mode 100644
index 00000000..d018b19a
--- /dev/null
+++ b/src/bundled/workflows/workflowDuration.ts
@@ -0,0 +1,129 @@
+// Adapted from block/buzz desktop workflow helpers at b9392d9d.
+const DURATION_PARTS_PATTERN =
+ /^\s*(?:(\d+)\s*w)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
+
+const SECONDS_PER_MINUTE = 60;
+const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
+const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
+const SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY;
+
+/** Parse compact durations such as `5s`, `1h 2s`, `2d`, or `3w`. */
+export function parseDurationSeconds(value: string): number | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ if (/^\d+$/.test(trimmed)) {
+ const seconds = Number(trimmed);
+ return Number.isSafeInteger(seconds) ? seconds : null;
+ }
+
+ const match = DURATION_PARTS_PATTERN.exec(trimmed);
+ if (!match || match.slice(1).every((part) => part === undefined)) return null;
+
+ const weeks = Number(match[1] ?? 0);
+ const days = Number(match[2] ?? 0);
+ const hours = Number(match[3] ?? 0);
+ const minutes = Number(match[4] ?? 0);
+ const seconds = Number(match[5] ?? 0);
+ const total =
+ weeks * SECONDS_PER_WEEK +
+ days * SECONDS_PER_DAY +
+ hours * SECONDS_PER_HOUR +
+ minutes * SECONDS_PER_MINUTE +
+ seconds;
+
+ return Number.isSafeInteger(total) ? total : null;
+}
+
+/** Format whole seconds as a compact duration, omitting empty units. */
+export function formatDurationSeconds(totalSeconds: number): string {
+ if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
+ if (totalSeconds === 0) return "0s";
+
+ const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
+ const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
+ const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+ const minutes = Math.floor(
+ (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
+ );
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
+ const parts: string[] = [];
+
+ if (weeks > 0) parts.push(`${weeks}w`);
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0) parts.push(`${minutes}m`);
+ if (seconds > 0) parts.push(`${seconds}s`);
+
+ return parts.join(" ");
+}
+
+function verboseUnit(value: number, unit: string): string {
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
+}
+
+/** Format whole seconds with fully spelled-out units for summary UI. */
+export function formatDurationSecondsVerbose(totalSeconds: number): string {
+ if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
+ if (totalSeconds === 0) return "0 seconds";
+
+ const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
+ const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
+ const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+ const minutes = Math.floor(
+ (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
+ );
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
+ const parts: string[] = [];
+
+ if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
+ if (days > 0) parts.push(verboseUnit(days, "day"));
+ if (hours > 0) parts.push(verboseUnit(hours, "hour"));
+ if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
+ if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
+
+ return parts.join(" ");
+}
+
+function steppedRange(start: number, end: number, step: number): number[] {
+ const values: number[] = [];
+ for (let value = start; value <= end; value += step) values.push(value);
+ return values;
+}
+
+/**
+ * Slider stops favor the short delays people use most, then relax precision as
+ * the duration grows. The typed field still accepts exact values between stops.
+ */
+export const DURATION_SLIDER_STOPS = [
+ ...steppedRange(1, 120, 1),
+ ...steppedRange(125, 600, 5),
+ ...steppedRange(615, 1_800, 15),
+ ...steppedRange(1_860, 7_200, 60),
+ ...steppedRange(7_500, 10_800, 300),
+];
+
+export const DEFAULT_DURATION_SECONDS = 1;
+
+export function durationSliderIndex(totalSeconds: number): number {
+ if (totalSeconds <= (DURATION_SLIDER_STOPS[0] ?? 1)) return 0;
+
+ const lastIndex = DURATION_SLIDER_STOPS.length - 1;
+ if (totalSeconds >= (DURATION_SLIDER_STOPS[lastIndex] ?? 10_800))
+ return lastIndex;
+
+ let low = 0;
+ let high = lastIndex;
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2);
+ const value = DURATION_SLIDER_STOPS[middle] ?? 0;
+ if (value === totalSeconds) return middle;
+ if (value < totalSeconds) low = middle + 1;
+ else high = middle - 1;
+ }
+
+ return totalSeconds - (DURATION_SLIDER_STOPS[high] ?? 0) <=
+ (DURATION_SLIDER_STOPS[low] ?? 10_800) - totalSeconds
+ ? high
+ : low;
+}
diff --git a/src/bundled/workflows/workflowFormTypes.test.mjs b/src/bundled/workflows/workflowFormTypes.test.mjs
new file mode 100644
index 00000000..59a269b7
--- /dev/null
+++ b/src/bundled/workflows/workflowFormTypes.test.mjs
@@ -0,0 +1,322 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+import { parse as parseYaml } from "yaml";
+
+import {
+ formStateToYaml,
+ isThreadReplyEligibleTrigger,
+ supportsMessageTextCondition,
+ withTriggerType,
+ yamlToFormState,
+ DEFAULT_FORM_STATE,
+} from "./workflowFormTypes.ts";
+
+function accepted(yaml) {
+ const result = yamlToFormState(yaml);
+ assert.equal(result.ok, true, result.ok ? undefined : result.error);
+ return result.state;
+}
+
+function normalizeBackendDefaults(value) {
+ const copy = structuredClone(value);
+ if (copy.enabled === undefined) copy.enabled = true;
+ for (const step of copy.steps ?? []) {
+ if (step.action === "call_webhook" && step.method === undefined) {
+ step.method = "POST";
+ }
+ }
+ return copy;
+}
+
+function sendMessageState(overrides) {
+ return {
+ ...DEFAULT_FORM_STATE,
+ name: "Auto Reply",
+ trigger: { on: "message_posted", filter: "trigger_is_reply == false" },
+ steps: [
+ {
+ id: "step_1",
+ action: "send_message",
+ text: "pre-written reply",
+ ...overrides,
+ },
+ ],
+ };
+}
+
+test("message-text conditions are limited to message-bearing triggers", () => {
+ assert.equal(supportsMessageTextCondition("message_posted"), true);
+ assert.equal(supportsMessageTextCondition("diff_posted"), true);
+ assert.equal(supportsMessageTextCondition("reaction_added"), false);
+ assert.equal(supportsMessageTextCondition("webhook"), false);
+ assert.equal(supportsMessageTextCondition("schedule"), false);
+});
+
+const acceptedFixtures = [
+ `name: Notify\ntrigger:\n on: message_posted\nsteps:\n - id: notify_1\n action: send_message\n text: hello\n`,
+ `name: React\ndescription: React to a message\nenabled: false\ntrigger:\n on: reaction_added\n emoji: eyes\n filter: trigger_message_id == "abc123"\nsteps:\n - id: react\n name: Add reaction\n timeout_secs: 30\n action: add_reaction\n emoji: white_check_mark\n`,
+ `name: Webhook\ntrigger:\n on: webhook\nsteps:\n - id: call\n action: call_webhook\n url: https://example.com/hook\n method: PATCH\n headers:\n Authorization: secret\n X-Trace: trace\n body: '{"ok":true}'\n`,
+ `name: Legacy actions\ntrigger:\n on: diff_posted\n filter: str_contains(trigger_text, "deploy")\nsteps:\n - id: dm\n action: send_dm\n to: abc123\n text: hello\n - id: approval\n action: request_approval\n from: manager\n message: Approve?\n timeout: 24h\n - id: topic\n action: set_channel_topic\n topic: Deployed\n - id: wait\n action: delay\n duration: 5m\n`,
+ `name: Scheduled preset\ntrigger:\n on: schedule\n interval: 15m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
+ `name: Scheduled custom\ntrigger:\n on: schedule\n cron: 0 */2 * * 1,3,5\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
+ `name: Scheduled legacy interval\ntrigger:\n on: schedule\n interval: 2h30m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
+];
+
+test("accepted Form fixtures survive a semantic YAML round trip", () => {
+ for (const fixture of acceptedFixtures) {
+ const generated = formStateToYaml(accepted(fixture));
+ assert.deepEqual(
+ normalizeBackendDefaults(parseYaml(generated)),
+ normalizeBackendDefaults(parseYaml(fixture)),
+ );
+ }
+});
+
+test("recognized nodes with unknown fields are refused without touching YAML", () => {
+ const fixtures = [
+ `name: Test\nunknown: true\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted, future_filter: x }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
+ ];
+
+ for (const yaml of fixtures) {
+ const original = yaml;
+ const result = yamlToFormState(yaml);
+ assert.equal(result.ok, false);
+ assert.match(result.error, /YAML editor/);
+ assert.equal(yaml, original);
+ }
+});
+
+test("invalid IDs, shapes, and scalar types are refused", () => {
+ const cases = [
+ [
+ "missing ID",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ action: send_message, text: hi }]\n`,
+ ],
+ [
+ "duplicate ID",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: same, action: send_message, text: hi }, { id: same, action: delay, duration: 5m }]\n`,
+ ],
+ [
+ "invalid ID",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: bad-id, action: send_message, text: hi }]\n`,
+ ],
+ [
+ "oversize ID",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: ${"a".repeat(65)}, action: send_message, text: hi }]\n`,
+ ],
+ [
+ "steps object",
+ `name: Test\ntrigger: { on: webhook }\nsteps: { id: s1, action: send_message, text: hi }\n`,
+ ],
+ [
+ "missing required action field",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message }]\n`,
+ ],
+ [
+ "numeric text",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
+ ],
+ [
+ "numeric header",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
+ ],
+ [
+ "zero timeout",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
+ ],
+ [
+ "fractional timeout",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
+ ],
+ [
+ "unsupported method",
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
+ ],
+ ];
+
+ for (const [name, yaml] of cases) {
+ assert.equal(yamlToFormState(yaml).ok, false, name);
+ }
+});
+
+test("step condition capabilities stay in YAML mode", () => {
+ const condition = `name: Conditional\ntrigger: { on: webhook }\nsteps: [{ id: s1, if: trigger_author == "abc", action: send_message, text: hi }]\n`;
+
+ const conditionResult = yamlToFormState(condition);
+ assert.equal(conditionResult.ok, false);
+ assert.match(conditionResult.error, /conditions.*YAML editor/);
+});
+
+test("malformed and unowned schedule definitions stay losslessly in YAML mode", () => {
+ const fixtures = [
+ `name: Missing\ntrigger: { on: schedule }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Both\ntrigger: { on: schedule, cron: "0 9 * * *", interval: 1h }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Numeric\ntrigger: { on: schedule, interval: 30 }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Unknown\ntrigger: { on: schedule, cron: "0 9 * * *", timezone: UTC }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Invalid cron\ntrigger: { on: schedule, cron: "60 9 * * *" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ ];
+
+ for (const yaml of fixtures) {
+ const original = yaml;
+ const result = yamlToFormState(yaml);
+ assert.equal(result.ok, false);
+ assert.match(result.error, /YAML editor/);
+ assert.equal(yaml, original);
+ }
+});
+
+test("the serializer emits only one schedule representation", () => {
+ const yaml = formStateToYaml({
+ name: "Exclusive",
+ description: "",
+ enabled: true,
+ trigger: { on: "schedule", cron: "0 9 * * *", interval: "1h" },
+ steps: [{ id: "s1", action: "send_message", text: "hi" }],
+ });
+ assert.deepEqual(parseYaml(yaml).trigger, {
+ on: "schedule",
+ cron: "0 9 * * *",
+ });
+});
+
+test("presents step timeout seconds as durations and serializes them numerically", () => {
+ const yaml = `name: Timed\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
+ const state = accepted(yaml);
+
+ assert.equal(state.steps[0].timeoutSecs, "1h 2s");
+ state.steps[0].timeoutSecs = "5m";
+ assert.equal(parseYaml(formStateToYaml(state)).steps[0].timeout_secs, 300);
+});
+
+test("advanced message expressions survive unrelated Form serialization", () => {
+ const filter =
+ 'str_contains(trigger_text, "deploy") && trigger_author == "abc"';
+ const yaml = `name: Advanced\ndescription: Before\ntrigger:\n on: message_posted\n filter: '${filter}'\nsteps:\n - id: s1\n action: send_message\n text: hi\n`;
+ const state = accepted(yaml);
+ state.description = "After";
+ const generated = parseYaml(formStateToYaml(state));
+
+ assert.equal(generated.description, "After");
+ assert.equal(generated.trigger.filter, filter);
+});
+
+test("values the Form serializer would normalize are refused", () => {
+ const fixtures = [
+ `name: Test\ndescription: " spaced "\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ndescription: ""\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: reaction_added, emoji: "" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
+ `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
+ ];
+
+ for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false);
+});
+
+test("reply_in_thread is emitted only when the checkbox is on", () => {
+ const withReply = formStateToYaml(sendMessageState({ replyInThread: true }));
+ assert.match(withReply, /reply_in_thread: true/);
+
+ const withoutReply = formStateToYaml(
+ sendMessageState({ replyInThread: false }),
+ );
+ assert.doesNotMatch(withoutReply, /reply_in_thread/);
+
+ const unset = formStateToYaml(sendMessageState({}));
+ assert.doesNotMatch(unset, /reply_in_thread/);
+});
+
+test("switching from Message Posted clears reply_in_thread before save", () => {
+ const messagePosted = sendMessageState({ replyInThread: true });
+
+ for (const triggerType of ["schedule", "webhook"]) {
+ const switched = withTriggerType(messagePosted, triggerType);
+ assert.equal(switched.trigger.on, triggerType);
+ assert.equal(switched.steps[0].replyInThread, false);
+ assert.doesNotMatch(formStateToYaml(switched), /reply_in_thread/);
+ }
+});
+
+test("an ineligible trigger cannot resurrect reply_in_thread through an action change", () => {
+ // Full repro: Message Posted → Send Message → enable Reply → switch action to
+ // Delay → switch trigger to an ineligible one → switch action back to Send
+ // Message. The action picker changes only `action` (a plain spread, mirrored
+ // here), so `withTriggerType` must clear the hidden flag on every step, not
+ // just the ones whose current action is send_message.
+ for (const triggerType of ["schedule", "webhook"]) {
+ const enabled = sendMessageState({ replyInThread: true });
+ const asDelay = {
+ ...enabled,
+ steps: [{ ...enabled.steps[0], action: "delay", duration: "5m" }],
+ };
+ const switched = withTriggerType(asDelay, triggerType);
+ const backToSend = {
+ ...switched,
+ steps: [{ ...switched.steps[0], action: "send_message" }],
+ };
+
+ assert.equal(backToSend.steps[0].replyInThread, false, triggerType);
+ assert.doesNotMatch(formStateToYaml(backToSend), /reply_in_thread/);
+ }
+});
+
+test("invalid reply_in_thread values are refused rather than normalized", () => {
+ const original = (yaml) => {
+ const result = yamlToFormState(yaml);
+ assert.equal(result.ok, false);
+ assert.match(result.error, /YAML editor/);
+ return result;
+ };
+
+ // Non-boolean would be silently deleted on serialization.
+ const nonBoolean = `name: Coerced\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: "yes" }]\n`;
+ assert.match(original(nonBoolean).error, /reply_in_thread must be a boolean/);
+
+ // true under an ineligible trigger would round-trip a backend-invalid definition.
+ for (const trigger of ["schedule, cron: '0 9 * * *'", "webhook"]) {
+ const yaml = `name: Ineligible\ntrigger: { on: ${trigger} }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: true }]\n`;
+ assert.match(
+ original(yaml).error,
+ /reply_in_thread is not supported for (schedule|webhook) triggers/,
+ );
+ }
+});
+
+test("reply_in_thread eligibility follows trigger capability", () => {
+ assert.equal(isThreadReplyEligibleTrigger("message_posted"), true);
+ assert.equal(isThreadReplyEligibleTrigger("schedule"), false);
+ assert.equal(isThreadReplyEligibleTrigger("webhook"), false);
+});
+test("reply_in_thread round-trips YAML -> form -> YAML", () => {
+ const yaml = formStateToYaml(sendMessageState({ replyInThread: true }));
+ const parsed = yamlToFormState(yaml);
+ assert.equal(parsed.ok, true);
+ assert.equal(parsed.state.steps[0].replyInThread, true);
+
+ const reserialized = formStateToYaml(parsed.state);
+ assert.match(reserialized, /reply_in_thread: true/);
+});
+
+test("absent reply_in_thread parses as false", () => {
+ const yaml = [
+ "name: No Reply",
+ "trigger:",
+ " on: message_posted",
+ "steps:",
+ " - id: step_1",
+ " action: send_message",
+ " text: hi",
+ "",
+ ].join("\n");
+ const parsed = yamlToFormState(yaml);
+ assert.equal(parsed.ok, true);
+ assert.equal(parsed.state.steps[0].replyInThread, false);
+});
+
+test("new workflow drafts start explicitly disabled", () => {
+ assert.equal(DEFAULT_FORM_STATE.enabled, false);
+ assert.equal(parseYaml(formStateToYaml(DEFAULT_FORM_STATE)).enabled, false);
+});
diff --git a/src/bundled/workflows/workflowFormTypes.ts b/src/bundled/workflows/workflowFormTypes.ts
new file mode 100644
index 00000000..64385653
--- /dev/null
+++ b/src/bundled/workflows/workflowFormTypes.ts
@@ -0,0 +1,657 @@
+// Adapted from block/buzz desktop workflow helpers at b9392d9d.
+import { stringify as yamlStringify, parse as yamlParse } from "yaml";
+
+import { cronExpressionError } from "./cronExpression";
+import {
+ formatDurationSeconds,
+ parseDurationSeconds,
+} from "./workflowDuration";
+
+export const TRIGGER_TYPES = [
+ "message_posted",
+ "reaction_added",
+ "diff_posted",
+ "webhook",
+ "schedule",
+] as const;
+export type TriggerType = (typeof TRIGGER_TYPES)[number];
+
+export function supportsMessageTextCondition(
+ triggerType: TriggerType,
+): boolean {
+ return triggerType === "message_posted" || triggerType === "diff_posted";
+}
+
+export const SELECTABLE_TRIGGER_TYPES = [
+ "message_posted",
+ "reaction_added",
+ "diff_posted",
+ "webhook",
+ "schedule",
+] as const satisfies readonly TriggerType[];
+
+export const ACTION_TYPES = [
+ "delay",
+ "send_message",
+ "send_dm",
+ "call_webhook",
+ "request_approval",
+ "add_reaction",
+ "set_channel_topic",
+] as const;
+export type ActionType = (typeof ACTION_TYPES)[number];
+
+export const SELECTABLE_ACTION_TYPES = [
+ "send_message",
+ "delay",
+ "call_webhook",
+] as const satisfies readonly ActionType[];
+
+export type TriggerConfig = {
+ on: TriggerType;
+ filter?: string | undefined;
+ emoji?: string | undefined;
+ cron?: string | undefined;
+ interval?: string | undefined;
+};
+
+export type HeaderFormState = {
+ id: string;
+ key: string;
+ value: string;
+};
+
+export type StepFormState = {
+ id: string;
+ name?: string | undefined;
+ action: ActionType;
+ condition?: string | undefined;
+ timeoutSecs?: string | undefined;
+ duration?: string | undefined;
+ text?: string | undefined;
+ channel?: string | undefined;
+ replyInThread?: boolean | undefined;
+ to?: string | undefined;
+ url?: string | undefined;
+ method?: string | undefined;
+ headers?: HeaderFormState[] | undefined;
+ body?: string | undefined;
+ emoji?: string | undefined;
+ topic?: string | undefined;
+ from?: string | undefined;
+ message?: string | undefined;
+ timeout?: string | undefined;
+};
+
+export type WorkflowFormState = {
+ name: string;
+ description: string;
+ enabled: boolean;
+ trigger: TriggerConfig;
+ steps: StepFormState[];
+};
+
+export const DEFAULT_FORM_STATE: WorkflowFormState = {
+ name: "",
+ description: "",
+ enabled: false,
+ trigger: { on: "message_posted" },
+ steps: [],
+};
+
+export const TRIGGER_LABELS: Record = {
+ message_posted: "Message Posted",
+ reaction_added: "Reaction Added",
+ diff_posted: "Diff Posted",
+ webhook: "Webhook",
+ schedule: "Schedule",
+};
+
+export const ACTION_LABELS: Record = {
+ delay: "Delay",
+ send_message: "Send Message",
+ send_dm: "Send DM",
+ call_webhook: "Call Webhook",
+ request_approval: "Request Approval",
+ add_reaction: "Add Reaction",
+ set_channel_topic: "Set Channel Topic",
+};
+
+function toHeaderRows(
+ headers: unknown,
+ stepId: string,
+): HeaderFormState[] | undefined {
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
+ return undefined;
+ }
+
+ const rows = Object.entries(headers).map(([key, value], index) => ({
+ id: `${stepId}_header_${index + 1}`,
+ key,
+ value: typeof value === "string" ? value : String(value),
+ }));
+
+ return rows.length > 0 ? rows : undefined;
+}
+
+function headersToRecord(
+ headers: HeaderFormState[] | undefined,
+): Record | undefined {
+ if (!headers) return undefined;
+
+ const entries = headers
+ .map(({ key, value }) => [key.trim(), value] as const)
+ .filter(([key]) => key.length > 0);
+
+ if (entries.length === 0) return undefined;
+ return Object.fromEntries(entries);
+}
+
+function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
+ if (!timeoutSecs) return undefined;
+ const parsed = parseDurationSeconds(timeoutSecs);
+ return parsed !== null && parsed > 0 ? parsed : undefined;
+}
+
+function actionFieldsForStep(step: StepFormState): Record {
+ const fields: Record = {};
+ if (step.name?.trim()) fields.name = step.name.trim();
+ if (step.condition?.trim()) fields.if = step.condition.trim();
+ const timeoutSecs = parseTimeoutSecs(step.timeoutSecs);
+ if (timeoutSecs !== undefined) fields.timeout_secs = timeoutSecs;
+
+ switch (step.action) {
+ case "delay":
+ if (step.duration) fields.duration = step.duration;
+ break;
+ case "send_message":
+ if (step.text) fields.text = step.text;
+ if (step.channel) fields.channel = step.channel;
+ if (step.replyInThread) fields.reply_in_thread = true;
+ break;
+ case "send_dm":
+ if (step.to) fields.to = step.to;
+ if (step.text) fields.text = step.text;
+ break;
+ case "call_webhook":
+ if (step.url) fields.url = step.url;
+ fields.method = step.method || "POST";
+ {
+ const headers = headersToRecord(step.headers);
+ if (headers) fields.headers = headers;
+ }
+ if (step.body) fields.body = step.body;
+ break;
+ case "request_approval":
+ if (step.from) fields.from = step.from;
+ if (step.message) fields.message = step.message;
+ if (step.timeout) fields.timeout = step.timeout;
+ break;
+ case "add_reaction":
+ if (step.emoji) fields.emoji = step.emoji;
+ break;
+ case "set_channel_topic":
+ if (step.topic) fields.topic = step.topic;
+ break;
+ }
+ return fields;
+}
+
+export function isThreadReplyEligibleTrigger(trigger: TriggerType): boolean {
+ return trigger !== "webhook" && trigger !== "schedule";
+}
+
+export function withTriggerType(
+ state: WorkflowFormState,
+ triggerType: TriggerType,
+): WorkflowFormState {
+ return {
+ ...state,
+ trigger: { on: triggerType },
+ // Clear threaded-reply state on every step, not just send_message ones:
+ // a hidden `replyInThread` on a step whose action was changed away from
+ // send_message would otherwise resurrect when the action is switched back.
+ steps: isThreadReplyEligibleTrigger(triggerType)
+ ? state.steps
+ : state.steps.map((step) =>
+ step.replyInThread ? { ...step, replyInThread: false } : step,
+ ),
+ };
+}
+
+export function formStateToYaml(state: WorkflowFormState): string {
+ const trigger: Record = { on: state.trigger.on };
+ if (
+ (state.trigger.on === "message_posted" ||
+ state.trigger.on === "diff_posted" ||
+ state.trigger.on === "reaction_added") &&
+ state.trigger.filter
+ ) {
+ trigger.filter = state.trigger.filter;
+ }
+ if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
+ trigger.emoji = state.trigger.emoji;
+ }
+ if (state.trigger.on === "schedule") {
+ if (state.trigger.cron) {
+ trigger.cron = state.trigger.cron;
+ } else if (state.trigger.interval) {
+ trigger.interval = state.trigger.interval;
+ }
+ }
+
+ const steps = state.steps.map((step) => ({
+ id: step.id,
+ action: step.action,
+ ...actionFieldsForStep(step),
+ }));
+
+ const workflow: Record = {
+ name: state.name,
+ trigger,
+ steps,
+ };
+
+ if (state.description.trim()) {
+ workflow.description = state.description.trim();
+ }
+ if (!state.enabled) {
+ workflow.enabled = false;
+ }
+
+ return yamlStringify(workflow);
+}
+
+const STEP_ID_PATTERN = /^step_(\d+)$/;
+
+export function nextStepId(existingSteps: StepFormState[]): string {
+ const existingIds = new Set(existingSteps.map((s) => s.id));
+ let maxN = 0;
+ for (const id of existingIds) {
+ const match = STEP_ID_PATTERN.exec(id);
+ if (match) maxN = Math.max(maxN, Number(match[1]));
+ }
+ let n = maxN + 1;
+ while (existingIds.has(`step_${n}`)) n++;
+ return `step_${n}`;
+}
+
+const TOP_LEVEL_KEYS = new Set([
+ "name",
+ "description",
+ "enabled",
+ "trigger",
+ "steps",
+]);
+const TRIGGER_KEYS: Record> = {
+ message_posted: new Set(["on", "filter"]),
+ reaction_added: new Set(["on", "emoji", "filter"]),
+ diff_posted: new Set(["on", "filter"]),
+ webhook: new Set(["on"]),
+ schedule: new Set(["on", "cron", "interval"]),
+};
+const COMMON_STEP_KEYS = ["id", "name", "action", "if", "timeout_secs"];
+const ACTION_STEP_KEYS: Record> = {
+ delay: new Set([...COMMON_STEP_KEYS, "duration"]),
+ send_message: new Set([
+ ...COMMON_STEP_KEYS,
+ "text",
+ "channel",
+ "reply_in_thread",
+ ]),
+ send_dm: new Set([...COMMON_STEP_KEYS, "to", "text"]),
+ call_webhook: new Set([
+ ...COMMON_STEP_KEYS,
+ "url",
+ "method",
+ "headers",
+ "body",
+ ]),
+ request_approval: new Set([
+ ...COMMON_STEP_KEYS,
+ "from",
+ "message",
+ "timeout",
+ ]),
+ add_reaction: new Set([...COMMON_STEP_KEYS, "emoji"]),
+ set_channel_topic: new Set([...COMMON_STEP_KEYS, "topic"]),
+};
+const REQUIRED_ACTION_STRING_KEYS: Record = {
+ delay: ["duration"],
+ send_message: ["text"],
+ send_dm: ["to", "text"],
+ call_webhook: ["url"],
+ request_approval: ["from", "message"],
+ add_reaction: ["emoji"],
+ set_channel_topic: ["topic"],
+};
+const OPTIONAL_ACTION_STRING_KEYS: Record = {
+ delay: [],
+ send_message: ["channel"],
+ send_dm: [],
+ call_webhook: ["method", "body"],
+ request_approval: ["timeout"],
+ add_reaction: [],
+ set_channel_topic: [],
+};
+const WEBHOOK_METHODS = new Set(["POST", "GET", "PUT", "PATCH", "DELETE"]);
+const STEP_ID_PATTERN_STRICT = /^[A-Za-z0-9_]{1,64}$/;
+
+type UnknownRecord = Record;
+
+function objectRecord(value: unknown): UnknownRecord | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as UnknownRecord)
+ : null;
+}
+
+function unknownKey(
+ record: UnknownRecord,
+ allowed: ReadonlySet,
+): string | null {
+ return Object.keys(record).find((key) => !allowed.has(key)) ?? null;
+}
+
+function requireNonEmptyString(
+ record: UnknownRecord,
+ key: string,
+ label: string,
+): string | { error: string } {
+ const value = record[key];
+ if (typeof value !== "string" || value.length === 0) {
+ return { error: `${label} must be a non-empty string` };
+ }
+ return value;
+}
+
+function optionalOwnedStringError(
+ record: UnknownRecord,
+ key: string,
+ label: string,
+): string | null {
+ const value = record[key];
+ if (value === undefined) return null;
+ if (typeof value !== "string") return `${label} must be a string`;
+ if (value.length === 0) {
+ return `${label} cannot be empty in Form mode — use the YAML editor`;
+ }
+ return null;
+}
+
+export function yamlToFormState(
+ yaml: string,
+): { ok: true; state: WorkflowFormState } | { ok: false; error: string } {
+ try {
+ const parsed = objectRecord(yamlParse(yaml));
+ if (!parsed) return { ok: false, error: "YAML must be an object" };
+
+ const topUnknown = unknownKey(parsed, TOP_LEVEL_KEYS);
+ if (topUnknown) {
+ return {
+ ok: false,
+ error: `Unsupported workflow field "${topUnknown}" — use the YAML editor`,
+ };
+ }
+ if (typeof parsed.name !== "string") {
+ return { ok: false, error: "name must be a string" };
+ }
+ if (parsed.description !== undefined) {
+ if (typeof parsed.description !== "string") {
+ return { ok: false, error: "description must be a string" };
+ }
+ if (
+ parsed.description.length === 0 ||
+ parsed.description.trim() !== parsed.description
+ ) {
+ return {
+ ok: false,
+ error:
+ "description cannot be empty or have surrounding whitespace in Form mode — use the YAML editor",
+ };
+ }
+ }
+ if (parsed.enabled !== undefined && typeof parsed.enabled !== "boolean") {
+ return { ok: false, error: "enabled must be a boolean" };
+ }
+
+ const rawTrigger = objectRecord(parsed.trigger);
+ if (!rawTrigger || typeof rawTrigger.on !== "string") {
+ return { ok: false, error: "trigger.on is required" };
+ }
+ if (!TRIGGER_TYPES.includes(rawTrigger.on as TriggerType)) {
+ return {
+ ok: false,
+ error: `Unsupported trigger type "${rawTrigger.on}" — use the YAML editor`,
+ };
+ }
+ const triggerOn = rawTrigger.on as TriggerType;
+ const triggerUnknown = unknownKey(rawTrigger, TRIGGER_KEYS[triggerOn]);
+ if (triggerUnknown) {
+ return {
+ ok: false,
+ error: `Unsupported ${triggerOn} trigger field "${triggerUnknown}" — use the YAML editor`,
+ };
+ }
+ for (const key of ["filter", "emoji", "cron", "interval"] as const) {
+ const error = optionalOwnedStringError(rawTrigger, key, `trigger.${key}`);
+ if (error) {
+ return {
+ ok: false,
+ error:
+ triggerOn === "schedule" && !error.includes("YAML editor")
+ ? `${error} — use the YAML editor`
+ : error,
+ };
+ }
+ }
+ if (triggerOn === "schedule") {
+ const hasCron = rawTrigger.cron !== undefined;
+ const hasInterval = rawTrigger.interval !== undefined;
+ if (hasCron === hasInterval) {
+ return {
+ ok: false,
+ error: hasCron
+ ? "Schedule triggers cannot specify both cron and interval — use the YAML editor"
+ : "Schedule triggers require either cron or interval — use the YAML editor",
+ };
+ }
+ if (typeof rawTrigger.cron === "string") {
+ const error = cronExpressionError(rawTrigger.cron);
+ if (error) {
+ return {
+ ok: false,
+ error: `Unsupported cron expression: ${error} Use the YAML editor`,
+ };
+ }
+ }
+ }
+ const trigger: TriggerConfig = {
+ on: triggerOn,
+ filter: rawTrigger.filter as string | undefined,
+ emoji: rawTrigger.emoji as string | undefined,
+ cron: rawTrigger.cron as string | undefined,
+ interval: rawTrigger.interval as string | undefined,
+ };
+
+ if (!Array.isArray(parsed.steps)) {
+ return { ok: false, error: "steps must be a list" };
+ }
+ const ids = new Set();
+ const steps: StepFormState[] = [];
+ for (const [index, value] of parsed.steps.entries()) {
+ const number = index + 1;
+ const step = objectRecord(value);
+ if (!step)
+ return { ok: false, error: `Step ${number} must be an object` };
+ if (
+ typeof step.id !== "string" ||
+ !STEP_ID_PATTERN_STRICT.test(step.id)
+ ) {
+ return {
+ ok: false,
+ error: `Step ${number} requires a unique 1–64 character alphanumeric or underscore ID`,
+ };
+ }
+ if (ids.has(step.id)) {
+ return {
+ ok: false,
+ error: `Duplicate step ID "${step.id}" — use the YAML editor`,
+ };
+ }
+ ids.add(step.id);
+
+ if (
+ typeof step.action !== "string" ||
+ !ACTION_TYPES.includes(step.action as ActionType)
+ ) {
+ return {
+ ok: false,
+ error: `Unsupported action type "${String(step.action)}" — use the YAML editor`,
+ };
+ }
+ const action = step.action as ActionType;
+ const stepUnknown = unknownKey(step, ACTION_STEP_KEYS[action]);
+ if (stepUnknown) {
+ return {
+ ok: false,
+ error: `Unsupported ${action} step field "${stepUnknown}" — use the YAML editor`,
+ };
+ }
+ if (step.if !== undefined) {
+ return {
+ ok: false,
+ error: "Step conditions are only available in the YAML editor",
+ };
+ }
+ const nameError = optionalOwnedStringError(
+ step,
+ "name",
+ `Step ${number} name`,
+ );
+ if (nameError) return { ok: false, error: nameError };
+ if (typeof step.name === "string" && step.name.trim() !== step.name) {
+ return {
+ ok: false,
+ error: `Step ${number} name has surrounding whitespace — use the YAML editor`,
+ };
+ }
+ if (
+ step.timeout_secs !== undefined &&
+ (!Number.isSafeInteger(step.timeout_secs) ||
+ (step.timeout_secs as number) <= 0)
+ ) {
+ return {
+ ok: false,
+ error: `Step ${number} timeout_secs must be a positive integer`,
+ };
+ }
+
+ for (const key of REQUIRED_ACTION_STRING_KEYS[action]) {
+ const required = requireNonEmptyString(
+ step,
+ key,
+ `Step ${number} ${key}`,
+ );
+ if (typeof required !== "string")
+ return { ok: false, error: required.error };
+ }
+ for (const key of OPTIONAL_ACTION_STRING_KEYS[action]) {
+ const error = optionalOwnedStringError(
+ step,
+ key,
+ `Step ${number} ${key}`,
+ );
+ if (error) return { ok: false, error };
+ }
+ if (
+ action === "call_webhook" &&
+ step.method !== undefined &&
+ !WEBHOOK_METHODS.has(step.method as string)
+ ) {
+ return {
+ ok: false,
+ error: `Unsupported webhook method "${String(step.method)}" — use the YAML editor`,
+ };
+ }
+ if (step.headers !== undefined) {
+ const headers = objectRecord(step.headers);
+ if (
+ !headers ||
+ Object.keys(headers).length === 0 ||
+ Object.values(headers).some((header) => typeof header !== "string")
+ ) {
+ return {
+ ok: false,
+ error:
+ "Webhook headers must be a non-empty object containing string values",
+ };
+ }
+ const unsafeHeader = Object.keys(headers).find(
+ (key) => key.length === 0 || key.trim() !== key,
+ );
+ if (unsafeHeader !== undefined) {
+ return {
+ ok: false,
+ error:
+ "Webhook header names cannot be empty or have surrounding whitespace in Form mode",
+ };
+ }
+ }
+
+ if (step.reply_in_thread !== undefined) {
+ if (typeof step.reply_in_thread !== "boolean") {
+ return {
+ ok: false,
+ error: `Step ${number} reply_in_thread must be a boolean — use the YAML editor`,
+ };
+ }
+ if (step.reply_in_thread && !isThreadReplyEligibleTrigger(triggerOn)) {
+ return {
+ ok: false,
+ error: `reply_in_thread is not supported for ${triggerOn} triggers — use the YAML editor`,
+ };
+ }
+ }
+
+ steps.push({
+ id: step.id,
+ name: step.name as string | undefined,
+ action,
+ timeoutSecs:
+ step.timeout_secs === undefined
+ ? undefined
+ : formatDurationSeconds(step.timeout_secs as number),
+ duration: step.duration as string | undefined,
+ text: step.text as string | undefined,
+ channel: step.channel as string | undefined,
+ replyInThread: step.reply_in_thread === true,
+ to: step.to as string | undefined,
+ url: step.url as string | undefined,
+ method: step.method as string | undefined,
+ headers: toHeaderRows(step.headers, step.id),
+ body: step.body as string | undefined,
+ emoji: step.emoji as string | undefined,
+ topic: step.topic as string | undefined,
+ from: step.from as string | undefined,
+ message: step.message as string | undefined,
+ timeout: step.timeout as string | undefined,
+ });
+ }
+
+ return {
+ ok: true,
+ state: {
+ name: parsed.name,
+ description: (parsed.description as string | undefined) ?? "",
+ enabled: parsed.enabled !== false,
+ trigger,
+ steps,
+ },
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ error: error instanceof Error ? error.message : "Invalid YAML",
+ };
+ }
+}
diff --git a/src/bundled/workflows/workflowYamlDocument.test.mjs b/src/bundled/workflows/workflowYamlDocument.test.mjs
new file mode 100644
index 00000000..ce1fc9eb
--- /dev/null
+++ b/src/bundled/workflows/workflowYamlDocument.test.mjs
@@ -0,0 +1,152 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ readWorkflowDocumentFields,
+ readWorkflowHeaderState,
+ yamlWithWorkflowEnabled,
+ yamlWithWorkflowName,
+} from "./workflowYamlDocument.ts";
+
+/** The name the dialog header would render for `yaml`. */
+function headerName(yaml, fallbackName) {
+ return readWorkflowHeaderState(yaml, { enabled: true, name: fallbackName })
+ .name;
+}
+
+/** The enabled state the dialog header would render for `yaml`. */
+function headerEnabled(yaml, fallbackEnabled) {
+ return readWorkflowHeaderState(yaml, {
+ enabled: fallbackEnabled,
+ name: undefined,
+ }).enabled;
+}
+
+// A step that has just been added from the builder carries no message text yet,
+// so the definition fails full form validation while the user is still on the
+// step pane. The header must keep working against that document.
+const INCOMPLETE_STEP_YAML = `name: mock-horse-battery
+trigger:
+ on: message_posted
+steps:
+ - id: step_1
+ action: send_message
+`;
+
+test("reads the name of a definition whose steps are still incomplete", () => {
+ assert.deepEqual(readWorkflowDocumentFields(INCOMPLETE_STEP_YAML), {
+ editable: true,
+ enabled: null,
+ name: "mock-horse-battery",
+ });
+ assert.equal(
+ headerName(INCOMPLETE_STEP_YAML, undefined),
+ "mock-horse-battery",
+ );
+});
+
+test("treats an empty definition as an editable blank name", () => {
+ assert.deepEqual(readWorkflowDocumentFields(""), {
+ editable: true,
+ enabled: null,
+ name: null,
+ });
+ assert.deepEqual(readWorkflowDocumentFields(" \n"), {
+ editable: true,
+ enabled: null,
+ name: null,
+ });
+ assert.equal(headerName("", undefined), "");
+});
+
+test("falls back to the saved name only when the document has none", () => {
+ assert.equal(headerName("", "Saved name"), "Saved name");
+ assert.equal(
+ headerName("name: ''\ntrigger:\n on: webhook\n", "Saved name"),
+ "Saved name",
+ );
+ assert.equal(
+ headerName(INCOMPLETE_STEP_YAML, "Saved name"),
+ "mock-horse-battery",
+ );
+});
+
+test("trims the rendered name and the saved fallback", () => {
+ assert.equal(headerName('name: " spaced "\n', undefined), "spaced");
+ assert.equal(headerName("", " saved "), "saved");
+});
+
+test("marks unparseable or non-map documents as uneditable", () => {
+ assert.deepEqual(readWorkflowDocumentFields("name: [unclosed\n"), {
+ editable: false,
+ enabled: null,
+ name: null,
+ });
+ assert.deepEqual(readWorkflowDocumentFields("- just\n- a list\n"), {
+ editable: false,
+ enabled: null,
+ name: null,
+ });
+ assert.equal(yamlWithWorkflowName("- just\n- a list\n", "next"), null);
+ assert.equal(yamlWithWorkflowEnabled("- just\n- a list\n", false), null);
+});
+
+test("ignores a non-string name rather than rendering it", () => {
+ assert.equal(readWorkflowDocumentFields("name: 42\n").name, null);
+ assert.equal(headerName("name: 42\n", "Saved name"), "Saved name");
+});
+
+test("keeps the header editable while a step is still incomplete", () => {
+ assert.deepEqual(
+ readWorkflowHeaderState(INCOMPLETE_STEP_YAML, {
+ enabled: true,
+ name: "Saved name",
+ }),
+ { canEdit: true, enabled: true, name: "mock-horse-battery" },
+ );
+ assert.equal(
+ readWorkflowHeaderState("name: [unclosed\n", {
+ enabled: false,
+ name: "Saved name",
+ }).canEdit,
+ false,
+ );
+});
+
+test("derives enabled from the document, defaulting to enabled", () => {
+ assert.equal(headerEnabled(INCOMPLETE_STEP_YAML, false), true);
+ assert.equal(
+ headerEnabled(`enabled: false\n${INCOMPLETE_STEP_YAML}`, true),
+ false,
+ );
+ // Only an unreadable document may fall back to the saved value.
+ assert.equal(headerEnabled("- just\n- a list\n", false), false);
+ assert.equal(headerEnabled("- just\n- a list\n", true), true);
+});
+
+test("writes the name back without disturbing the rest of the document", () => {
+ const next = yamlWithWorkflowName(INCOMPLETE_STEP_YAML, "renamed");
+ assert.equal(headerName(next, undefined), "renamed");
+ assert.match(next, /action: send_message/);
+ assert.match(next, /on: message_posted/);
+});
+
+test("seeds a definition when naming an empty document", () => {
+ const next = yamlWithWorkflowName("", "fresh");
+ assert.equal(headerName(next, undefined), "fresh");
+ assert.match(next, /trigger:/);
+});
+
+test("adds and removes the enabled key without touching the name", () => {
+ const disabled = yamlWithWorkflowEnabled(INCOMPLETE_STEP_YAML, false);
+ assert.match(disabled, /enabled: false/);
+ assert.equal(headerName(disabled, undefined), "mock-horse-battery");
+
+ const reEnabled = yamlWithWorkflowEnabled(disabled, true);
+ assert.doesNotMatch(reEnabled, /enabled:/);
+ assert.equal(headerName(reEnabled, undefined), "mock-horse-battery");
+});
+
+test("naming a new document cannot activate it", () => {
+ assert.equal(headerEnabled(yamlWithWorkflowName("", "Fresh"), true), false);
+});
diff --git a/src/bundled/workflows/workflowYamlDocument.ts b/src/bundled/workflows/workflowYamlDocument.ts
new file mode 100644
index 00000000..58c12061
--- /dev/null
+++ b/src/bundled/workflows/workflowYamlDocument.ts
@@ -0,0 +1,115 @@
+// Adapted from block/buzz desktop workflow helpers at b9392d9d.
+import { isMap, parseDocument } from "yaml";
+
+import { DEFAULT_FORM_STATE, formStateToYaml } from "./workflowFormTypes";
+
+/**
+ * Header-level view of a workflow definition.
+ *
+ * The dialog header (name, name editing, enabled toggle) must stay usable while
+ * the body of the definition is still incomplete — a freshly added step has no
+ * message text yet, so full form validation fails even though `name` is present
+ * and editable. These fields therefore come from the YAML document itself
+ * rather than from {@link yamlToFormState}.
+ */
+export type WorkflowDocumentFields = {
+ /** Whether top-level keys can be written back into the document. */
+ editable: boolean;
+ /** The explicit `enabled` value, or `null` when the key is absent. */
+ enabled: boolean | null;
+ /** The top-level `name` scalar, or `null` when absent or not a string. */
+ name: string | null;
+};
+
+const EMPTY_DOCUMENT_FIELDS: WorkflowDocumentFields = {
+ editable: true,
+ enabled: null,
+ name: null,
+};
+
+const UNEDITABLE_DOCUMENT_FIELDS: WorkflowDocumentFields = {
+ editable: false,
+ enabled: null,
+ name: null,
+};
+
+/**
+ * Reads the header-level fields of a workflow definition without requiring the
+ * whole definition to be valid.
+ */
+export function readWorkflowDocumentFields(
+ yaml: string,
+): WorkflowDocumentFields {
+ if (!yaml.trim()) return EMPTY_DOCUMENT_FIELDS;
+
+ const document = parseDocument(yaml);
+ if (document.errors.length > 0 || !isMap(document.contents)) {
+ return UNEDITABLE_DOCUMENT_FIELDS;
+ }
+
+ const name = document.get("name");
+ const enabled = document.get("enabled");
+ return {
+ editable: true,
+ enabled: typeof enabled === "boolean" ? enabled : null,
+ name: typeof name === "string" ? name : null,
+ };
+}
+
+/** What the dialog header renders for a definition, in a single parse. */
+export type WorkflowHeaderState = {
+ /** Whether the name can be edited and the enabled toggle can be written. */
+ canEdit: boolean;
+ enabled: boolean;
+ name: string;
+};
+
+/**
+ * Derives the header presentation from the working definition, falling back to
+ * the saved workflow only for what the document itself does not supply.
+ */
+export function readWorkflowHeaderState(
+ yaml: string,
+ fallback: { enabled: boolean; name: string | undefined },
+): WorkflowHeaderState {
+ const fields = readWorkflowDocumentFields(yaml);
+ return {
+ canEdit: fields.editable,
+ enabled: fields.editable ? fields.enabled !== false : fallback.enabled,
+ name: fields.name?.trim() || (fallback.name?.trim() ?? ""),
+ };
+}
+
+/** Writes `name` into the definition, preserving unrelated YAML nodes (not byte-exact formatting). */
+export function yamlWithWorkflowName(
+ yaml: string,
+ name: string,
+): string | null {
+ if (!yaml.trim()) {
+ return formStateToYaml({ ...DEFAULT_FORM_STATE, name });
+ }
+
+ const document = parseDocument(yaml);
+ if (document.errors.length > 0 || !isMap(document.contents)) return null;
+ document.set("name", name);
+ return document.toString();
+}
+
+/** Writes `enabled` into the definition, preserving unrelated YAML nodes (not byte-exact formatting). */
+export function yamlWithWorkflowEnabled(
+ yaml: string,
+ enabled: boolean,
+): string | null {
+ if (!yaml.trim()) {
+ return formStateToYaml({ ...DEFAULT_FORM_STATE, enabled });
+ }
+
+ const document = parseDocument(yaml);
+ if (document.errors.length > 0 || !isMap(document.contents)) return null;
+ if (enabled) {
+ document.delete("enabled");
+ } else {
+ document.set("enabled", false);
+ }
+ return document.toString();
+}
diff --git a/src/bundled/workflows/workflows.css b/src/bundled/workflows/workflows.css
new file mode 100644
index 00000000..dd9408b1
--- /dev/null
+++ b/src/bundled/workflows/workflows.css
@@ -0,0 +1,129 @@
+.workflows-page {
+ height: 100%;
+ overflow: auto;
+ padding: 24px;
+ color: var(--text-primary);
+}
+.workflows-page h1,
+.workflows-page h2,
+.workflows-page h3 {
+ margin: 0;
+}
+.workflow-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-block: 16px;
+}
+.workflow-list {
+ list-style: none;
+ padding: 0;
+ margin: 16px 0;
+}
+.workflow-list li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 16px;
+ padding-block: 8px;
+ border-bottom: 1px solid var(--border-primary);
+}
+.workflow-detail {
+ border-top: 1px solid var(--border-primary);
+ padding-block: 16px;
+}
+.workflow-editor,
+.workflow-form {
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+}
+.workflow-form {
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+.workflow-field {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+.workflow-name {
+ flex: 1 1 240px;
+}
+.workflow-field input,
+.workflow-field textarea {
+ width: 100%;
+ box-sizing: border-box;
+ color: var(--text-primary);
+ background: var(--bg-inset);
+ border: 1px solid var(--border-primary);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font: inherit;
+}
+.workflow-field textarea {
+ resize: vertical;
+}
+.workflow-field textarea.workflow-yaml {
+ font-family: var(--font-mono);
+}
+.workflow-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 20px;
+}
+.workflow-step {
+ display: grid;
+ gap: 12px;
+ padding-block: 16px;
+ border-bottom: 1px solid var(--border-primary);
+}
+.workflow-step p {
+ margin: 0;
+}
+.workflow-key,
+.workflow-trace {
+ overflow-wrap: anywhere;
+}
+.workflow-trace {
+ white-space: pre-wrap;
+ max-height: 400px;
+ overflow: auto;
+ background: var(--bg-inset);
+ padding: 12px;
+}
+.workflow-operations {
+ border-top: 1px solid var(--border-primary);
+ padding-block: 16px;
+}
+.workflow-dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ background: var(--overlay);
+}
+.workflow-dialog {
+ position: fixed;
+ inset: 50% auto auto 50%;
+ transform: translate(-50%, -50%);
+ width: min(480px, calc(100vw - 32px));
+ padding: 24px;
+ box-sizing: border-box;
+ background: var(--bg-float);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+ border-radius: 16px;
+}
+html[data-keyboard-navigation] .workflow-field input:focus-visible,
+html[data-keyboard-navigation] .workflow-field textarea:focus-visible {
+ outline: 2px solid var(--purple-8);
+ outline-offset: 2px;
+}
+@media (max-width: 640px) {
+ .workflows-page {
+ padding: 16px;
+ }
+}
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
new file mode 100644
index 00000000..6674aa06
--- /dev/null
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -0,0 +1,122 @@
+import { test, expect } from "@playwright/test";
+import { createServer } from "vite";
+import react from "@vitejs/plugin-react";
+import { fileURLToPath } from "node:url";
+
+let server;
+let url;
+test.beforeAll(async () => {
+ server = await createServer({
+ root: fileURLToPath(new URL("../../../", import.meta.url)),
+ configFile: false,
+ envDir: false,
+ plugins: [react()],
+ server: { host: "127.0.0.1", port: 0 },
+ });
+ await server.listen();
+ url = `http://127.0.0.1:${server.httpServer.address().port}/src/bundled/workflows/fixture.html`;
+});
+test.afterAll(async () => {
+ await server?.close();
+});
+
+test("workflow editor preserves YAML, resolves exact saves, retains conflicts and purges access", async ({
+ page,
+}) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ const yaml = page.getByLabel("Workflow YAML", { exact: true });
+ await expect
+ .poll(() => yaml.inputValue())
+ .toContain("# Keep this comment on opening");
+ await page.getByRole("tab", { name: "Form", exact: true }).click();
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ await expect
+ .poll(() => yaml.inputValue())
+ .toContain("# Keep this comment on opening");
+ await yaml.fill(
+ (await yaml.inputValue()).replace("Hello from a fixture", "Edited text"),
+ );
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(1);
+ await button("Complete concurrent head").click();
+ await expect
+ .poll(() => page.getByText(/waiting for a readback/).count())
+ .toBe(1);
+ expect(await button("Save workflow").isDisabled()).toBe(true);
+ expect(await yaml.inputValue()).toContain("Edited text");
+ await button("Complete exact save").click();
+ await expect.poll(() => button("Save workflow").isEnabled()).toBe(true);
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ await yaml.fill(
+ (await yaml.inputValue()).replace("Edited text", "Rejected draft"),
+ );
+ await button("Save workflow").click();
+ await button("Reject operation").click();
+ await expect
+ .poll(() => page.getByText("Fixture conflict", { exact: true }).count())
+ .toBe(1);
+ expect(await yaml.inputValue()).toContain("Rejected draft");
+ await button("Continue editing retained draft").click();
+ expect(await button("Save workflow").isEnabled()).toBe(true);
+ await button("Close editor").click();
+ await expect.poll(() => page.getByRole("alertdialog").count()).toBe(1);
+ await button("Keep editing").click();
+ expect(await yaml.inputValue()).toContain("Rejected draft");
+ await button("Revoke access").click();
+ await expect
+ .poll(() => page.getByRole("region", { name: "Workflow editor" }).count())
+ .toBe(0);
+ expect(await page.getByText("Rejected draft", { exact: false }).count()).toBe(
+ 0,
+ );
+ await page.reload();
+ await button("New workflow").click();
+ expect(
+ await page
+ .getByRole("switch", { name: "Enabled in configuration" })
+ .getAttribute("aria-checked"),
+ ).toBe("false");
+ await button("Add Send Message").click();
+ await page
+ .getByLabel("Workflow name", { exact: true })
+ .fill("Incomplete editor");
+ await page
+ .getByLabel("Message text", { exact: true })
+ .fill("Keyboard-created text");
+ await expect.poll(() => button("Save workflow").isEnabled()).toBe(true);
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ expect(await yaml.inputValue()).toContain("enabled: false");
+ await yaml.fill(
+ (await yaml.inputValue()).replace("on: message_posted", "on: webhook"),
+ );
+ expect(await button("Save workflow").isDisabled()).toBe(true);
+ await expect
+ .poll(() => page.getByText(/Webhook-trigger saves are unavailable/).count())
+ .toBe(1);
+ for (const width of [390, 768, 1440]) {
+ await page.setViewportSize({ width, height: 900 });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ }
+ await button("Toggle appearance").click();
+ expect(await page.locator("html").getAttribute("data-color-mode")).toBe(
+ "dark",
+ );
+ await button("Unmount plugin").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowFixture.definitions.disposed()),
+ )
+ .toBe(true);
+ expect(errors).toEqual([]);
+});
diff --git a/src/bundled/workflows/workflows.playwright.config.mjs b/src/bundled/workflows/workflows.playwright.config.mjs
new file mode 100644
index 00000000..bde9095a
--- /dev/null
+++ b/src/bundled/workflows/workflows.playwright.config.mjs
@@ -0,0 +1,24 @@
+import { defineConfig } from "@playwright/test";
+
+// Focused offline UI iteration. The integration owner imports the journey from
+// tests/browser/workflows.spec.mjs so the ordinary browser lanes also run it.
+export default defineConfig({
+ testDir: ".",
+ testMatch: "workflows.journey.mjs",
+ outputDir: "../../../test-results/workflows",
+ workers: 1,
+ retries: 0,
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+ use: {
+ headless: true,
+ actionTimeout: 5_000,
+ viewport: { width: 1000, height: 900 },
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ },
+ projects: ["chromium", "webkit"].map((browserName) => ({
+ name: browserName,
+ use: { browserName },
+ })),
+});
From 1072c87a95ca1ebb4a973c359c63829f925439d4 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 09:10:55 -0600
Subject: [PATCH 07/20] fix(workflows): own read views by subscription and
exercise session UI
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/bundled/workflows/WorkflowChannel.tsx | 25 ++--
src/bundled/workflows/WorkflowRuns.tsx | 70 ++++-----
src/bundled/workflows/WorkflowsPage.tsx | 6 +-
src/bundled/workflows/editor-model.test.ts | 19 +++
src/bundled/workflows/editor-model.ts | 5 +-
src/bundled/workflows/fixture.tsx | 13 +-
src/bundled/workflows/fixtures.ts | 93 ++++++++++--
src/bundled/workflows/session-fixture.html | 1 +
src/bundled/workflows/session-fixture.tsx | 158 ++++++++++++++++++++
src/bundled/workflows/useWorkflowView.ts | 37 +++--
src/bundled/workflows/workflows.journey.mjs | 144 +++++++++++++++++-
11 files changed, 488 insertions(+), 83 deletions(-)
create mode 100644 src/bundled/workflows/session-fixture.html
create mode 100644 src/bundled/workflows/session-fixture.tsx
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
index 953acfc6..e57d45d8 100644
--- a/src/bundled/workflows/WorkflowChannel.tsx
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -1,6 +1,6 @@
import {
useEffect,
- useMemo,
+ useCallback,
useRef,
useState,
useSyncExternalStore,
@@ -39,11 +39,12 @@ export function WorkflowChannel({
viewer: string;
onDraftRiskChange?: (atRisk: boolean) => void;
}) {
- const view = useMemo(
- () => capability.definitions(channelId),
- [capability, channelId],
+ const { snapshot, refresh } = useWorkflowView(
+ useCallback(
+ () => capability.definitions(channelId),
+ [capability, channelId],
+ ),
);
- const snapshot = useWorkflowView(view);
const operations = useSyncExternalStore(
capability.operations.subscribe,
capability.operations.snapshot,
@@ -113,11 +114,10 @@ export function WorkflowChannel({
else open(next);
};
useEffect(() => {
- if (operation?.eventId && operation.outcome === "succeeded")
- void view.refresh();
- }, [operation?.eventId, operation?.outcome, view]);
+ if (operation?.eventId && operation.outcome === "succeeded") void refresh();
+ }, [operation?.eventId, operation?.outcome, refresh]);
useEffect(() => {
- if (!operation || !draft || snapshot.status !== "ready") return;
+ if (!operation || !draft || snapshot?.status !== "ready") return;
const saved = exactSaveReadback(operation, snapshot.data.items);
if (saved) {
submission.current = null;
@@ -127,7 +127,7 @@ export function WorkflowChannel({
// A cleared/unavailable view withdraws the saved private definition from display.
// Unsaved user-authored drafts never become a second retained definition cache.
useEffect(() => {
- if (snapshot.status === "unavailable" || snapshot.status === "idle") {
+ if (snapshot?.status === "unavailable" || snapshot?.status === "idle") {
submission.current = null;
setDraft(null);
setPendingSelection(null);
@@ -135,7 +135,7 @@ export function WorkflowChannel({
setReadRuns(false);
setError(null);
}
- }, [snapshot.status]);
+ }, [snapshot?.status]);
const save = () => {
if (
!draft ||
@@ -239,13 +239,14 @@ export function WorkflowChannel({
: operation?.outcome === "rejected"
? "Save rejected. Your draft is retained; review the error before retrying."
: "This operation has not been resolved. Your draft and operation identity are retained.";
+ if (!snapshot) return Reading configurations…
;
return (
Saved configurations
void view.refresh()}
+ onClick={() => void refresh()}
>
Refresh configurations
diff --git a/src/bundled/workflows/WorkflowRuns.tsx b/src/bundled/workflows/WorkflowRuns.tsx
index e70b44fe..d0ad9255 100644
--- a/src/bundled/workflows/WorkflowRuns.tsx
+++ b/src/bundled/workflows/WorkflowRuns.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useCallback, useState } from "react";
import type {
WorkflowCapability,
WorkflowDefinition,
@@ -43,18 +43,21 @@ function RunPage({
cursor: WorkflowRunCursor | undefined;
onPage: (cursor: WorkflowRunCursor | undefined) => void;
}) {
- const view = useMemo(
- () => capability.runs(workflow, cursor),
- [capability, workflow, cursor],
+ const { snapshot, refresh } = useWorkflowView(
+ useCallback(
+ () => capability.runs(workflow, cursor),
+ [capability, workflow, cursor],
+ ),
);
- const snapshot = useWorkflowView(view);
+ const [approvalRun, setApprovalRun] = useState
(null);
+ if (!snapshot) return Reading runs…
;
return (
Runs
void view.refresh()}
+ onClick={() => void refresh()}
>
Refresh runs
@@ -99,14 +102,23 @@ function RunPage({
{JSON.stringify(run.trace, null, 2)}
-
- Approval history (read-only)
-
-
+
+ setApprovalRun(approvalRun === run.id ? null : run.id)
+ }
+ >
+ {approvalRun === run.id ? "Hide approvals" : "Read approvals"}
+
+ {approvalRun === run.id && (
+
+ )}
))}
@@ -126,24 +138,6 @@ function RunPage({
);
}
-function ApprovalHistory({
- capability,
- workflow,
- runId,
-}: {
- capability: WorkflowCapability;
- workflow: WorkflowReference;
- runId: string;
-}) {
- const [opened, setOpened] = useState(false);
- return opened ? (
-
- ) : (
-
setOpened(true)}>
- Read approvals
-
- );
-}
function ApprovalRows({
capability,
workflow,
@@ -153,11 +147,13 @@ function ApprovalRows({
workflow: WorkflowReference;
runId: string;
}) {
- const view = useMemo(
- () => capability.approvals(workflow, runId),
- [capability, workflow, runId],
+ const { snapshot, refresh } = useWorkflowView(
+ useCallback(
+ () => capability.approvals(workflow, runId),
+ [capability, workflow, runId],
+ ),
);
- const snapshot = useWorkflowView(view);
+ if (!snapshot) return
Reading approvals…
;
return (
{snapshot.status === "ready" ? (
@@ -181,7 +177,7 @@ function ApprovalRows({
void view.refresh()}
+ onClick={() => void refresh()}
>
Refresh approvals
diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx
index bbaf73a2..626a8098 100644
--- a/src/bundled/workflows/WorkflowsPage.tsx
+++ b/src/bundled/workflows/WorkflowsPage.tsx
@@ -2,7 +2,6 @@ import { useState } from "react";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import { useChannelList, useRelayConnection } from "../../features/relay/react";
-import type { WorkflowCapability } from "../../features/workflows/types";
import { Button } from "../../shared/design-system/ui/Button";
import { Panel } from "../../shared/design-system/ui/Panel";
import { Select } from "../../shared/design-system/ui/Select";
@@ -52,10 +51,7 @@ export function WorkflowCommunity({
session: RelaySession;
viewer: string;
}) {
- // Typed optional only during staged host integration; not an alternate capability.
- const capability = (
- session as RelaySession & { workflows?: WorkflowCapability }
- ).workflows;
+ const capability = session.workflows;
const channels = useChannelList(session.channels);
const [selected, setSelected] = useState("");
const [draftAtRisk, setDraftAtRisk] = useState(false);
diff --git a/src/bundled/workflows/editor-model.test.ts b/src/bundled/workflows/editor-model.test.ts
index 030f9d4d..29df9f6b 100644
--- a/src/bundled/workflows/editor-model.test.ts
+++ b/src/bundled/workflows/editor-model.test.ts
@@ -43,3 +43,22 @@ test("save readback requires operation revision plus owner/channel/id", () => {
exactSaveReadback({ ...operation, outcome: "unknown" }, [exact]),
).toBeUndefined();
});
+
+test("draft boundaries match signing: UTF-8 bytes, steps and legacy enabled default", () => {
+ const pad = `#${"a".repeat(24_000 - new TextEncoder().encode(fixtureYaml).length - 1)}`;
+ expect(draftError(fixtureYaml + pad)).toBeNull();
+ expect(draftError(`${fixtureYaml}${pad}é`)).toMatch(/24,000 bytes/);
+ const steps = (count: number) =>
+ "name: bounded\ntrigger: {on: message_posted}\nsteps:\n" +
+ Array.from(
+ { length: count },
+ (_, i) => ` - {id: step_${i}, action: delay, duration: 1s}\n`,
+ ).join("");
+ expect(draftError(steps(100))).toBeNull();
+ expect(draftError(steps(101))).toMatch(/100 steps/);
+ expect(draftError(fixtureYaml.replace("enabled: false\n", ""))).toBeNull();
+ for (const value of ["null", "'true'", "1"])
+ expect(
+ draftError(fixtureYaml.replace("enabled: false", `enabled: ${value}`)),
+ ).toMatch(/true or false/);
+});
diff --git a/src/bundled/workflows/editor-model.ts b/src/bundled/workflows/editor-model.ts
index 873e2c26..d7c42f1d 100644
--- a/src/bundled/workflows/editor-model.ts
+++ b/src/bundled/workflows/editor-model.ts
@@ -29,8 +29,8 @@ export function visualForm(yaml: string): ReturnType
{
/** Draft shape validation is not relay authorization or a promise of execution. */
export function draftError(yaml: string): string | null {
- if (new TextEncoder().encode(yaml).length > 64 * 1024)
- return "The draft is too large (64 KiB maximum).";
+ if (new TextEncoder().encode(yaml).length > 24_000)
+ return "The draft is too large (24,000 bytes maximum).";
try {
const doc = parseDocument(yaml);
if (doc.errors.length)
@@ -50,6 +50,7 @@ export function draftError(yaml: string): string | null {
return "Choose a trigger.";
if (!Array.isArray(data.steps) || !data.steps.length)
return "Add at least one step.";
+ if (data.steps.length > 100) return "Use at most 100 steps.";
const ids = new Set();
for (const step of data.steps) {
if (
diff --git a/src/bundled/workflows/fixture.tsx b/src/bundled/workflows/fixture.tsx
index b34046a1..51be2652 100644
--- a/src/bundled/workflows/fixture.tsx
+++ b/src/bundled/workflows/fixture.tsx
@@ -1,5 +1,8 @@
import { createRoot } from "react-dom/client";
-import { useState } from "react";
+import { StrictMode, useState } from "react";
+import { useKeyboardFocusVisibility } from "../../shared/design-system/useKeyboardFocusVisibility";
+import "@fontsource-variable/inter/wght.css";
+import "@fontsource/jetbrains-mono/400.css";
import "../../shared/styles/globals.css";
import "./workflows.css";
import { Panel } from "../../shared/design-system/ui/Panel";
@@ -14,6 +17,7 @@ import {
const fixture = createWorkflowFixture();
Object.assign(window, { workflowFixture: fixture });
function Fixture() {
+ useKeyboardFocusVisibility();
const [mounted, setMounted] = useState(true);
return (
@@ -60,4 +64,9 @@ function Fixture() {
);
}
const root = document.getElementById("root");
-if (root) createRoot(root).render( );
+if (root)
+ createRoot(root).render(
+
+
+ ,
+ );
diff --git a/src/bundled/workflows/fixtures.ts b/src/bundled/workflows/fixtures.ts
index f9c496e8..21614540 100644
--- a/src/bundled/workflows/fixtures.ts
+++ b/src/bundled/workflows/fixtures.ts
@@ -3,6 +3,10 @@ import type {
WorkflowDefinition,
WorkflowOperation,
WorkflowView,
+ WorkflowRun,
+ WorkflowRunCursor,
+ WorkflowApproval,
+ WorkflowDefinitions,
} from "../../features/workflows/types";
export const fixtureViewer = "11".repeat(32);
@@ -17,6 +21,22 @@ steps:
action: send_message
text: Hello from a fixture
`;
+export const fixtureRun: WorkflowRun = {
+ id: "77777777-7777-4777-8777-777777777777",
+ workflowId: "55555555-5555-4555-8555-555555555555",
+ status: "completed",
+ currentStep: 1,
+ trace: [{ step: "notify", status: "completed" }],
+ createdAt: 1_789_224_000,
+ startedAt: 1_789_224_000,
+ completedAt: 1_789_224_001,
+ errorCode: null,
+ errorMessage: null,
+};
+export const fixtureCursor: WorkflowRunCursor = {
+ before: "2026-09-12T12:00:00.123456Z",
+ beforeId: fixtureRun.id,
+};
export const fixtureDefinition: WorkflowDefinition = {
id: "55555555-5555-4555-8555-555555555555",
owner: fixtureViewer,
@@ -59,15 +79,38 @@ export function fixtureView(data: T) {
/** Explicit offline test capability; never used by the bundled plugin entry. */
export function createWorkflowFixture() {
- const definitions = fixtureView({
- items: [fixtureDefinition] as readonly WorkflowDefinition[],
- partial: false,
- });
+ let definitionState: ReturnType<
+ WorkflowView["snapshot"]
+ > = {
+ status: "ready",
+ data: { items: [fixtureDefinition], partial: false },
+ };
+ const definitionViews: ReturnType>[] =
+ [];
+ const definitions = {
+ update(next: typeof definitionState) {
+ definitionState = next;
+ for (const owned of definitionViews)
+ if (!owned.disposed()) owned.update(next);
+ },
+ disposed: () => definitionViews.every((owned) => owned.disposed()),
+ active: () => definitionViews.filter((owned) => !owned.disposed()).length,
+ };
const listeners = new Set<() => void>();
let operations: readonly WorkflowOperation[] = [];
let counter = 0;
let savedInput: Parameters[0] | undefined;
- const calls = { save: 0, delete: 0, trigger: 0, runs: 0, approvals: 0 };
+ const calls = {
+ save: 0,
+ delete: 0,
+ trigger: 0,
+ runs: 0,
+ approvals: 0,
+ retry: [] as string[],
+ };
+ const runViews: { disposed(): boolean }[] = [];
+ const approvalViews: { disposed(): boolean }[] = [];
+ let runCursor: WorkflowRunCursor | undefined;
const publish = (next: readonly WorkflowOperation[]) => {
operations = next;
for (const listener of listeners) listener();
@@ -99,14 +142,36 @@ export function createWorkflowFixture() {
delete: true,
webhookSecrets: false,
},
- definitions: () => definitions.view,
- runs: () => {
+ definitions: () => {
+ const owned = fixtureView(definitionState.data);
+ owned.update(definitionState);
+ definitionViews.push(owned);
+ return owned.view;
+ },
+ runs: (_workflow, cursor) => {
calls.runs++;
- return fixtureView({ runs: [], next: null }).view;
+ runCursor = cursor;
+ const next = fixtureView({
+ runs: cursor ? [] : [fixtureRun],
+ next: cursor ? null : fixtureCursor,
+ });
+ runViews.push(next);
+ return next.view;
},
approvals: () => {
calls.approvals++;
- return fixtureView([]).view;
+ const next = fixtureView([
+ {
+ reference: "cc".repeat(32),
+ runId: fixtureRun.id,
+ stepId: "notify",
+ status: "granted",
+ note: "Fixture decision",
+ createdAt: 1_789_224_000,
+ },
+ ]);
+ approvalViews.push(next);
+ return next.view;
},
save(input) {
calls.save++;
@@ -136,7 +201,9 @@ export function createWorkflowFixture() {
listeners.delete(listener);
};
},
- retry() {},
+ retry(id) {
+ calls.retry.push(id);
+ },
async dismiss() {},
},
takeWebhookSecret() {
@@ -148,6 +215,9 @@ export function createWorkflowFixture() {
definitions,
calls,
input: () => savedInput,
+ runCursor: () => runCursor,
+ runViews,
+ approvalViews,
finish(outcome: WorkflowOperation["outcome"], exact = true) {
const operation = operations.at(-1);
if (!operation) throw new Error("No operation");
@@ -184,6 +254,9 @@ export function createWorkflowFixture() {
...item,
outcome,
delivery: outcome === "rejected" ? "failed" : "accepted",
+ ...(item.action === "trigger" && outcome === "succeeded"
+ ? { runId: fixtureRun.id }
+ : {}),
...(outcome === "rejected"
? { error: "Fixture conflict" }
: {}),
diff --git a/src/bundled/workflows/session-fixture.html b/src/bundled/workflows/session-fixture.html
new file mode 100644
index 00000000..42459a16
--- /dev/null
+++ b/src/bundled/workflows/session-fixture.html
@@ -0,0 +1 @@
+Offline workflows session fixture
diff --git a/src/bundled/workflows/session-fixture.tsx b/src/bundled/workflows/session-fixture.tsx
new file mode 100644
index 00000000..094553ac
--- /dev/null
+++ b/src/bundled/workflows/session-fixture.tsx
@@ -0,0 +1,158 @@
+import { StrictMode, useState } from "react";
+import { createRoot } from "react-dom/client";
+import type { ReadJournal } from "../../features/relay/read-state-storage";
+import { createRelaySession } from "../../features/relay/session";
+import type { RelayData, RelaySnapshot } from "../../features/relay/service";
+import {
+ keypair,
+ metadata,
+ roster,
+ signed,
+} from "../../features/relay/testing";
+import type { RelayEvent } from "../../features/relay/events";
+import { Button } from "../../shared/design-system/ui/Button";
+import { useKeyboardFocusVisibility } from "../../shared/design-system/useKeyboardFocusVisibility";
+import { WorkflowsPage } from "./WorkflowsPage";
+import { fixtureChannel, fixtureDefinition, fixtureYaml } from "./fixtures";
+import "@fontsource-variable/inter/wght.css";
+import "@fontsource/jetbrains-mono/400.css";
+import "../../shared/styles/globals.css";
+
+// Real session/protocol/read ownership with disposable test keys and memory only.
+// No broker, network, keychain, or workflow writes.
+const viewer = keypair();
+const authority = keypair();
+const secondChannel = "88888888-8888-4888-8888-888888888888";
+const channels = [fixtureChannel, secondChannel];
+let incoming: ((events: readonly RelayEvent[]) => void) | undefined;
+let generation = 0;
+let currentScope = "Fixture A";
+let definitionReads = 0;
+function session(scope: string) {
+ let journal: ReadJournal | undefined;
+ const events = channels.flatMap((channel, index) => [
+ roster(authority, channel, [viewer.pubkey]),
+ metadata(authority, channel, index ? "Second channel" : "First channel"),
+ signed(viewer, {
+ kind: 30620,
+ content: fixtureYaml.replace("Message helper", `${scope} helper`),
+ tags: [
+ ["h", channel],
+ ["d", fixtureDefinition.id],
+ ],
+ }),
+ ]);
+ return createRelaySession(
+ {
+ scope,
+ viewer: viewer.pubkey,
+ relayAuthor: authority.pubkey,
+ media: () => undefined,
+ async query(filters) {
+ if (filters.some((filter) => filter.kinds?.includes(30620)))
+ definitionReads++;
+ return events.filter((event) =>
+ filters.some(
+ (filter) =>
+ (!filter.kinds || filter.kinds.includes(event.kind)) &&
+ (!filter["#h"] ||
+ event.tags.some(
+ ([k, v]) => k === "h" && v && filter["#h"]?.includes(v),
+ )),
+ ),
+ );
+ },
+ subscribe(callbacks) {
+ incoming = callbacks.receive;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ },
+ {
+ readStateStorage: {
+ async update(change) {
+ journal = change(journal);
+ return journal;
+ },
+ close() {},
+ },
+ },
+ );
+}
+let owner = session(currentScope);
+let snapshot: RelaySnapshot = {
+ status: "ready",
+ generation,
+ scope: currentScope,
+ viewer: viewer.pubkey,
+ session: owner.session,
+};
+const listeners = new Set<() => void>();
+const relay: RelayData = {
+ snapshot: () => snapshot,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ retry() {},
+ disconnect() {},
+ clearCache: () => owner.clearCache(),
+};
+function switchScope() {
+ owner.dispose();
+ currentScope = currentScope === "Fixture A" ? "Fixture B" : "Fixture A";
+ generation++;
+ owner = session(currentScope);
+ snapshot = {
+ status: "ready",
+ generation,
+ scope: currentScope,
+ viewer: viewer.pubkey,
+ session: owner.session,
+ };
+ for (const listener of listeners) listener();
+}
+Object.assign(window, {
+ sessionFixture: { definitionReads: () => definitionReads },
+});
+function Fixture() {
+ useKeyboardFocusVisibility();
+ const [mounted, setMounted] = useState(true);
+ return (
+
+
+ Offline production-session fixture — ephemeral keys; no network or
+ writes.
+
+
+ Switch community
+ {
+ incoming?.([roster(authority, fixtureChannel, [], 1_800_000_000)]);
+ }}
+ >
+ Revoke selected channel
+
+ {
+ void owner.clearCache();
+ }}
+ >
+ Clear session cache
+
+ setMounted((value) => !value)}>
+ Toggle workflows page
+
+
+ {mounted && }
+
+ );
+}
+const root = document.getElementById("root");
+if (root)
+ createRoot(root).render(
+
+
+ ,
+ );
diff --git a/src/bundled/workflows/useWorkflowView.ts b/src/bundled/workflows/useWorkflowView.ts
index 347b1c34..9331d03a 100644
--- a/src/bundled/workflows/useWorkflowView.ts
+++ b/src/bundled/workflows/useWorkflowView.ts
@@ -1,16 +1,31 @@
-import { useEffect, useSyncExternalStore } from "react";
+import { useMemo, useSyncExternalStore } from "react";
import type { WorkflowView } from "../../features/workflows/types";
-/** Host factories create idle interest; the mounted consumer owns start/stop. */
-export function useWorkflowView(view: WorkflowView) {
+/** Allocate host interest on subscription, not render. StrictMode may discard a
+ * render or unsubscribe/resubscribe without constructing a new component. */
+export function useWorkflowView(create: () => WorkflowView) {
+ const store = useMemo(() => {
+ let view: WorkflowView | undefined;
+ return {
+ snapshot: () => view?.snapshot(),
+ refresh: () => view?.refresh() ?? Promise.resolve(),
+ subscribe(listener: () => void) {
+ const owned = create();
+ view = owned;
+ const stop = owned.subscribe(listener);
+ void owned.refresh();
+ return () => {
+ stop();
+ owned.dispose();
+ if (view === owned) view = undefined;
+ };
+ },
+ };
+ }, [create]);
const snapshot = useSyncExternalStore(
- view.subscribe,
- view.snapshot,
- view.snapshot,
+ store.subscribe,
+ store.snapshot,
+ store.snapshot,
);
- useEffect(() => {
- void view.refresh();
- return () => view.dispose();
- }, [view]);
- return snapshot;
+ return { snapshot, refresh: store.refresh };
}
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index 6674aa06..9248a5fa 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -27,6 +27,12 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
page.on("pageerror", (error) => errors.push(String(error)));
await page.goto(url);
const button = (name) => page.getByRole("button", { name, exact: true });
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowFixture.definitions.active()),
+ )
+ .toBe(1);
+ await button("Refresh configurations").click();
await button("Message helper").click();
await page.getByRole("tab", { name: "YAML", exact: true }).click();
const yaml = page.getByLabel("Workflow YAML", { exact: true });
@@ -66,8 +72,12 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
await button("Continue editing retained draft").click();
expect(await button("Save workflow").isEnabled()).toBe(true);
await button("Close editor").click();
- await expect.poll(() => page.getByRole("alertdialog").count()).toBe(1);
- await button("Keep editing").click();
+ await expect(
+ page.getByRole("alertdialog", { name: "Leave this draft?" }),
+ ).toBeVisible();
+ await expect(button("Keep editing")).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(button("Close editor")).toBeFocused();
expect(await yaml.inputValue()).toContain("Rejected draft");
await button("Revoke access").click();
await expect
@@ -109,9 +119,17 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
).toBe(true);
}
await button("Toggle appearance").click();
- expect(await page.locator("html").getAttribute("data-color-mode")).toBe(
- "dark",
+ await expect(page.locator("html")).toHaveAttribute("data-color-mode", "dark");
+ // Wait for the shared control transition before checking its final paint.
+ await expect(button("Close editor")).toHaveCSS("color", "rgb(245, 245, 245)");
+ await expect(button("Close editor")).toHaveCSS(
+ "background-color",
+ "rgb(22, 22, 22)",
);
+ await yaml.focus();
+ await page.keyboard.press("ArrowLeft");
+ await expect(yaml).toHaveCSS("outline-style", "solid");
+ await expect(yaml).toHaveCSS("outline-width", "2px");
await button("Unmount plugin").click();
await expect
.poll(() =>
@@ -120,3 +138,121 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
.toBe(true);
expect(errors).toEqual([]);
});
+
+test("history reads are lazy, paged by exact cursor and released; unknown operations never get a replacement ID", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ expect(await page.evaluate(() => window.workflowFixture.calls.runs)).toBe(0);
+ await button("Read runs").click();
+ await expect(page.getByText("Current step: 1")).toBeVisible();
+ expect(
+ await page.evaluate(() => window.workflowFixture.calls.approvals),
+ ).toBe(0);
+ await button("Read approvals").click();
+ await expect(
+ page.getByText("notify: granted — Fixture decision"),
+ ).toBeVisible();
+ await button("Hide approvals").click();
+ expect(
+ await page.evaluate(() =>
+ window.workflowFixture.approvalViews.every((view) => view.disposed()),
+ ),
+ ).toBe(true);
+ await button("Older runs").click();
+ await expect(page.getByText("No runs returned on this page.")).toBeVisible();
+ expect(await page.evaluate(() => window.workflowFixture.runCursor())).toEqual(
+ {
+ before: "2026-09-12T12:00:00.123456Z",
+ beforeId: "77777777-7777-4777-8777-777777777777",
+ },
+ );
+ expect(
+ await page.evaluate(() =>
+ window.workflowFixture.runViews
+ .slice(0, -1)
+ .every((view) => view.disposed()),
+ ),
+ ).toBe(true);
+ await button("Hide runs").click();
+ expect(
+ await page.evaluate(() =>
+ window.workflowFixture.runViews.every((view) => view.disposed()),
+ ),
+ ).toBe(true);
+ await button("Run now").click();
+ await button("Unknown operation").click();
+ await expect(button("Run now")).toBeDisabled();
+ const id = await page.evaluate(
+ () =>
+ window.workflowFixture.capability.operations.snapshot().at(-1).eventId,
+ );
+ await button("Retry same signed operation").click();
+ expect(await page.evaluate(() => window.workflowFixture.calls.retry)).toEqual(
+ [id],
+ );
+ await button("Close editor").click();
+ await button("Message helper").click();
+ await expect(button("Run now")).toBeDisabled();
+ await expect(button("Save workflow")).toBeDisabled();
+ expect(await page.evaluate(() => window.workflowFixture.calls.trigger)).toBe(
+ 1,
+ );
+});
+
+test("real session page under StrictMode fences community changes, warns for dirty channel navigation and purges access", async ({
+ page,
+}) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ await page.goto(url.replace("/fixture.html", "/session-fixture.html"));
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ const choose = async (name) => {
+ await page.getByRole("combobox", { name: "Channel", exact: true }).click();
+ await page.getByRole("option", { name, exact: true }).click();
+ };
+ await choose("First channel");
+ await button("Fixture A helper").click();
+ await expect(button("Save workflow")).toBeDisabled();
+ await page
+ .getByLabel("Workflow name", { exact: true })
+ .fill("Unsaved private text");
+ await choose("Second channel");
+ await expect(page.getByRole("alertdialog")).toBeVisible();
+ await button("Keep editing").click();
+ await expect(page.getByLabel("Workflow name", { exact: true })).toHaveValue(
+ "Unsaved private text",
+ );
+ await choose("Second channel");
+ await page
+ .getByRole("alertdialog")
+ .getByRole("button", { name: "Change channel", exact: true })
+ .click();
+ await expect(
+ page.getByRole("region", { name: "Workflow editor" }),
+ ).toHaveCount(0);
+ await choose("First channel");
+ await button("Fixture A helper").click();
+ await button("Switch community").click();
+ await expect(
+ page.getByRole("region", { name: "Workflow editor" }),
+ ).toHaveCount(0);
+ await choose("First channel");
+ await button("Fixture B helper").click();
+ await button("Switch community").click();
+ await choose("First channel");
+ await button("Fixture A helper").click();
+ await page
+ .getByLabel("Workflow name", { exact: true })
+ .fill("Revoked private text");
+ await button("Revoke selected channel").click();
+ await expect(
+ page.getByRole("region", { name: "Workflow editor" }),
+ ).toHaveCount(0);
+ expect(await page.locator("body").innerText()).not.toContain(
+ "Revoked private text",
+ );
+ expect(errors).toEqual([]);
+});
From 1ad1bbcc63d949429282a91597adbb7daaf37ba1 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 09:15:04 -0600
Subject: [PATCH 08/20] feat(workflows): register the page in both bundled
catalogs
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
crates/plugin-manager/src/lib.rs | 2 ++
src/app/pages.integration.test.mjs | 31 ++++++++++++++++++++++++++++--
src/bundled/index.ts | 3 +++
3 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs
index 4cc98d0a..34d760fe 100644
--- a/crates/plugin-manager/src/lib.rs
+++ b/crates/plugin-manager/src/lib.rs
@@ -65,6 +65,8 @@ pub fn bundled_manifests() -> Vec {
.expect("projects manifest"),
serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json"))
.expect("agents manifest"),
+ serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json"))
+ .expect("workflows manifest"),
]
}
fn is_bundled(id: &str) -> bool {
diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs
index ed4fa6db..3aaac3c7 100644
--- a/src/app/pages.integration.test.mjs
+++ b/src/app/pages.integration.test.mjs
@@ -29,7 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
services = createServices();
assert.deepEqual(services.pages.snapshot(), []);
await settle();
- assert.equal(services.pages.snapshot().length, 3);
+ assert.equal(services.pages.snapshot().length, 4);
await vi.waitFor(() =>
assert.equal(services.conversation.tools.snapshot().length, 2),
);
@@ -85,7 +85,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.some((panel) => panel.pluginId === "buzz.bestie"),
false,
);
- assert.equal(services.pages.snapshot().length, 3);
+ assert.equal(services.pages.snapshot().length, 4);
await services.plugins.change("enable", "buzz.bestie");
// Management completion is not activation completion; Cordis still owns import/disposal barriers.
await vi.waitFor(() =>
@@ -121,6 +121,33 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
);
assert.equal(services.relay.snapshot().session, session);
assert.ok(session.agentLibrary);
+ const workflows = services.pages
+ .snapshot()
+ .find((page) => page.pluginId === "buzz.workflows");
+ assert.equal(workflows.title, "Workflows");
+ assert.equal(workflows.layout, "workspace");
+ assert.match(
+ renderToStaticMarkup(createElement(workflows.component)),
+ /Connect to a community/,
+ );
+ await services.plugins.change("disable", "buzz.workflows");
+ assert.equal(
+ services.pages
+ .snapshot()
+ .some((page) => page.pluginId === "buzz.workflows"),
+ false,
+ );
+ assert.equal(services.relay.snapshot().session, session);
+ assert.ok(session.workflows);
+ await services.plugins.change("enable", "buzz.workflows");
+ await vi.waitFor(() =>
+ assert.ok(
+ services.pages
+ .snapshot()
+ .some((page) => page.pluginId === "buzz.workflows"),
+ ),
+ );
+ await services.plugins.change("disable", "buzz.workflows");
await services.plugins.change("disable", "buzz.channels");
const [projects] = services.pages.snapshot();
assert.equal(services.pages.snapshot().length, 1);
diff --git a/src/bundled/index.ts b/src/bundled/index.ts
index 78cf15c0..7e4ea521 100644
--- a/src/bundled/index.ts
+++ b/src/bundled/index.ts
@@ -16,6 +16,8 @@ import bestieManifest from "./bestie/manifest.json";
import * as bestie from "./bestie";
import projectsManifest from "./projects/manifest.json";
import * as projects from "./projects";
+import workflowsManifest from "./workflows/manifest.json";
+import * as workflows from "./workflows";
import type { BundledPlugin } from "../plugins/manager";
export const bundledPlugins: readonly BundledPlugin[] = [
@@ -28,4 +30,5 @@ export const bundledPlugins: readonly BundledPlugin[] = [
{ manifest: { ...bestieManifest, apiVersion: 1 }, module: bestie },
{ manifest: { ...projectsManifest, apiVersion: 1 }, module: projects },
{ manifest: { ...agentsManifest, apiVersion: 1 }, module: agents },
+ { manifest: { ...workflowsManifest, apiVersion: 1 }, module: workflows },
];
From 9a2da965f1c68f270e3d354663310fb5867a461c Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 09:46:32 -0600
Subject: [PATCH 09/20] fix(workflows): explain unavailable workflow creation
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/bundled/workflows/WorkflowChannel.tsx | 10 ++++++--
src/bundled/workflows/session-fixture.tsx | 21 ++++++++++------
src/bundled/workflows/workflows.journey.mjs | 28 +++++++++++++++++++++
3 files changed, 49 insertions(+), 10 deletions(-)
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
index e57d45d8..920dc6c2 100644
--- a/src/bundled/workflows/WorkflowChannel.tsx
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -283,10 +283,16 @@ export function WorkflowChannel({
{snapshot.data.partial && (
This is a bounded, partial list.
)}
+ {!capability.availability.save && (
+
+ Creating and saving workflows is unavailable from this host. You can
+ browse saved configurations, but cannot save changes here yet.
+
+ )}
{snapshot.status === "ready" && !snapshot.data.items.length && (
- No saved configurations returned for this channel. Create a disabled
- draft to start.
+ No saved configurations returned for this channel.
+ {capability.availability.save && " Create a disabled draft to start."}
)}
diff --git a/src/bundled/workflows/session-fixture.tsx b/src/bundled/workflows/session-fixture.tsx
index 094553ac..e8952e82 100644
--- a/src/bundled/workflows/session-fixture.tsx
+++ b/src/bundled/workflows/session-fixture.tsx
@@ -33,14 +33,19 @@ function session(scope: string) {
const events = channels.flatMap((channel, index) => [
roster(authority, channel, [viewer.pubkey]),
metadata(authority, channel, index ? "Second channel" : "First channel"),
- signed(viewer, {
- kind: 30620,
- content: fixtureYaml.replace("Message helper", `${scope} helper`),
- tags: [
- ["h", channel],
- ["d", fixtureDefinition.id],
- ],
- }),
+ // The second channel exercises the real session's read-only empty state.
+ ...(index
+ ? []
+ : [
+ signed(viewer, {
+ kind: 30620,
+ content: fixtureYaml.replace("Message helper", `${scope} helper`),
+ tags: [
+ ["h", channel],
+ ["d", fixtureDefinition.id],
+ ],
+ }),
+ ]),
]);
return createRelaySession(
{
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index 9248a5fa..abb6a13e 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -87,6 +87,18 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
0,
);
await page.reload();
+ await page.evaluate(() =>
+ window.workflowFixture.definitions.update({
+ status: "ready",
+ data: { items: [], partial: false },
+ }),
+ );
+ await expect(
+ page.getByText(/Create a disabled draft to start/),
+ ).toBeVisible();
+ await expect(
+ page.getByText(/Creating and saving workflows is unavailable/),
+ ).toHaveCount(0);
await button("New workflow").click();
expect(
await page
@@ -214,6 +226,10 @@ test("real session page under StrictMode fences community changes, warns for dir
await page.getByRole("option", { name, exact: true }).click();
};
await choose("First channel");
+ await expect(button("New workflow")).toBeDisabled();
+ await expect(
+ page.getByText(/Creating and saving workflows is unavailable/),
+ ).toBeVisible();
await button("Fixture A helper").click();
await expect(button("Save workflow")).toBeDisabled();
await page
@@ -230,6 +246,18 @@ test("real session page under StrictMode fences community changes, warns for dir
.getByRole("alertdialog")
.getByRole("button", { name: "Change channel", exact: true })
.click();
+ await expect(
+ page.getByText("No saved configurations returned for this channel.", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(button("New workflow")).toBeDisabled();
+ await expect(
+ page.getByText(/Creating and saving workflows is unavailable/),
+ ).toBeVisible();
+ await expect(page.getByText(/Create a disabled draft to start/)).toHaveCount(
+ 0,
+ );
await expect(
page.getByRole("region", { name: "Workflow editor" }),
).toHaveCount(0);
From 108dacf9f13a6151588dde963057e75b2e958ca3 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 10:24:38 -0600
Subject: [PATCH 10/20] feat(workflows): negotiate lifecycle before real host
writes
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
dev/relay-broker.mjs | 57 +++++-
dev/workflow-broker.test.mjs | 173 +++++++++++++++++-
docs/workflows.md | 40 ++++
src/features/relay/signed-admission.test.ts | 6 +
src/features/relay/signed-boundary.test.ts | 6 +
src/features/relay/signed-priority.test.ts | 6 +
src/features/relay/transport.test.ts | 6 +
src/features/relay/transport.ts | 86 ++++++---
src/features/workflows/compatibility.test.ts | 182 +++++++++++++++++++
src/features/workflows/compatibility.ts | 56 ++++++
src/features/workflows/http.test.ts | 6 +
src/features/workflows/http.ts | 2 +
12 files changed, 598 insertions(+), 28 deletions(-)
create mode 100644 src/features/workflows/compatibility.test.ts
create mode 100644 src/features/workflows/compatibility.ts
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index f2d15596..7f6b3671 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -1,3 +1,8 @@
+import { workflowLifecycleVersion } from "../src/features/workflows/compatibility.ts";
+import {
+ validateWorkflowEvent,
+ WORKFLOW_KINDS,
+} from "../src/features/workflows/protocol.ts";
import {
workflowReadPath,
workflowReadText,
@@ -175,7 +180,7 @@ async function relayAuthority(fetch, relay) {
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error("Relay identity discovery failed");
- const nip11 = await response.json();
+ const nip11 = JSON.parse(await workflowReadText(response));
if (!nip11 || typeof nip11 !== "object" || Array.isArray(nip11))
throw new Error("Relay did not advertise its identity");
const author = nip11.self ?? nip11.pubkey;
@@ -183,6 +188,15 @@ async function relayAuthority(fetch, relay) {
throw new Error("Relay did not advertise its identity");
return {
relayAuthor: author,
+ ...(workflowLifecycleVersion(nip11, relay, author) === 1
+ ? {
+ workflowInfo: {
+ self: nip11.self,
+ supported_extensions: ["buzz-workflows"],
+ workflows: { lifecycle: 1, host: new URL(relay).host },
+ },
+ }
+ : {}),
...(readSnapshotCommunity(nip11.read_state_snapshot)
? { readStateCommunity: readSnapshotCommunity(nip11.read_state_snapshot) }
: {}),
@@ -506,18 +520,27 @@ export function relayBrokerPlugin({
});
}
}
- if (route === "/api/relay/session" && req.method === "GET")
+ if (route === "/api/relay/session" && req.method === "GET") {
+ const discovered = await authority(fetchUpstream, relay);
return json(res, 200, {
viewer,
- ...(await getAuthority(relay)),
+ ...discovered,
relayUrl: relay,
- writeKinds: [9],
+ writeKinds:
+ workflowLifecycleVersion(
+ discovered.workflowInfo,
+ relay,
+ discovered.relayAuthor,
+ ) === 1
+ ? [9, ...WORKFLOW_KINDS]
+ : [9],
workflowReads: true,
sidebarPreferences: true,
readState: true,
agentLibrary: true,
live: true,
});
+ }
if (
["/api/relay/stream-retry", "/api/relay/stream-priority"].includes(
route,
@@ -837,13 +860,35 @@ export function relayBrokerPlugin({
const signing = route === "/api/relay/sign";
const publishing = route === "/api/relay/publish";
if (signing || publishing) {
- if (!validMessageTemplate(filters))
+ if (filters?.kind !== 9) {
+ try {
+ const discovered = await authority(fetchUpstream, relay);
+ if (
+ workflowLifecycleVersion(
+ discovered.workflowInfo,
+ relay,
+ discovered.relayAuthor,
+ ) !== 1
+ )
+ throw new Error("Workflow lifecycle unsupported");
+ validateWorkflowEvent(
+ { ...filters, pubkey: signing ? viewer : filters.pubkey },
+ viewer,
+ { delete: true, webhookSecrets: false },
+ );
+ } catch {
+ return json(res, 400, {
+ error: "Workflow operation unavailable or invalid",
+ sent: false,
+ });
+ }
+ } else if (!validMessageTemplate(filters))
return json(res, 400, { error: "Message rejected" });
if (signing) {
const started = performance.now();
const event = finalizeEvent(
{
- kind: 9,
+ kind: filters.kind,
content: filters.content,
created_at: filters.created_at,
tags: filters.tags,
diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs
index 702dbb1f..51264605 100644
--- a/dev/workflow-broker.test.mjs
+++ b/dev/workflow-broker.test.mjs
@@ -11,6 +11,7 @@ const runId = "22222222-2222-4222-8222-222222222222";
const cursor = { before: "2026-09-12T14:44:19.123456+00:00", beforeId: runId };
async function harness(
respond = () => Response.json({ runs: [], next: null }),
+ metadata,
) {
const key = new Uint8Array(32);
key[31] = 8;
@@ -26,8 +27,14 @@ async function harness(
relayUrl: "https://a.workflow.test",
communityAliases: JSON.stringify({ secondary: "https://b.workflow.test" }),
identity: () => key,
- authority: async () => ({ relayAuthor: viewer }),
+ ...(metadata ? {} : { authority: async () => ({ relayAuthor: viewer }) }),
upstreamFetch: async (url, init) => {
+ if (init.headers.Accept === "application/nostr+json") {
+ expect(init.redirect).toBe("error");
+ const data = metadata(String(url), viewer);
+ if (data instanceof Error) throw data;
+ return data instanceof Response ? data : Response.json(data);
+ }
const auth = JSON.parse(
Buffer.from(init.headers.Authorization.slice(6), "base64").toString(),
);
@@ -57,6 +64,7 @@ async function harness(
const base = `http://127.0.0.1:${server.address().port}`;
return {
base,
+ viewer,
calls,
logs,
post: (route, body, headers = {}) =>
@@ -221,3 +229,166 @@ it("closing workflow interest aborts the actual broker upstream request", async
await h.close();
}
});
+
+const compatible = (url, viewer) => ({
+ self: viewer,
+ supported_extensions: ["buzz-workflows"],
+ workflows: { lifecycle: 1, host: new URL(url).host },
+});
+const yaml =
+ "name: Local test\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: wait\n action: delay\n duration: 1s\n";
+const template = (kind = 30620) => ({
+ kind,
+ created_at: Math.floor(Date.now() / 1000),
+ content: kind === 30620 ? yaml : "",
+ tags: [
+ ["h", runId],
+ ["d", id],
+ ],
+});
+it("real discovery enables only canonical workflow sign/publish with exact own events and unchanged receipts", async () => {
+ const h = await harness(({ init }) => {
+ const event = JSON.parse(init.body);
+ expect(verifyEvent(event)).toBe(true);
+ return Response.json({
+ accepted: true,
+ event_id: event.id,
+ message: "workflow-result",
+ });
+ }, compatible);
+ try {
+ const t = await connectBrokerTransport(h.base);
+ expect(t.workflows.lifecycleVersion).toBe(1);
+ expect(t.writer.kinds).toEqual([9, 30620, 46020, 5]);
+ for (const input of [
+ template(),
+ template(46020),
+ {
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["a", `30620:${h.viewer}:${id}`],
+ ],
+ },
+ ]) {
+ const event = await t.writer.sign(input, signal());
+ expect(verifyEvent(event)).toBe(true);
+ expect(event.pubkey).toBe(h.viewer);
+ expect(event.kind).toBe(input.kind);
+ expect(event.content).toBe(input.content);
+ expect(event.tags).toEqual(input.tags);
+ expect(await t.writer.publish(event, signal())).toBe("workflow-result");
+ expect(JSON.parse(h.calls.at(-1).init.body)).toEqual(
+ JSON.parse(JSON.stringify(event)),
+ );
+ }
+ expect(h.calls).toHaveLength(3);
+ } finally {
+ await h.close();
+ }
+});
+it("broker checks fresh own-host evidence at sign and publish; old, other-host and downgrade cannot inherit a write grant", async () => {
+ let enabled = true;
+ const h = await harness(
+ () => {
+ throw new Error("must not dispatch writes");
+ },
+ (url, viewer) =>
+ enabled && url.startsWith("https://a.")
+ ? compatible(url, viewer)
+ : { self: viewer },
+ );
+ try {
+ const t = await connectBrokerTransport(h.base);
+ expect(t.workflows.lifecycleVersion).toBe(1);
+ const event = await t.writer.sign(template(), signal());
+ const other = await connectBrokerTransport(h.base, undefined, "secondary");
+ expect(other.workflows.lifecycleVersion).toBeUndefined();
+ expect(other.writer.kinds).toEqual([9]);
+ await expect(other.writer.sign(template(), signal())).rejects.toThrow();
+ enabled = false;
+ await expect(t.writer.sign(template(), signal())).rejects.toThrow();
+ await expect(t.writer.publish(event, signal())).rejects.toThrow();
+ expect(
+ (await connectBrokerTransport(h.base)).workflows.lifecycleVersion,
+ ).toBeUndefined();
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+});
+it("compatible broker refuses malformed, alternate-delete, webhook and forged commands before upstream writes", async () => {
+ const h = await harness(() => {
+ throw new Error("must not dispatch writes");
+ }, compatible);
+ try {
+ for (const input of [
+ null,
+ { ...template(), kind: 9005 },
+ {
+ ...template(),
+ tags: [
+ ["h", runId],
+ ["d", "name"],
+ ],
+ },
+ { ...template(), content: yaml.replace("message_posted", "webhook") },
+ {
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["e", "a".repeat(64)],
+ ],
+ },
+ {
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["a", `030620:${h.viewer}:${id}`],
+ ],
+ },
+ {
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["a", `30620:${"a".repeat(64)}:${id}`],
+ ],
+ },
+ { ...template(), tags: [...template().tags, ["p", "a".repeat(64)]] },
+ ]) {
+ expect((await h.post("sign", input)).status).toBe(400);
+ }
+ const own = await (await h.post("sign", template())).json();
+ expect(
+ (await h.post("publish", { ...own, content: "tampered" })).status,
+ ).toBe(400);
+ expect(
+ (await h.post("publish", { ...own, pubkey: "a".repeat(64) })).status,
+ ).toBe(400);
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+});
+it("real NIP-11 discovery does not accept fallback identity, malformed or wrong-host descriptor", async () => {
+ for (const mutate of [
+ (d) => ({ ...d, self: undefined, pubkey: d.self }),
+ (d) => ({ ...d, supported_extensions: [] }),
+ (d) => ({ ...d, workflows: { ...d.workflows, lifecycle: "1" } }),
+ (d) => ({ ...d, workflows: { ...d.workflows, host: "other.test" } }),
+ (d) => ({ ...d, workflows: null }),
+ ]) {
+ const h = await harness(undefined, (url, viewer) =>
+ mutate(compatible(url, viewer)),
+ );
+ try {
+ const t = await connectBrokerTransport(h.base);
+ expect(t.workflows.lifecycleVersion).toBeUndefined();
+ expect(t.writer.kinds).toEqual([9]);
+ await expect(t.writer.sign(template(), signal())).rejects.toThrow();
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+ }
+});
diff --git a/docs/workflows.md b/docs/workflows.md
index 0463a817..31e16a83 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -109,3 +109,43 @@ A save receipt does not populate a read view. Refresh explicitly and match both
permitting resave. A coordinate-only old/concurrent head is not save readback.
Generic kind-5 deletes retain their previous behavior: only workflow-coordinate
kind-5 operations use workflow validation and receipt semantics.
+
+## Forward lifecycle advertisement (implementation checkpoint)
+
+A repaired relay advertises the extension `buzz-workflows` in its host-scoped
+NIP-11 document, alongside its explicit stable `self` key and:
+
+```json
+{"workflows":{"lifecycle":1,"host":"relay.example:8443"}}
+```
+
+`host` is the normalized, successfully resolved request authority (no scheme or
+path; non-default port retained). The descriptor is absent when host resolution
+fails or the relay has no stable identity. Revision 1 promises atomic forward
+signed-definition/runtime saves and canonical, timestamp-ordered deletion with
+retained cutoff proofs. Legacy name, numeric-kind aliases, definition-e-target
+and admin-delete entrances reject; historical split state is not repaired or
+certified. Authorization, moderation, schema fencing and execution remain relay
+checks, not implications of this metadata.
+
+The app requires all three fields: extension, exact numeric lifecycle 1 and
+matching normalized host, plus a lowercase explicit `self` matching the session's
+relay authority. No software-version, kind-list or contact-key fallback. Metadata
+is fetched from the selected HTTPS origin without redirects, bounded to 1 MiB and
+10 seconds. Missing/malformed/failed evidence never grants workflow writes.
+
+Both signed and broker hosts discover per connection, and recheck before workflow
+signing and publication (no retained positive compatibility cache). Broker session
+DTOs project the validated descriptor; browser code validates it again. A downgrade
+can leave old UI controls visible until reconnect, but signing/publication rejects
+without sending the workflow. Broker signing permits only canonical owned
+workflow operations through the same shared validator as the session. Webhook
+saves and alternate deletion shapes are unavailable; ordinary message signing
+remains unchanged. Metadata is a compatibility assertion, not a cryptographic
+lease against a server replacement between the check and write.
+
+The application’s real browser/native dev shell currently uses the broker;
+`connectSignedTransport` is the alternative signer-owned adapter, not a claim
+that native Keychain production wiring has been exercised. The new handshake
+still requires independent review and an approved disposable live test. No
+production deployment or real create/run validation is implied by this checkpoint.
diff --git a/src/features/relay/signed-admission.test.ts b/src/features/relay/signed-admission.test.ts
index 42121bc8..2e32b8dc 100644
--- a/src/features/relay/signed-admission.test.ts
+++ b/src/features/relay/signed-admission.test.ts
@@ -139,3 +139,9 @@ it("explicit quota rejection is retryable; missing response stays unknown with n
await delay(550);
expect(fetcher).toHaveBeenCalledTimes(2);
});
+
+// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
+vi.mock("../workflows/compatibility", async (original) => ({
+ ...(await original()),
+ discoverWorkflowLifecycle: async () => undefined,
+}));
diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts
index 5eb8633f..68299ed2 100644
--- a/src/features/relay/signed-boundary.test.ts
+++ b/src/features/relay/signed-boundary.test.ts
@@ -294,3 +294,9 @@ it("async auth ownership is bounded across signed constructor recreation", async
await next;
expect(fetcher).toHaveBeenCalledTimes(1);
});
+
+// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
+vi.mock("../workflows/compatibility", async (original) => ({
+ ...(await original()),
+ discoverWorkflowLifecycle: async () => undefined,
+}));
diff --git a/src/features/relay/signed-priority.test.ts b/src/features/relay/signed-priority.test.ts
index 0c75e2e8..3f0cfdda 100644
--- a/src/features/relay/signed-priority.test.ts
+++ b/src/features/relay/signed-priority.test.ts
@@ -37,3 +37,9 @@ it("production reader priority reaches actual signed fetch admission", async ()
r.dispose();
}
});
+
+// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
+vi.mock("../workflows/compatibility", async (original) => ({
+ ...(await original()),
+ discoverWorkflowLifecycle: async () => undefined,
+}));
diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts
index d4e54f3e..d1b1571e 100644
--- a/src/features/relay/transport.test.ts
+++ b/src/features/relay/transport.test.ts
@@ -184,3 +184,9 @@ it("uses each signed transport's own origin for protected media, never a deploym
);
expect(a.media("http://images.example/insecure.png")).toBeUndefined();
});
+
+// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
+vi.mock("../workflows/compatibility", async (original) => ({
+ ...(await original()),
+ discoverWorkflowLifecycle: async () => undefined,
+}));
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index 5868ef96..a6f89fa9 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -1,3 +1,11 @@
+import {
+ isWorkflowOperation,
+ validateWorkflowEvent,
+} from "../workflows/protocol";
+import {
+ discoverWorkflowLifecycle,
+ workflowLifecycleVersion,
+} from "../workflows/compatibility";
import { workflowHost, workflowReadPath } from "../workflows/http";
import type { WorkflowHost } from "../workflows/host";
import { readReceiptText } from "./receipt";
@@ -150,6 +158,7 @@ export async function connectBrokerTransport(
archiveAuthority?: unknown;
writeKinds?: number[];
workflowReads?: boolean;
+ workflowInfo?: unknown;
relayUrl?: string;
live?: boolean;
sidebarPreferences?: boolean;
@@ -185,14 +194,20 @@ export async function connectBrokerTransport(
: {}),
...(session.workflowReads === true
? {
- workflows: workflowHost((route, body, signal) =>
- fetch(`${endpoint}/${route}`, {
- method: "POST",
- credentials: "same-origin",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- signal,
- }),
+ workflows: workflowHost(
+ (route, body, signal) =>
+ fetch(`${endpoint}/${route}`, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal,
+ }),
+ workflowLifecycleVersion(
+ session.workflowInfo,
+ session.relayUrl ?? "",
+ session.relayAuthor,
+ ),
),
}
: {}),
@@ -388,8 +403,27 @@ export async function connectSignedTransport(
): Promise {
const viewer = await signer.getPublicKey();
httpOrigin = relayOrigin(httpOrigin);
+ const lifecycleVersion = await discoverWorkflowLifecycle(
+ httpOrigin,
+ relayAuthor,
+ );
const principal = () => signedAdmissions(httpOrigin, viewer);
const profiling = createRelayProfiler();
+ async function checkWorkflow(event: EventTemplate, signal: AbortSignal) {
+ signal.throwIfAborted();
+ if (!isWorkflowOperation(event)) return;
+ if (
+ (await discoverWorkflowLifecycle(httpOrigin, relayAuthor, signal)) !== 1
+ )
+ throw new PublishRejected(
+ "Reliable workflow writes are unavailable on this relay",
+ );
+ signal.throwIfAborted();
+ validateWorkflowEvent({ ...event, id: "", pubkey: viewer }, viewer, {
+ delete: true,
+ webhookSecrets: false,
+ });
+ }
return {
profiling,
subscribe: (callbacks) => {
@@ -420,26 +454,36 @@ export async function connectSignedTransport(
},
};
},
- workflows: workflowHost((route, body, signal) =>
- signedRequest(
- signer,
- `${httpOrigin}${workflowReadPath(route, body)}`,
- undefined,
- signal,
- profiling,
- route,
- principal().api,
- "foreground",
- "GET",
- ),
+ workflows: workflowHost(
+ (route, body, signal) =>
+ signedRequest(
+ signer,
+ `${httpOrigin}${workflowReadPath(route, body)}`,
+ undefined,
+ signal,
+ profiling,
+ route,
+ principal().api,
+ "foreground",
+ "GET",
+ ),
+ lifecycleVersion,
),
scope: httpOrigin,
viewer,
relayAuthor,
media: (url) => mediaUrl(url, undefined, httpOrigin),
writer: {
- sign: (event) => signer.signEvent(event),
+ async sign(event, signal) {
+ await checkWorkflow(event, signal);
+ return signer.signEvent(event);
+ },
async publish(event, signal) {
+ await checkWorkflow(event, signal);
+ if (isWorkflowOperation(event) && event.pubkey !== viewer)
+ throw new PublishRejected(
+ "Workflow signer does not match this session",
+ );
return acceptPublish(
await signedRequest(
signer,
diff --git a/src/features/workflows/compatibility.test.ts b/src/features/workflows/compatibility.test.ts
new file mode 100644
index 00000000..8012c0b1
--- /dev/null
+++ b/src/features/workflows/compatibility.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, assert, expect, it, vi } from "vitest";
+import {
+ connectBrokerTransport,
+ connectSignedTransport,
+} from "../relay/transport";
+import { keypair, signed } from "../relay/testing";
+import { workflowLifecycleVersion } from "./compatibility";
+const origin = "https://workflow-compat.test";
+const key = keypair();
+const info = {
+ self: key.pubkey,
+ supported_extensions: ["buzz-workflows"],
+ workflows: { lifecycle: 1, host: "workflow-compat.test" },
+};
+const invalid = [
+ null,
+ {},
+ { ...info, self: undefined, pubkey: key.pubkey },
+ { ...info, self: "b".repeat(64) },
+ { ...info, supported_extensions: [] },
+ { ...info, workflows: undefined },
+ ...[0, 2, "1", true, null].map((lifecycle) => ({
+ ...info,
+ workflows: { ...info.workflows, lifecycle },
+ })),
+ ...[
+ "other.test",
+ "workflow-compat.test:443",
+ "workflow-compat.test:1234",
+ "https://workflow-compat.test",
+ "workflow-compat.test/path",
+ ].map((host) => ({ ...info, workflows: { ...info.workflows, host } })),
+];
+afterEach(() => vi.unstubAllGlobals());
+it("requires explicit version, extension, self and exact normalized host", () => {
+ expect(workflowLifecycleVersion(info, origin, key.pubkey)).toBe(1);
+ expect(
+ workflowLifecycleVersion(info, "wss://WORKFLOW-COMPAT.test./", key.pubkey),
+ ).toBe(1);
+ for (const data of invalid)
+ expect(workflowLifecycleVersion(data, origin, key.pubkey)).toBeUndefined();
+});
+it.each([info, ...invalid])(
+ "real broker session projects only verified compatibility %#",
+ async (workflowInfo) => {
+ vi.stubGlobal("fetch", async () =>
+ Response.json({
+ viewer: key.pubkey,
+ relayAuthor: key.pubkey,
+ relayUrl: origin,
+ workflowReads: true,
+ workflowInfo,
+ writeKinds: [9, 30620, 46020, 5],
+ }),
+ );
+ const transport = await connectBrokerTransport();
+ expect(transport.workflows?.lifecycleVersion).toBe(
+ workflowInfo === info ? 1 : undefined,
+ );
+ },
+);
+it.each([info, ...invalid])(
+ "signed host discovers against its own origin without signing or forwarding identity %#",
+ async (metadata) => {
+ const fetcher = vi.fn(async () => Response.json(metadata));
+ const signEvent = vi.fn(async (t: Parameters[1]) =>
+ signed(key, t),
+ );
+ vi.stubGlobal("fetch", fetcher);
+ const transport = await connectSignedTransport(
+ { getPublicKey: async () => key.pubkey, signEvent },
+ origin,
+ key.pubkey,
+ );
+ expect(transport.workflows?.lifecycleVersion).toBe(
+ metadata === info ? 1 : undefined,
+ );
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(fetcher.mock.calls[0]).toEqual([
+ origin,
+ {
+ headers: { Accept: "application/nostr+json" },
+ redirect: "error",
+ credentials: "omit",
+ signal: expect.any(AbortSignal),
+ },
+ ]);
+ expect(signEvent).not.toHaveBeenCalled();
+ },
+);
+it("metadata failure, malformed JSON and reconnect downgrade keep signed host reads available, writes off", async () => {
+ for (const result of [
+ new Response("bad json"),
+ new Response("", { status: 503 }),
+ new Error("offline"),
+ ]) {
+ vi.stubGlobal("fetch", async () => {
+ if (result instanceof Error) throw result;
+ return result;
+ });
+ const transport = await connectSignedTransport(
+ {
+ getPublicKey: async () => key.pubkey,
+ signEvent: async (t) => signed(key, t),
+ },
+ origin,
+ key.pubkey,
+ );
+ expect(transport.workflows?.lifecycleVersion).toBeUndefined();
+ expect(transport.workflows?.runs).toBeTypeOf("function");
+ }
+});
+it("direct signer checks real metadata again at both signing and publishing, preserving exact event and receipt", async () => {
+ let compatible = true;
+ const calls: string[] = [];
+ const signEvent = vi.fn(async (t: Parameters[1]) =>
+ signed(key, t),
+ );
+ const template = {
+ kind: 46020,
+ created_at: 1,
+ content: "",
+ tags: [
+ ["h", "11111111-1111-4111-8111-111111111111"],
+ ["d", "22222222-2222-4222-8222-222222222222"],
+ ],
+ };
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
+ if (url === origin)
+ return Response.json(compatible ? info : { self: key.pubkey });
+ calls.push(url);
+ expect(init?.body).toBe(JSON.stringify(event));
+ return Response.json({
+ accepted: true,
+ event_id: event.id,
+ message: "run-result",
+ });
+ });
+ const t = await connectSignedTransport(
+ { getPublicKey: async () => key.pubkey, signEvent },
+ origin,
+ key.pubkey,
+ );
+ assert.exists(t.writer);
+ const event = await t.writer.sign(template, new AbortController().signal);
+ expect(event.kind).toBe(46020);
+ expect(await t.writer.publish(event, new AbortController().signal)).toBe(
+ "run-result",
+ );
+ expect(calls).toEqual([`${origin}/events`]);
+ compatible = false;
+ signEvent.mockClear();
+ await expect(
+ t.writer.sign(template, new AbortController().signal),
+ ).rejects.toThrow("unavailable");
+ await expect(
+ t.writer.publish(event, new AbortController().signal),
+ ).rejects.toThrow("unavailable");
+ expect(signEvent).not.toHaveBeenCalled();
+ expect(calls).toHaveLength(1);
+});
+it("positive metadata alone cannot authorize invalid direct workflow commands", async () => {
+ const signEvent = vi.fn(async (t: Parameters[1]) =>
+ signed(key, t),
+ );
+ const fetcher = vi.fn(async () => Response.json(info));
+ vi.stubGlobal("fetch", fetcher);
+ const t = await connectSignedTransport(
+ { getPublicKey: async () => key.pubkey, signEvent },
+ origin,
+ key.pubkey,
+ );
+ assert.exists(t.writer);
+ await expect(
+ t.writer.sign(
+ { kind: 30620, created_at: 1, content: "invalid", tags: [] },
+ new AbortController().signal,
+ ),
+ ).rejects.toThrow();
+ expect(signEvent).not.toHaveBeenCalled();
+ expect(fetcher).toHaveBeenCalledTimes(2);
+});
diff --git a/src/features/workflows/compatibility.ts b/src/features/workflows/compatibility.ts
new file mode 100644
index 00000000..c2d361a2
--- /dev/null
+++ b/src/features/workflows/compatibility.ts
@@ -0,0 +1,56 @@
+import { relayOrigin } from "../communities/destination.ts";
+import { record } from "./protocol.ts";
+import { workflowReadText } from "./http.ts";
+
+/** HTTPS-origin evidence only; contact keys, kind lists and software versions never qualify. */
+export function workflowLifecycleVersion(
+ info: unknown,
+ origin: string,
+ expectedAuthor?: string,
+): 1 | undefined {
+ if (
+ !record(info) ||
+ typeof info.self !== "string" ||
+ !/^[0-9a-f]{64}$/.test(info.self) ||
+ (expectedAuthor !== undefined && info.self !== expectedAuthor) ||
+ !Array.isArray(info.supported_extensions) ||
+ !info.supported_extensions.includes("buzz-workflows") ||
+ !record(info.workflows) ||
+ info.workflows.lifecycle !== 1
+ )
+ return undefined;
+ try {
+ return info.workflows.host === new URL(relayOrigin(origin)).host
+ ? 1
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** One bounded, unsigned NIP-11 GET to the captured origin; no redirects or credential forwarding. */
+export async function discoverWorkflowLifecycle(
+ origin: string,
+ author: string,
+ signal?: AbortSignal,
+): Promise<1 | undefined> {
+ try {
+ const response = await fetch(origin, {
+ headers: { Accept: "application/nostr+json" },
+ redirect: "error",
+ credentials: "omit",
+ signal: signal
+ ? AbortSignal.any([signal, AbortSignal.timeout(10000)])
+ : AbortSignal.timeout(10000),
+ });
+ if (!response.ok) return undefined;
+ return workflowLifecycleVersion(
+ JSON.parse(await workflowReadText(response)),
+ origin,
+ author,
+ );
+ } catch {
+ // Metadata failure cannot grant writes or prevent ordinary history access.
+ return undefined;
+ }
+}
diff --git a/src/features/workflows/http.test.ts b/src/features/workflows/http.test.ts
index 4cfb4e10..ddcfdcba 100644
--- a/src/features/workflows/http.test.ts
+++ b/src/features/workflows/http.test.ts
@@ -92,3 +92,9 @@ it("direct workflow cancellation/invalid arguments never sign or dispatch", asyn
expect(signEvent).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
+
+// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
+vi.mock("./compatibility", async (original) => ({
+ ...(await original()),
+ discoverWorkflowLifecycle: async () => undefined,
+}));
diff --git a/src/features/workflows/http.ts b/src/features/workflows/http.ts
index 0cc46320..a5adcdfa 100644
--- a/src/features/workflows/http.ts
+++ b/src/features/workflows/http.ts
@@ -63,6 +63,7 @@ export function workflowHost(
body: unknown,
signal: AbortSignal,
) => Promise,
+ lifecycleVersion?: 1,
): WorkflowHost {
async function read(route: string, body: unknown, signal: AbortSignal) {
workflowReadPath(route, body);
@@ -89,6 +90,7 @@ export function workflowHost(
}
}
return Object.freeze({
+ ...(lifecycleVersion === 1 ? { lifecycleVersion } : {}),
runs: (id, cursor, signal) =>
read("workflow-runs", { id, ...(cursor ? { cursor } : {}) }, signal),
approvals: (id, runId, signal) =>
From 7f9eea65aefc5206c19a7169ceb48171d90807d6 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 10:40:57 -0600
Subject: [PATCH 11/20] fix(workflows): fence broker lifetime and captured
authority
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
dev/relay-broker.mjs | 256 ++++++++--------
dev/workflow-broker.test.mjs | 290 ++++++++++++++++---
docs/workflows.md | 16 +
src/features/relay/transport.ts | 20 +-
src/features/workflows/compatibility.test.ts | 90 ++++++
src/features/workflows/protocol.ts | 4 +-
6 files changed, 509 insertions(+), 167 deletions(-)
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index 7f6b3671..29a050bb 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -173,11 +173,14 @@ function loadIdentity(authorizedViewer) {
}
return decoded.data;
}
-async function relayAuthority(fetch, relay) {
+async function relayAuthority(fetch, relay, signal) {
const response = await fetch(relay, {
headers: { Accept: "application/nostr+json" },
redirect: "error",
- signal: AbortSignal.timeout(10000),
+ signal: AbortSignal.any([
+ ...(signal ? [signal] : []),
+ AbortSignal.timeout(10000),
+ ]),
});
if (!response.ok) throw new Error("Relay identity discovery failed");
const nip11 = JSON.parse(await workflowReadText(response));
@@ -362,6 +365,12 @@ export function relayBrokerPlugin({
)
return json(res, 403, { error: "Origin rejected" });
const url = new URL(req.url, origin);
+ // Own the request before any awaited discovery/body read. A close that
+ // already happened cannot be recovered by a listener at dispatch time.
+ const cancel = new AbortController();
+ const release = () => cancel.abort();
+ res.once("close", release);
+ if (res.destroyed) release();
try {
if (url.pathname === "/api/relay/register" && req.method === "POST") {
let raw = "";
@@ -862,12 +871,24 @@ export function relayBrokerPlugin({
if (signing || publishing) {
if (filters?.kind !== 9) {
try {
- const discovered = await authority(fetchUpstream, relay);
+ // This is a connection pin, not an authorization token. Each
+ // caller retains its own identity across other tabs/reconnects.
+ const expected = req.headers["x-buzz-workflow-authority"];
+ if (
+ typeof expected !== "string" ||
+ !/^[0-9a-f]{64}$/.test(expected)
+ )
+ throw new Error("Workflow connection authority missing");
+ const discovered = await authority(
+ fetchUpstream,
+ relay,
+ cancel.signal,
+ );
if (
workflowLifecycleVersion(
discovered.workflowInfo,
relay,
- discovered.relayAuthor,
+ expected,
) !== 1
)
throw new Error("Workflow lifecycle unsupported");
@@ -877,6 +898,7 @@ export function relayBrokerPlugin({
{ delete: true, webhookSecrets: false },
);
} catch {
+ cancel.signal.throwIfAborted();
return json(res, 400, {
error: "Workflow operation unavailable or invalid",
sent: false,
@@ -884,6 +906,9 @@ export function relayBrokerPlugin({
}
} else if (!validMessageTemplate(filters))
return json(res, 400, { error: "Message rejected" });
+ // A fixture/adapter may finish discovery despite abort. Never turn
+ // that late result into a signature or a newly admitted publication.
+ cancel.signal.throwIfAborted();
if (signing) {
const started = performance.now();
const event = finalizeEvent(
@@ -938,128 +963,119 @@ export function relayBrokerPlugin({
try {
const lane = admissions(relay, viewer).api;
const body = workflowPath ? undefined : JSON.stringify(filters);
- // A browser that gave up (the client's ten-second deadline) must also release
- // this upstream request, or hung requests exhaust the inflight budget.
- const cancel = new AbortController();
- const release = () => cancel.abort();
- res.once("close", release);
const admissionStart = performance.now();
let connectsBefore, upstreamStart;
let response;
- try {
- const requestSignal = AbortSignal.any([
- cancel.signal,
- AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
- ]);
- response = await admittedApiRequest(
- lane,
- () => {
- // Auth freshness and network timings begin at dispatch, not queue entry.
- requestSignal.throwIfAborted();
- timings.push(
- `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`,
- );
- const authStart = performance.now();
- const auth = finalizeEvent(
- {
- kind: 27235,
- created_at: Math.floor(Date.now() / 1000),
- content: "",
- tags: [
- ["u", `${relay}${upstreamPath}`],
- ["method", method],
- ...(body === undefined
- ? []
- : [
- [
- "payload",
- createHash("sha256").update(body).digest("hex"),
- ],
- ]),
- ["nonce", randomBytes(16).toString("hex")],
- ],
- },
- key,
- );
+ const requestSignal = AbortSignal.any([
+ cancel.signal,
+ AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
+ ]);
+ response = await admittedApiRequest(
+ lane,
+ () => {
+ // Auth freshness and network timings begin at dispatch, not queue entry.
+ requestSignal.throwIfAborted();
+ timings.push(
+ `admission;dur=${(performance.now() - admissionStart).toFixed(2)}`,
+ );
+ const authStart = performance.now();
+ const auth = finalizeEvent(
+ {
+ kind: 27235,
+ created_at: Math.floor(Date.now() / 1000),
+ content: "",
+ tags: [
+ ["u", `${relay}${upstreamPath}`],
+ ["method", method],
+ ...(body === undefined
+ ? []
+ : [
+ [
+ "payload",
+ createHash("sha256").update(body).digest("hex"),
+ ],
+ ]),
+ ["nonce", randomBytes(16).toString("hex")],
+ ],
+ },
+ key,
+ );
+ timings.push(
+ `auth;dur=${(performance.now() - authStart).toFixed(2)}`,
+ );
+ connectsBefore = upstream.connects();
+ upstreamStart = performance.now();
+ return fetchUpstream(`${relay}${upstreamPath}`, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization:
+ "Nostr " +
+ Buffer.from(JSON.stringify(auth)).toString("base64"),
+ },
+ body,
+ redirect: "error",
+ signal: requestSignal,
+ }).then((response) => {
timings.push(
- `auth;dur=${(performance.now() - authStart).toFixed(2)}`,
+ `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
);
- connectsBefore = upstream.connects();
- upstreamStart = performance.now();
- return fetchUpstream(`${relay}${upstreamPath}`, {
- method,
- headers: {
- "Content-Type": "application/json",
- Authorization:
- "Nostr " +
- Buffer.from(JSON.stringify(auth)).toString("base64"),
- },
- body,
- redirect: "error",
- signal: requestSignal,
- }).then((response) => {
- timings.push(
- `ttfb;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
- );
- return response;
- });
- },
- requestSignal,
- route === "/api/relay/query" &&
- req.headers["x-buzz-read-priority"] === "background"
- ? "background"
- : "foreground",
- );
- const text =
- snapshot && response.ok
- ? await readSnapshotText(response)
- : workflowPath && response.ok
- ? await workflowReadText(response)
- : publishing && response.ok
- ? await readReceiptText(response)
- : await response.text();
- // The relay's own service time separates server work from network time.
- const relayMs = Number(
- response.headers.get("x-envoy-upstream-service-time"),
- );
- timings.push(
- ...upstream.connectTiming(connectsBefore),
- ...(Number.isFinite(relayMs) &&
- response.headers.has("x-envoy-upstream-service-time")
- ? [`relay;dur=${relayMs}`]
- : []),
- `upstream;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
- );
- res.setHeader("Server-Timing", timings.join(", "));
- stats.queries++;
- if (!response.ok) {
- stats.errors++;
- let failure;
- try {
- failure = apiFailure(response.status, JSON.parse(text));
- } catch {
- failure = apiFailure(response.status, undefined);
- }
- return json(res, response.status, failure);
- }
- if (profile) {
- const receipt = JSON.parse(text);
- if (
- receipt.event_id !== filters.id ||
- typeof receipt.accepted !== "boolean"
- )
- return json(res, 502, {
- error: "Profile publication could not be confirmed",
- });
+ return response;
+ });
+ },
+ requestSignal,
+ route === "/api/relay/query" &&
+ req.headers["x-buzz-read-priority"] === "background"
+ ? "background"
+ : "foreground",
+ );
+ const text =
+ snapshot && response.ok
+ ? await readSnapshotText(response)
+ : workflowPath && response.ok
+ ? await workflowReadText(response)
+ : publishing && response.ok
+ ? await readReceiptText(response)
+ : await response.text();
+ // The relay's own service time separates server work from network time.
+ const relayMs = Number(
+ response.headers.get("x-envoy-upstream-service-time"),
+ );
+ timings.push(
+ ...upstream.connectTiming(connectsBefore),
+ ...(Number.isFinite(relayMs) &&
+ response.headers.has("x-envoy-upstream-service-time")
+ ? [`relay;dur=${relayMs}`]
+ : []),
+ `upstream;dur=${(performance.now() - upstreamStart).toFixed(2)}`,
+ );
+ res.setHeader("Server-Timing", timings.join(", "));
+ stats.queries++;
+ if (!response.ok) {
+ stats.errors++;
+ let failure;
+ try {
+ failure = apiFailure(response.status, JSON.parse(text));
+ } catch {
+ failure = apiFailure(response.status, undefined);
}
- res.writeHead(200, {
- "Content-Type": "application/json",
- "Cache-Control": "no-store",
- });
- res.end(text);
- } finally {
- res.off("close", release);
+ return json(res, response.status, failure);
+ }
+ if (profile) {
+ const receipt = JSON.parse(text);
+ if (
+ receipt.event_id !== filters.id ||
+ typeof receipt.accepted !== "boolean"
+ )
+ return json(res, 502, {
+ error: "Profile publication could not be confirmed",
+ });
}
+ res.writeHead(200, {
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store",
+ });
+ res.end(text);
} finally {
inflight--;
}
@@ -1087,6 +1103,8 @@ export function relayBrokerPlugin({
if (isConnectFailure(error))
return json(res, 502, { error: "Relay unreachable", sent: false });
json(res, 500, { error: "Local relay broker failed" });
+ } finally {
+ res.off("close", release);
}
});
},
diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs
index 51264605..b41d3877 100644
--- a/dev/workflow-broker.test.mjs
+++ b/dev/workflow-broker.test.mjs
@@ -1,11 +1,16 @@
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
-import { expect, it } from "vitest";
-import { getPublicKey, verifyEvent } from "nostr-tools";
+import { expect, it, vi } from "vitest";
+import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools";
import { relayBrokerPlugin } from "./relay-broker.mjs";
import { connectBrokerTransport } from "../src/features/relay/transport.ts";
import { WORKFLOW_READ_BYTES } from "../src/features/workflows/http.ts";
+vi.mock("nostr-tools", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, finalizeEvent: vi.fn(actual.finalizeEvent) };
+});
+
const id = "11111111-1111-4111-8111-111111111111";
const runId = "22222222-2222-4222-8222-222222222222";
const cursor = { before: "2026-09-12T14:44:19.123456+00:00", beforeId: runId };
@@ -17,11 +22,14 @@ async function harness(
key[31] = 8;
const viewer = getPublicKey(key),
calls = [],
- logs = [];
+ logs = [],
+ closed = [],
+ completed = [];
let handler;
const server = createServer((req, res) => {
if (!req.headers.origin) req.headers.origin = `http://${req.headers.host}`;
- handler(req, res);
+ res.once("close", () => closed.push(req.url));
+ void handler(req, res).finally(() => completed.push(req.url));
});
await relayBrokerPlugin({
relayUrl: "https://a.workflow.test",
@@ -31,7 +39,7 @@ async function harness(
upstreamFetch: async (url, init) => {
if (init.headers.Accept === "application/nostr+json") {
expect(init.redirect).toBe("error");
- const data = metadata(String(url), viewer);
+ const data = await metadata(String(url), viewer, init);
if (data instanceof Error) throw data;
return data instanceof Response ? data : Response.json(data);
}
@@ -67,6 +75,8 @@ async function harness(
viewer,
calls,
logs,
+ closed,
+ completed,
post: (route, body, headers = {}) =>
fetch(`${base}/api/relay/${route}`, {
method: "POST",
@@ -317,53 +327,113 @@ it("broker checks fresh own-host evidence at sign and publish; old, other-host a
await h.close();
}
});
-it("compatible broker refuses malformed, alternate-delete, webhook and forged commands before upstream writes", async () => {
+const invalidCommands = [
+ ["null", () => null],
+ ["admin deletion", () => ({ ...template(), kind: 9005 })],
+ [
+ "legacy name",
+ () => ({
+ ...template(),
+ tags: [
+ ["h", runId],
+ ["d", "name"],
+ ],
+ }),
+ ],
+ [
+ "webhook",
+ () => ({
+ ...template(),
+ content: yaml.replace("message_posted", "webhook"),
+ }),
+ ],
+ [
+ "event-target deletion",
+ () => ({
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["e", "a".repeat(64)],
+ ],
+ }),
+ ],
+ [
+ "numeric alias",
+ (viewer) => ({
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["a", `030620:${viewer}:${id}`],
+ ],
+ }),
+ ],
+ [
+ "other owner",
+ () => ({
+ ...template(5),
+ tags: [
+ ["h", runId],
+ ["a", `30620:${"a".repeat(64)}:${id}`],
+ ],
+ }),
+ ],
+ [
+ "extra tag",
+ () => ({
+ ...template(),
+ tags: [...template().tags, ["p", "a".repeat(64)]],
+ }),
+ ],
+];
+it.each(invalidCommands)(
+ "compatible broker rejects %s before signing or upstream writes",
+ async (_name, input) => {
+ const h = await harness(() => {
+ throw new Error("must not dispatch writes");
+ }, compatible);
+ try {
+ const signaturesBefore = finalizeEvent.mock.calls.length;
+ expect(
+ (
+ await h.post("sign", input(h.viewer), {
+ "X-Buzz-Workflow-Authority": h.viewer,
+ })
+ ).status,
+ ).toBe(400);
+ expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+ },
+);
+it("compatible broker rejects forged commands before upstream writes", async () => {
const h = await harness(() => {
throw new Error("must not dispatch writes");
}, compatible);
try {
- for (const input of [
- null,
- { ...template(), kind: 9005 },
- {
- ...template(),
- tags: [
- ["h", runId],
- ["d", "name"],
- ],
- },
- { ...template(), content: yaml.replace("message_posted", "webhook") },
- {
- ...template(5),
- tags: [
- ["h", runId],
- ["e", "a".repeat(64)],
- ],
- },
- {
- ...template(5),
- tags: [
- ["h", runId],
- ["a", `030620:${h.viewer}:${id}`],
- ],
- },
- {
- ...template(5),
- tags: [
- ["h", runId],
- ["a", `30620:${"a".repeat(64)}:${id}`],
- ],
- },
- { ...template(), tags: [...template().tags, ["p", "a".repeat(64)]] },
- ]) {
- expect((await h.post("sign", input)).status).toBe(400);
- }
- const own = await (await h.post("sign", template())).json();
+ const own = await (
+ await h.post("sign", template(), {
+ "X-Buzz-Workflow-Authority": h.viewer,
+ })
+ ).json();
expect(
- (await h.post("publish", { ...own, content: "tampered" })).status,
+ (
+ await h.post(
+ "publish",
+ { ...own, content: "tampered" },
+ { "X-Buzz-Workflow-Authority": h.viewer },
+ )
+ ).status,
).toBe(400);
expect(
- (await h.post("publish", { ...own, pubkey: "a".repeat(64) })).status,
+ (
+ await h.post(
+ "publish",
+ { ...own, pubkey: "a".repeat(64) },
+ { "X-Buzz-Workflow-Authority": h.viewer },
+ )
+ ).status,
).toBe(400);
expect(h.calls).toHaveLength(0);
} finally {
@@ -392,3 +462,133 @@ it("real NIP-11 discovery does not accept fallback identity, malformed or wrong-
}
}
});
+
+const gate = () => {
+ let resolve;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+};
+it.each(["sign", "publish"])(
+ "disconnect during fresh discovery cancels %s before any late signature or upstream write",
+ async (operation) => {
+ const entered = gate(),
+ release = gate();
+ let hold = false,
+ metadataSignal;
+ const h = await harness(
+ ({ init }) =>
+ Response.json({
+ accepted: true,
+ event_id: JSON.parse(init.body).id,
+ message: "workflow-result",
+ }),
+ async (url, viewer, init) => {
+ if (hold) {
+ metadataSignal = init.signal;
+ entered.resolve();
+ // Deliberately ignore abort while held: the caller must also fence a late result.
+ await release.promise;
+ }
+ return compatible(url, viewer);
+ },
+ );
+ try {
+ const t = await connectBrokerTransport(h.base);
+ const input =
+ operation === "sign"
+ ? template()
+ : await t.writer.sign(template(), signal());
+ const route = `/api/relay/${operation}`;
+ const completedBefore = h.completed.filter(
+ (value) => value === route,
+ ).length;
+ const signaturesBefore = finalizeEvent.mock.calls.length;
+ const cancel = new AbortController();
+ hold = true;
+ const pending = t.writer[operation](input, cancel.signal);
+ const rejected = expect(pending).rejects.toThrow();
+ await Promise.race([entered.promise, pending]);
+ cancel.abort();
+ await rejected;
+ await vi.waitFor(() => expect(h.closed).toContain(route));
+ const discoveryAborted = metadataSignal.aborted;
+ release.resolve();
+ await vi.waitFor(() =>
+ expect(h.completed.filter((value) => value === route)).toHaveLength(
+ completedBefore + 1,
+ ),
+ );
+ expect(discoveryAborted).toBe(true);
+ expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
+ expect(h.calls).toHaveLength(0);
+ expect(h.logs).toHaveLength(0);
+ hold = false;
+ const event = await t.writer.sign(template(), signal());
+ expect(await t.writer.publish(event, signal())).toBe("workflow-result");
+ expect(h.calls).toHaveLength(1);
+ } finally {
+ release.resolve();
+ await h.close();
+ }
+ },
+);
+it("workflow requests pin each connection authority; reconnecting B never rebinds an open A session", async () => {
+ let rotated = false;
+ const h = await harness(
+ ({ init }) =>
+ Response.json({
+ accepted: true,
+ event_id: JSON.parse(init.body).id,
+ message: "workflow-result",
+ }),
+ (url, viewer) => compatible(url, rotated ? "a".repeat(64) : viewer),
+ );
+ try {
+ const a = await connectBrokerTransport(h.base);
+ const eventA = await a.writer.sign(template(), signal());
+ rotated = true;
+ const signaturesBefore = finalizeEvent.mock.calls.length;
+ await expect(a.writer.sign(template(), signal())).rejects.toThrow();
+ await expect(a.writer.publish(eventA, signal())).rejects.toThrow();
+ expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
+ expect(h.calls).toHaveLength(0);
+ const b = await connectBrokerTransport(h.base);
+ expect(b.relayAuthor).toBe("a".repeat(64));
+ const eventB = await b.writer.sign(template(), signal());
+ expect(await b.writer.publish(eventB, signal())).toBe("workflow-result");
+ await expect(a.writer.sign(template(), signal())).rejects.toThrow();
+ await expect(a.writer.publish(eventA, signal())).rejects.toThrow();
+ expect(h.calls).toHaveLength(1);
+ } finally {
+ await h.close();
+ }
+});
+it.each([undefined, "", "not-a-key", "a".repeat(64)])(
+ "broker rejects absent, malformed or mismatching request authority: %s",
+ async (authority) => {
+ const h = await harness(undefined, compatible);
+ try {
+ const t = await connectBrokerTransport(h.base);
+ const event = await t.writer.sign(template(), signal());
+ const signaturesBefore = finalizeEvent.mock.calls.length;
+ const headers =
+ authority === undefined
+ ? {}
+ : { "X-Buzz-Workflow-Authority": authority };
+ for (const [route, body] of [
+ ["sign", template()],
+ ["publish", event],
+ ]) {
+ const response = await h.post(route, body, headers);
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({ sent: false });
+ }
+ expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
+ expect(h.calls).toHaveLength(0);
+ } finally {
+ await h.close();
+ }
+ },
+);
diff --git a/docs/workflows.md b/docs/workflows.md
index 31e16a83..7d81f9f4 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -149,3 +149,19 @@ The application’s real browser/native dev shell currently uses the broker;
that native Keychain production wiring has been exercised. The new handshake
still requires independent review and an approved disposable live test. No
production deployment or real create/run validation is implied by this checkpoint.
+
+## Host review follow-up (2026-09-12)
+
+Workflow broker requests carry the connection's captured relay authority in
+`X-Buzz-Workflow-Authority`. The broker requires a lowercase public key and checks
+fresh host-scoped metadata against that value at both sign and publish. The pin is
+not an authorization token and is never forwarded upstream; a new B connection
+cannot rebind an older A connection. Identity changes require reconnecting.
+
+The broker owns response-close cancellation before reading the operation body or
+awaiting metadata, propagates it to discovery/admission, and checks it before
+signing and dispatch. A late metadata result cannot create a new signature or
+publication after cancellation. Already-dispatched writes retain unknown-outcome
+semantics. Explicit numeric workflow coordinates (including leading zeros and
+`+`) enter workflow validation, which rejects every noncanonical coordinate;
+unrelated kind-5 event/coordinate deletes are unchanged in the signed adapter.
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index a6f89fa9..c0b8e904 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -330,7 +330,15 @@ export async function connectBrokerTransport(
const result = await fetch(`${endpoint}/sign`, {
method: "POST",
credentials: "same-origin",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ ...(template.kind !== 9
+ ? {
+ "X-Buzz-Workflow-Authority":
+ session.relayAuthor as string,
+ }
+ : {}),
+ },
body: JSON.stringify(template),
signal,
});
@@ -344,7 +352,15 @@ export async function connectBrokerTransport(
const result = await fetch(`${endpoint}/publish`, {
method: "POST",
credentials: "same-origin",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ ...(event.kind !== 9
+ ? {
+ "X-Buzz-Workflow-Authority":
+ session.relayAuthor as string,
+ }
+ : {}),
+ },
body: JSON.stringify(event),
signal,
});
diff --git a/src/features/workflows/compatibility.test.ts b/src/features/workflows/compatibility.test.ts
index 8012c0b1..f8a459fb 100644
--- a/src/features/workflows/compatibility.test.ts
+++ b/src/features/workflows/compatibility.test.ts
@@ -180,3 +180,93 @@ it("positive metadata alone cannot authorize invalid direct workflow commands",
expect(signEvent).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledTimes(2);
});
+
+it.each(
+ [false, true].flatMap((compatible) =>
+ ["030620", "+30620", "+00030620"].flatMap((alias) =>
+ ["sign", "publish"].map((operation) => ({
+ compatible,
+ alias,
+ operation,
+ })),
+ ),
+ ),
+)(
+ "signed host rejects $alias at $operation with compatibility=$compatible",
+ async ({ compatible, alias, operation }) => {
+ const signEvent = vi.fn(async (t: Parameters[1]) =>
+ signed(key, t),
+ );
+ const calls: string[] = [];
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
+ if (url === origin)
+ return Response.json(compatible ? info : { self: key.pubkey });
+ calls.push(url);
+ const event = JSON.parse(String(init?.body));
+ return Response.json({
+ accepted: true,
+ event_id: event.id,
+ message: "accepted",
+ });
+ });
+ const t = await connectSignedTransport(
+ { getPublicKey: async () => key.pubkey, signEvent },
+ origin,
+ key.pubkey,
+ );
+ assert.exists(t.writer);
+ const input = {
+ kind: 5,
+ created_at: 1,
+ content: "",
+ tags: [
+ ["h", "11111111-1111-4111-8111-111111111111"],
+ ["a", `${alias}:${key.pubkey}:22222222-2222-4222-8222-222222222222`],
+ ],
+ };
+ await expect(
+ operation === "sign"
+ ? t.writer.sign(input, new AbortController().signal)
+ : t.writer.publish(signed(key, input), new AbortController().signal),
+ ).rejects.toThrow();
+ expect(signEvent).not.toHaveBeenCalled();
+ expect(calls).toHaveLength(0);
+ },
+);
+it.each([false, true])(
+ "unrelated deletes retain signed host behavior with compatibility=%s",
+ async (compatible) => {
+ const calls: string[] = [];
+ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
+ if (url === origin)
+ return Response.json(compatible ? info : { self: key.pubkey });
+ calls.push(url);
+ const event = JSON.parse(String(init?.body));
+ return Response.json({
+ accepted: true,
+ event_id: event.id,
+ message: "accepted",
+ });
+ });
+ const t = await connectSignedTransport(
+ {
+ getPublicKey: async () => key.pubkey,
+ signEvent: async (t) => signed(key, t),
+ },
+ origin,
+ key.pubkey,
+ );
+ assert.exists(t.writer);
+ for (const target of [
+ ["e", "b".repeat(64)],
+ ["a", `030000:${key.pubkey}:other`],
+ ]) {
+ const event = await t.writer.sign(
+ { kind: 5, created_at: 1, content: "", tags: [target] },
+ new AbortController().signal,
+ );
+ await t.writer.publish(event, new AbortController().signal);
+ }
+ expect(calls).toEqual([`${origin}/events`, `${origin}/events`]);
+ },
+);
diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts
index 4154dea3..417f01a9 100644
--- a/src/features/workflows/protocol.ts
+++ b/src/features/workflows/protocol.ts
@@ -17,7 +17,9 @@ export function isWorkflowOperation(
event.kind === 46020 ||
(event.kind === 5 &&
event.tags.some(
- ([name, value]) => name === "a" && value?.startsWith("30620:"),
+ // The relay parses the kind numerically (including + and leading zeros).
+ // Classify those coordinates here; workflowReference rejects aliases.
+ ([name, value]) => name === "a" && /^\+?0*30620:/.test(value ?? ""),
))
);
}
From 900f43bf6af768715ea6e9140c82cd36f2c29879 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 08:17:59 -0600
Subject: [PATCH 12/20] Fix shared Switch keyboard and disabled semantics
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src/bundled/workflows/workflows.journey.mjs | 110 ++++++++++++++++++++
src/shared/design-system/ui/Switch.tsx | 1 -
tests/fixtures/design-system/viewer.spec.ts | 58 +++++++++++
3 files changed, 168 insertions(+), 1 deletion(-)
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index abb6a13e..fc7a51c4 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
import { createServer } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
+import { parse as parseYaml } from "yaml";
let server;
let url;
@@ -151,6 +152,115 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an
expect(errors).toEqual([]);
});
+test("keyboard switches feed enabled-save confirmation and disabled readback", async ({
+ page,
+ browserName,
+}) => {
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ page.on("console", (message) => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ const enabled = page.getByRole("switch", {
+ name: "Enabled in configuration",
+ });
+ const reply = page.getByRole("switch", {
+ name: "Reply in the triggering thread",
+ });
+ const tab =
+ browserName === "webkit" && process.platform === "darwin"
+ ? "Alt+Tab"
+ : "Tab";
+ const focusByTab = async (control) => {
+ for (let attempt = 0; attempt < 40; attempt++) {
+ if (await control.evaluate((node) => node === document.activeElement))
+ return;
+ await page.keyboard.press(tab);
+ }
+ await expect(control).toBeFocused();
+ };
+ const saves = () => page.evaluate(() => window.workflowFixture.calls.save);
+ const savedYaml = async () =>
+ parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml));
+
+ await button("New workflow").click();
+ const name = page.getByLabel("Workflow name", { exact: true });
+ await name.fill("Keyboard workflow");
+ await button("Add Send Message").click();
+ await page.getByLabel("Message text", { exact: true }).fill("Offline only");
+ await name.focus();
+ await page.keyboard.press(tab);
+ await expect(enabled).toBeFocused();
+ await expect(enabled).not.toBeChecked();
+ await page.keyboard.press("Space");
+ await expect(enabled).toBeChecked();
+ await page.keyboard.press("Enter");
+ await expect(enabled).not.toBeChecked();
+ await page.keyboard.press("Space");
+ await expect(enabled).toBeChecked();
+
+ await focusByTab(reply);
+ await page.keyboard.press("Enter");
+ await expect(reply).toBeChecked();
+ await page.keyboard.press("Space");
+ await expect(reply).not.toBeChecked();
+ await page.keyboard.press("Enter");
+ await expect(reply).toBeChecked();
+ await focusByTab(button("Save workflow"));
+ await page.keyboard.press("Enter");
+ const dialog = page.getByRole("alertdialog");
+ await expect(dialog).toContainText("It will run for every new message");
+ await expect(button("Keep editing")).toBeFocused();
+ expect(await saves()).toBe(0);
+ await page.keyboard.press("Escape");
+ await expect(dialog).toHaveCount(0);
+ await expect(button("Save workflow")).toBeFocused();
+ expect(await saves()).toBe(0);
+ await page.keyboard.press("Space");
+ await expect(button("Keep editing")).toBeFocused();
+ await page.keyboard.press(tab);
+ await expect(button("Save enabled workflow")).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect.poll(saves).toBe(1);
+ expect((await savedYaml()).enabled).not.toBe(false);
+ expect((await savedYaml()).steps[0].reply_in_thread).toBe(true);
+ await expect(enabled).toBeDisabled();
+ await expect(reply).toBeDisabled();
+ await enabled.click({ force: true });
+ await enabled.press("Space");
+ await reply.press("Enter");
+ await expect(enabled).toBeChecked();
+ await expect(reply).toBeChecked();
+ expect(await saves()).toBe(1);
+
+ // The offline capability supplies the exact asynchronous receipt/readback.
+ await page.evaluate(() => window.workflowFixture.finish("succeeded"));
+ await expect(button("Save workflow")).toBeEnabled();
+ await expect(enabled).toBeChecked();
+ await expect(reply).toBeChecked();
+ await name.focus();
+ await page.keyboard.press(tab);
+ await expect(enabled).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(enabled).not.toBeChecked();
+ await focusByTab(reply);
+ await page.keyboard.press("Space");
+ await expect(reply).not.toBeChecked();
+ await focusByTab(button("Save workflow"));
+ await page.keyboard.press("Enter");
+ await expect.poll(saves).toBe(2);
+ await expect(dialog).toHaveCount(0);
+ expect((await savedYaml()).enabled).toBe(false);
+ expect((await savedYaml()).steps[0].reply_in_thread).not.toBe(true);
+ await page.evaluate(() => window.workflowFixture.finish("succeeded"));
+ await expect(enabled).toBeEnabled();
+ await expect(enabled).not.toBeChecked();
+ await expect(reply).not.toBeChecked();
+ expect(errors).toEqual([]);
+});
+
test("history reads are lazy, paged by exact cursor and released; unknown operations never get a replacement ID", async ({
page,
}) => {
diff --git a/src/shared/design-system/ui/Switch.tsx b/src/shared/design-system/ui/Switch.tsx
index 8ed98f22..dc76568e 100644
--- a/src/shared/design-system/ui/Switch.tsx
+++ b/src/shared/design-system/ui/Switch.tsx
@@ -16,7 +16,6 @@ export function Switch({
{...props}
aria-label={label}
className="buzz-switch-control"
- nativeButton
>
diff --git a/tests/fixtures/design-system/viewer.spec.ts b/tests/fixtures/design-system/viewer.spec.ts
index eb0aa4e7..acc1d81b 100644
--- a/tests/fixtures/design-system/viewer.spec.ts
+++ b/tests/fixtures/design-system/viewer.spec.ts
@@ -103,6 +103,64 @@ test("narrow, intermediate and wide layouts preserve theme and keyboard interact
);
});
+test("switch keyboard activation matches pointer state and focus in both modes", async ({
+ page,
+ browserName,
+}) => {
+ await page.goto(`${viewer}#/design/components/switch`);
+ const switches = page.getByRole("switch", { name: "Show agent activity" });
+ const control = switches.nth(0);
+ const tab =
+ browserName === "webkit" && process.platform === "darwin"
+ ? "Alt+Tab"
+ : "Tab";
+ for (const width of [390, 800, 1280]) {
+ await page.setViewportSize({ width, height: 900 });
+ for (const mode of ["light", "dark"]) {
+ const toggle = page.getByRole("button", { name: `Use ${mode} mode` });
+ if (await toggle.count()) await toggle.click();
+ await expect(control).not.toBeChecked();
+ await control.click();
+ await expect(control).toBeChecked();
+ await expect(control).toHaveCSS("outline-style", "none");
+ await page.keyboard.press(tab);
+ await expect(switches.nth(1)).toBeFocused();
+ await page.keyboard.press(`Shift+${tab}`);
+ await expect(control).toBeFocused();
+ await expect(control).toHaveCSS("outline-style", "solid");
+ await expect(control).toHaveCSS("outline-width", "2px");
+ await page.keyboard.press("Space");
+ await expect(control).not.toBeChecked();
+ await page.keyboard.press("Enter");
+ await expect(control).toBeChecked();
+ await control.click();
+ await expect(control).not.toBeChecked();
+ }
+ }
+});
+
+test("disabled switch exposes its state, skips Tab and rejects activation", async ({
+ page,
+ browserName,
+}) => {
+ await page.goto(`${viewer}#/design/components/switch`);
+ const switches = page.getByRole("switch", { name: "Show agent activity" });
+ const disabled = switches.nth(2);
+ await expect(disabled).toBeDisabled();
+ await expect(disabled).not.toBeChecked();
+ await switches.nth(1).click();
+ await page.keyboard.press(
+ browserName === "webkit" && process.platform === "darwin"
+ ? "Alt+Tab"
+ : "Tab",
+ );
+ await expect(disabled).not.toBeFocused();
+ await disabled.click({ force: true });
+ await disabled.press("Space");
+ await disabled.press("Enter");
+ await expect(disabled).not.toBeChecked();
+});
+
test("viewer does not replace host styles or appearance ownership", async ({
page,
context,
From b62d61710e150fb104778841319e43f9aebbe29a Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 08:32:37 -0600
Subject: [PATCH 13/20] test(workflows): include workflow page in navigation
expectations
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
tests/browser/layout.spec.mjs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs
index a841a51b..371bd461 100644
--- a/tests/browser/layout.spec.mjs
+++ b/tests/browser/layout.spec.mjs
@@ -517,7 +517,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde
await page.setViewportSize({ width: 1280, height: 832 });
await page.goto(app.origin);
const nav = page.getByRole("navigation", { name: "Pages", exact: true });
- const titles = ["Home", "Messages", "Projects", "Agents"];
+ const titles = ["Home", "Messages", "Projects", "Agents", "Workflows"];
await expect(nav.getByRole("button")).toHaveText(titles);
await nav.getByRole("button", { name: "Projects", exact: true }).click();
const surface = page.getByRole("region", { name: "Projects", exact: true });
@@ -554,6 +554,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde
"Home",
"Messages",
"Agents",
+ "Workflows",
]);
await projects.click();
await expect(nav.getByRole("button")).toHaveText(titles);
@@ -567,6 +568,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde
"Home",
"Projects",
"Agents",
+ "Workflows",
]);
await channels.click();
await expect(nav.getByRole("button")).toHaveText(titles);
@@ -575,6 +577,7 @@ test("Projects stays centered and page navigation survives plugin re-enable orde
"Messages",
"Projects",
"Agents",
+ "Workflows",
"Make it yoursSettings",
]);
await button(page, "Find a page").click();
From 0ec3f4b409c08d3f6530c802b1b7afbe4adfb407 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 09:09:11 -0600
Subject: [PATCH 14/20] docs(workflows): describe implemented host contract and
validation limits
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
docs/workflows.md | 65 +++++++++++++++++++++++++++++------------------
1 file changed, 40 insertions(+), 25 deletions(-)
diff --git a/docs/workflows.md b/docs/workflows.md
index 7d81f9f4..e9a77469 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -1,23 +1,19 @@
# Workflows capability and bundled UI handoff
-Status: implementation contract, not a shipped or live-validated feature.
-Wes approved session FOUNDATION wiring and workflow-only relay save/delete repair
-on 2026-09-12 (Buzz event `768f982eb3295e1bcbc69614d61b38deb3dc608e62864a5c58df9d8453f7fac9`).
+Status: implemented and exercised in isolated browser/broker trials against a
+patched relay; not shipped or packaged-native validated.
-## Ownership and base
+## Ownership
-App baseline: `17f90c18fff6b86bc029e710401fb2b60bc385ea`.
-Brain owns `src/features/workflows/**`, relay transport/outbox/session integration,
-`dev/` host adapters, catalogs, dependencies, this document, and the separate
-legacy relay repair. Pinky owns `src/bundled/workflows/**` and adjacent UI/helper
-tests in a separate worktree. No shared live-tree mutations.
+The bundled page owns presentation and drafts. The shared session owns workflow
+reads, commands and receipt handling through its existing reader and outbox.
+Host adapters own signing, publication and relay compatibility discovery. The
+relay owns authorization, execution and atomic signed-definition/runtime state.
The type contract is [types.ts](../src/features/workflows/types.ts).
UI imports that capability by type and receives the captured session's
-`workflows` property once integration lands; build/test UI compositions against
-explicit fixture capabilities meanwhile. Do not implement an alternate host in
-bundled code. Use the existing `pages` + `relay` injection and
-`useRelayConnection`, not new plugins/author API or a router.
+`workflows` property. It uses the existing `pages` + `relay` injection and
+`useRelayConnection`, with no alternate connection, cache, outbox or router.
## First complete UI slice
@@ -34,7 +30,7 @@ bundled code. Use the existing `pages` + `relay` injection and
writes retain drafts. Show accepted delivery separately from domain success.
Delete requires confirmation and host availability; an old relay's generic
accepted kind-5 receipt does not prove deletion.
-- Manual run and bounded real run/trace next, before advanced editor polish.
+- Manual run and bounded run history/trace detail.
Only returned run ID correlates a run; never choose newest run as recovery.
Approval rows are read-only; their hash is not an approval token.
- Form and YAML share restrictions. Webhook create/transition cannot bypass the
@@ -84,7 +80,7 @@ dirty-close/conflict drafts, narrow layouts and YAML ownership tests. Fixture
feedback can precede final package gates. Live identity/signing/destructive
workflow trials require a separate consented test, not this implementation approval.
-## Host checkpoint (2026-09-12)
+## Host adapters
The app implements lazy structured history reads in both signed and dev-broker
hosts. The broker exposes only `workflow-runs` / `workflow-approvals` POST inputs,
@@ -93,11 +89,9 @@ the captured principal's API admission. History capability means the adapter
exists, not that an older relay serves the endpoint: failures remain explicit.
Responses are stream-bounded to 1 MiB before parsing; command receipts to 16 KiB.
-All workflow writes remain unavailable in real host connections at this
-checkpoint. Fixtures may supply `WorkflowHost.lifecycleVersion = 1` to exercise
-commands. No real host advertises that evidence until the forward relay repair
-and compatibility handshake are implemented and reviewed. Receipt tests do not
-prove a deployed database transaction.
+Workflow writes require positively discovered `WorkflowHost.lifecycleVersion = 1`.
+Old or unverified relays remain browse-only; fixtures can supply that capability
+explicitly. Receipt tests do not prove a deployed database transaction.
Revocation puts existing and newly opened denied views in `unavailable`, purges
all data before callbacks, and cancels late results. A regrant requires explicit
@@ -110,7 +104,7 @@ permitting resave. A coordinate-only old/concurrent head is not save readback.
Generic kind-5 deletes retain their previous behavior: only workflow-coordinate
kind-5 operations use workflow validation and receipt semantics.
-## Forward lifecycle advertisement (implementation checkpoint)
+## Forward lifecycle advertisement
A repaired relay advertises the extension `buzz-workflows` in its host-scoped
NIP-11 document, alongside its explicit stable `self` key and:
@@ -146,11 +140,11 @@ lease against a server replacement between the check and write.
The application’s real browser/native dev shell currently uses the broker;
`connectSignedTransport` is the alternative signer-owned adapter, not a claim
-that native Keychain production wiring has been exercised. The new handshake
-still requires independent review and an approved disposable live test. No
-production deployment or real create/run validation is implied by this checkpoint.
+that native Keychain production wiring has been exercised. The handshake and forward
+lifecycle were independently reviewed and exercised against a disposable local
+relay. This does not imply a production deployment or packaged sign-in support.
-## Host review follow-up (2026-09-12)
+## Authority and cancellation
Workflow broker requests carry the connection's captured relay authority in
`X-Buzz-Workflow-Authority`. The broker requires a lowercase public key and checks
@@ -165,3 +159,24 @@ publication after cancellation. Already-dispatched writes retain unknown-outcome
semantics. Explicit numeric workflow coordinates (including leading zeros and
`+`) enter workflow validation, which rejects every noncanonical coordinate;
unrelated kind-5 event/coordinate deletes are unchanged in the signed adapter.
+
+## Validation and remaining boundaries
+
+Isolated Chromium/WebKit trials exercised disabled creation/manual rejection,
+keyboard enable/save/cancel, reaction execution, edits, stale-editor conflict and
+draft retention, run/trace display, disable/re-enable, and confirmed deletion with
+fresh reads. A committed save with an unknown response was recovered by replaying
+the exact signed event and matching its revision on readback, not by issuing a
+replacement operation. This was not a UI retry-button acceptance test.
+
+The frozen pre-integration app `b62d617` passed 264 functional browser tests (132
+per engine). Real-relay trials used the `900f43b` production app bytes and relay
+`ab411c30b` with two loopback-only listener adaptations. Later mainline integration
+requires its own checks; these results do not certify later commits.
+
+Fresh accepted saves project the configured enabled flag into runtime state;
+omitting it defaults to enabled. Historical split rows and exact pre-fix event
+replays are not repaired. Existing trigger caches can remain stale for roughly
+ten seconds: disable is not immediate distributed cancellation of selected or
+running work. Packaged production host/sign-in, historical reconciliation and a
+complete schedule/webhook/approval/permission matrix remain outside this slice.
From 597cd933367d922c5d24d52a4661c8b4769b75dc Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 09:47:43 -0600
Subject: [PATCH 15/20] test(activity): freeze observer freshness clock
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
dev/agent-observer.test.mjs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/dev/agent-observer.test.mjs b/dev/agent-observer.test.mjs
index b113e130..46aef126 100644
--- a/dev/agent-observer.test.mjs
+++ b/dev/agent-observer.test.mjs
@@ -1,4 +1,4 @@
-import { test, expect } from "vitest";
+import { test, expect, vi, beforeEach, afterEach } from "vitest";
import {
finalizeEvent,
generateSecretKey,
@@ -7,6 +7,12 @@ import {
} from "nostr-tools";
import { decodeAgentObserver } from "./agent-observer.mjs";
+// Keep frame construction and validation in the same second at the ±300s boundary.
+beforeEach(() => {
+ vi.spyOn(Date, "now").mockReturnValue(1700000000999);
+});
+afterEach(() => vi.restoreAllMocks());
+
const owner = generateSecretKey(),
agent = generateSecretKey(),
stranger = generateSecretKey();
From a62eb71fe731951ac30aabac595c43e5feb1c0a8 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 12:28:59 -0600
Subject: [PATCH 16/20] docs(workflows): distinguish run admission from
cancellation
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
docs/workflows.md | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/docs/workflows.md b/docs/workflows.md
index e9a77469..86ee85bb 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -176,7 +176,11 @@ requires its own checks; these results do not certify later commits.
Fresh accepted saves project the configured enabled flag into runtime state;
omitting it defaults to enabled. Historical split rows and exact pre-fix event
-replays are not repaired. Existing trigger caches can remain stale for roughly
-ten seconds: disable is not immediate distributed cancellation of selected or
-running work. Packaged production host/sign-in, historical reconciliation and a
-complete schedule/webhook/approval/permission matrix remain outside this slice.
+replays are not repaired. Trigger caches can still hold stale selections, but the
+[companion relay repair](https://github.com/block/buzz/pull/7621) at `c73db439d`
+checks the selected definition and creation incarnation atomically when admitting
+a new run. A committed disable, update or deletion rejects an ineligible or stale
+selection; callers cannot refresh only the admission record and execute the old
+definition. This is not cancellation of already-admitted runs. Packaged production
+host/sign-in, historical reconciliation and a complete
+schedule/webhook/approval/permission matrix remain outside this slice.
From f4d9d51a572d762e461806cc759ed25aab2c520d Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Mon, 14 Sep 2026 08:13:26 -0600
Subject: [PATCH 17/20] refactor(workflows): keep the app-only editor and
recovery slice
Remove lifecycle negotiation, unused signed-host support, advanced visual serializers and approval/secret placeholders. Recover unknown saves by exact configuration readback; keep dismissal durable and remove the unused replay API. Replace cron interpretation with a plain schedule activation warning.
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
dev/relay-broker.mjs | 74 +----
dev/workflow-broker.test.mjs | 255 ++--------------
docs/workflows.md | 236 ++++-----------
src/bundled/workflows/ConfirmAction.tsx | 17 +-
src/bundled/workflows/WorkflowChannel.tsx | 47 ++-
src/bundled/workflows/WorkflowEditor.tsx | 7 +-
src/bundled/workflows/WorkflowForm.tsx | 117 ++++----
src/bundled/workflows/WorkflowOperations.tsx | 193 ++++++++----
src/bundled/workflows/WorkflowRuns.tsx | 68 +----
src/bundled/workflows/cronExpression.test.mjs | 54 ----
src/bundled/workflows/cronExpression.ts | 157 ----------
src/bundled/workflows/editor-model.ts | 23 +-
src/bundled/workflows/fixtures.ts | 105 ++++---
.../workflowActivationWarning.test.mjs | 73 +----
.../workflows/workflowActivationWarning.ts | 173 ++---------
.../workflows/workflowDuration.test.mjs | 21 --
src/bundled/workflows/workflowDuration.ts | 70 -----
.../workflows/workflowFormTypes.test.mjs | 152 +++-------
src/bundled/workflows/workflowFormTypes.ts | 280 +-----------------
.../workflows/workflowYamlDocument.test.mjs | 94 ++----
src/bundled/workflows/workflowYamlDocument.ts | 24 --
src/bundled/workflows/workflows.journey.mjs | 230 ++++++++++++--
src/features/relay/outbox-receipts.test.ts | 18 +-
src/features/relay/outbox.ts | 11 +-
src/features/relay/signed-admission.test.ts | 6 -
src/features/relay/signed-boundary.test.ts | 6 -
src/features/relay/signed-priority.test.ts | 6 -
src/features/relay/transport.test.ts | 6 -
src/features/relay/transport.ts | 129 ++------
src/features/workflows/capability.test.ts | 144 ++++++++-
src/features/workflows/capability.ts | 88 +++---
src/features/workflows/compatibility.test.ts | 272 -----------------
src/features/workflows/compatibility.ts | 56 ----
src/features/workflows/host.ts | 3 -
src/features/workflows/http.test.ts | 85 +-----
src/features/workflows/http.ts | 96 +++---
src/features/workflows/protocol.test.ts | 47 +--
src/features/workflows/protocol.ts | 55 +---
src/features/workflows/session.test.ts | 129 +++++++-
src/features/workflows/types.ts | 24 --
40 files changed, 1109 insertions(+), 2542 deletions(-)
delete mode 100644 src/bundled/workflows/cronExpression.test.mjs
delete mode 100644 src/bundled/workflows/cronExpression.ts
delete mode 100644 src/features/workflows/compatibility.test.ts
delete mode 100644 src/features/workflows/compatibility.ts
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index e755f4d5..24e89a40 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -1,10 +1,9 @@
-import { workflowLifecycleVersion } from "../src/features/workflows/compatibility.ts";
import {
validateWorkflowEvent,
WORKFLOW_KINDS,
} from "../src/features/workflows/protocol.ts";
import {
- workflowReadPath,
+ workflowRunsPath,
workflowReadText,
} from "../src/features/workflows/http.ts";
import { readReceiptText } from "../src/features/relay/receipt.ts";
@@ -175,17 +174,14 @@ function loadIdentity(authorizedViewer) {
}
return decoded.data;
}
-async function relayAuthority(fetch, relay, signal) {
+async function relayAuthority(fetch, relay) {
const response = await fetch(relay, {
headers: { Accept: "application/nostr+json" },
redirect: "error",
- signal: AbortSignal.any([
- ...(signal ? [signal] : []),
- AbortSignal.timeout(10000),
- ]),
+ signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error("Relay identity discovery failed");
- const nip11 = JSON.parse(await workflowReadText(response));
+ const nip11 = await response.json();
if (!nip11 || typeof nip11 !== "object" || Array.isArray(nip11))
throw new Error("Relay did not advertise its identity");
const author = nip11.self ?? nip11.pubkey;
@@ -193,15 +189,6 @@ async function relayAuthority(fetch, relay, signal) {
throw new Error("Relay did not advertise its identity");
return {
relayAuthor: author,
- ...(workflowLifecycleVersion(nip11, relay, author) === 1
- ? {
- workflowInfo: {
- self: nip11.self,
- supported_extensions: ["buzz-workflows"],
- workflows: { lifecycle: 1, host: new URL(relay).host },
- },
- }
- : {}),
...(readSnapshotCommunity(nip11.read_state_snapshot)
? { readStateCommunity: readSnapshotCommunity(nip11.read_state_snapshot) }
: {}),
@@ -367,8 +354,7 @@ export function relayBrokerPlugin({
)
return json(res, 403, { error: "Origin rejected" });
const url = new URL(req.url, origin);
- // Own the request before any awaited discovery/body read. A close that
- // already happened cannot be recovered by a listener at dispatch time.
+ // Own cancellation before awaiting the request body, signing or dispatch.
const cancel = new AbortController();
const release = () => cancel.abort();
res.once("close", release);
@@ -532,19 +518,11 @@ export function relayBrokerPlugin({
}
}
if (route === "/api/relay/session" && req.method === "GET") {
- const discovered = await authority(fetchUpstream, relay);
return json(res, 200, {
viewer,
- ...discovered,
+ ...(await getAuthority(relay)),
relayUrl: relay,
- writeKinds:
- workflowLifecycleVersion(
- discovered.workflowInfo,
- relay,
- discovered.relayAuthor,
- ) === 1
- ? [9, ...WORKFLOW_KINDS]
- : [9],
+ writeKinds: [9, ...WORKFLOW_KINDS],
workflowReads: true,
sidebarPreferences: true,
readState: true,
@@ -757,7 +735,6 @@ export function relayBrokerPlugin({
"/api/relay/accept-policy",
"/api/relay/gifs",
"/api/relay/workflow-runs",
- "/api/relay/workflow-approvals",
].includes(route) ||
req.method !== "POST"
)
@@ -775,17 +752,9 @@ export function relayBrokerPlugin({
return json(res, 400, { error: "Filter body is not JSON" });
}
let workflowPath;
- if (
- [
- "/api/relay/workflow-runs",
- "/api/relay/workflow-approvals",
- ].includes(route)
- ) {
+ if (route === "/api/relay/workflow-runs") {
try {
- workflowPath = workflowReadPath(
- route.slice("/api/relay/".length),
- filters,
- );
+ workflowPath = workflowRunsPath(filters);
} catch {
return json(res, 400, {
error: "Invalid workflow read",
@@ -892,31 +861,9 @@ export function relayBrokerPlugin({
if (signing || publishing) {
if (filters?.kind !== 9) {
try {
- // This is a connection pin, not an authorization token. Each
- // caller retains its own identity across other tabs/reconnects.
- const expected = req.headers["x-buzz-workflow-authority"];
- if (
- typeof expected !== "string" ||
- !/^[0-9a-f]{64}$/.test(expected)
- )
- throw new Error("Workflow connection authority missing");
- const discovered = await authority(
- fetchUpstream,
- relay,
- cancel.signal,
- );
- if (
- workflowLifecycleVersion(
- discovered.workflowInfo,
- relay,
- expected,
- ) !== 1
- )
- throw new Error("Workflow lifecycle unsupported");
validateWorkflowEvent(
{ ...filters, pubkey: signing ? viewer : filters.pubkey },
viewer,
- { delete: true, webhookSecrets: false },
);
} catch {
cancel.signal.throwIfAborted();
@@ -927,8 +874,7 @@ export function relayBrokerPlugin({
}
} else if (!validMessageTemplate(filters))
return json(res, 400, { error: "Message rejected" });
- // A fixture/adapter may finish discovery despite abort. Never turn
- // that late result into a signature or a newly admitted publication.
+ // Never sign or publish after the requesting browser has left.
cancel.signal.throwIfAborted();
if (signing) {
const started = performance.now();
diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs
index b41d3877..7beb74c1 100644
--- a/dev/workflow-broker.test.mjs
+++ b/dev/workflow-broker.test.mjs
@@ -22,14 +22,11 @@ async function harness(
key[31] = 8;
const viewer = getPublicKey(key),
calls = [],
- logs = [],
- closed = [],
- completed = [];
+ logs = [];
let handler;
const server = createServer((req, res) => {
if (!req.headers.origin) req.headers.origin = `http://${req.headers.host}`;
- res.once("close", () => closed.push(req.url));
- void handler(req, res).finally(() => completed.push(req.url));
+ void handler(req, res);
});
await relayBrokerPlugin({
relayUrl: "https://a.workflow.test",
@@ -75,8 +72,6 @@ async function harness(
viewer,
calls,
logs,
- closed,
- completed,
post: (route, body, headers = {}) =>
fetch(`${base}/api/relay/${route}`, {
method: "POST",
@@ -91,23 +86,18 @@ async function harness(
}
const signal = () => new AbortController().signal;
it("real broker scoped history signs exact GET path/cursor and captured principal without startup reads or workflow writes", async () => {
- const h = await harness(({ url }) =>
- Response.json(
- url.endsWith("approvals") ? { approvals: [] } : { runs: [], next: null },
- ),
- );
+ const h = await harness();
try {
const first = await connectBrokerTransport(h.base);
const other = await connectBrokerTransport(h.base, undefined, "secondary");
expect(h.calls).toHaveLength(0);
- expect(first.writer.kinds).toEqual([9]);
- expect(first.workflows.lifecycleVersion).toBeUndefined();
+ expect(first.writer.kinds).toEqual([9, 30620, 46020, 5]);
await first.workflows.runs(id, cursor, signal());
- await other.workflows.approvals(id, runId, signal());
+ await other.workflows.runs(id, undefined, signal());
await first.workflows.runs(id, undefined, signal());
expect(h.calls.map((call) => call.url)).toEqual([
`https://a.workflow.test/workflows/${id}/runs?limit=20&before=2026-09-12T14%3A44%3A19.123456%2B00%3A00&before_id=${runId}`,
- `https://b.workflow.test/workflows/${id}/runs/${runId}/approvals`,
+ `https://b.workflow.test/workflows/${id}/runs?limit=20`,
`https://a.workflow.test/workflows/${id}/runs?limit=20`,
]);
for (const { url, init, auth } of h.calls) {
@@ -141,7 +131,7 @@ it("broker rejects arbitrary targets, cursors, limits and untrusted origin befor
}
expect(
(await h.post("workflow-approvals", { id, runId: "../" })).status,
- ).toBe(400);
+ ).toBe(404);
expect(
(await h.post("workflow-runs", { id }, { Origin: "https://evil.test" }))
.status,
@@ -240,11 +230,7 @@ it("closing workflow interest aborts the actual broker upstream request", async
}
});
-const compatible = (url, viewer) => ({
- self: viewer,
- supported_extensions: ["buzz-workflows"],
- workflows: { lifecycle: 1, host: new URL(url).host },
-});
+const existingBackend = (_url, viewer) => ({ self: viewer });
const yaml =
"name: Local test\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: wait\n action: delay\n duration: 1s\n";
const template = (kind = 30620) => ({
@@ -256,7 +242,7 @@ const template = (kind = 30620) => ({
["d", id],
],
});
-it("real discovery enables only canonical workflow sign/publish with exact own events and unchanged receipts", async () => {
+it("existing backend signs only canonical workflow sign/publish with exact own events and unchanged receipts", async () => {
const h = await harness(({ init }) => {
const event = JSON.parse(init.body);
expect(verifyEvent(event)).toBe(true);
@@ -265,10 +251,9 @@ it("real discovery enables only canonical workflow sign/publish with exact own e
event_id: event.id,
message: "workflow-result",
});
- }, compatible);
+ }, existingBackend);
try {
const t = await connectBrokerTransport(h.base);
- expect(t.workflows.lifecycleVersion).toBe(1);
expect(t.writer.kinds).toEqual([9, 30620, 46020, 5]);
for (const input of [
template(),
@@ -297,36 +282,6 @@ it("real discovery enables only canonical workflow sign/publish with exact own e
await h.close();
}
});
-it("broker checks fresh own-host evidence at sign and publish; old, other-host and downgrade cannot inherit a write grant", async () => {
- let enabled = true;
- const h = await harness(
- () => {
- throw new Error("must not dispatch writes");
- },
- (url, viewer) =>
- enabled && url.startsWith("https://a.")
- ? compatible(url, viewer)
- : { self: viewer },
- );
- try {
- const t = await connectBrokerTransport(h.base);
- expect(t.workflows.lifecycleVersion).toBe(1);
- const event = await t.writer.sign(template(), signal());
- const other = await connectBrokerTransport(h.base, undefined, "secondary");
- expect(other.workflows.lifecycleVersion).toBeUndefined();
- expect(other.writer.kinds).toEqual([9]);
- await expect(other.writer.sign(template(), signal())).rejects.toThrow();
- enabled = false;
- await expect(t.writer.sign(template(), signal())).rejects.toThrow();
- await expect(t.writer.publish(event, signal())).rejects.toThrow();
- expect(
- (await connectBrokerTransport(h.base)).workflows.lifecycleVersion,
- ).toBeUndefined();
- expect(h.calls).toHaveLength(0);
- } finally {
- await h.close();
- }
-});
const invalidCommands = [
["null", () => null],
["admin deletion", () => ({ ...template(), kind: 9005 })],
@@ -386,20 +341,14 @@ const invalidCommands = [
],
];
it.each(invalidCommands)(
- "compatible broker rejects %s before signing or upstream writes",
+ "existing-backend broker rejects %s before signing or upstream writes",
async (_name, input) => {
const h = await harness(() => {
throw new Error("must not dispatch writes");
- }, compatible);
+ }, existingBackend);
try {
const signaturesBefore = finalizeEvent.mock.calls.length;
- expect(
- (
- await h.post("sign", input(h.viewer), {
- "X-Buzz-Workflow-Authority": h.viewer,
- })
- ).status,
- ).toBe(400);
+ expect((await h.post("sign", input(h.viewer))).status).toBe(400);
expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
expect(h.calls).toHaveLength(0);
} finally {
@@ -407,188 +356,20 @@ it.each(invalidCommands)(
}
},
);
-it("compatible broker rejects forged commands before upstream writes", async () => {
+it("existing-backend broker rejects forged commands before upstream writes", async () => {
const h = await harness(() => {
throw new Error("must not dispatch writes");
- }, compatible);
+ }, existingBackend);
try {
- const own = await (
- await h.post("sign", template(), {
- "X-Buzz-Workflow-Authority": h.viewer,
- })
- ).json();
+ const own = await (await h.post("sign", template())).json();
expect(
- (
- await h.post(
- "publish",
- { ...own, content: "tampered" },
- { "X-Buzz-Workflow-Authority": h.viewer },
- )
- ).status,
+ (await h.post("publish", { ...own, content: "tampered" })).status,
).toBe(400);
expect(
- (
- await h.post(
- "publish",
- { ...own, pubkey: "a".repeat(64) },
- { "X-Buzz-Workflow-Authority": h.viewer },
- )
- ).status,
+ (await h.post("publish", { ...own, pubkey: "a".repeat(64) })).status,
).toBe(400);
expect(h.calls).toHaveLength(0);
} finally {
await h.close();
}
});
-it("real NIP-11 discovery does not accept fallback identity, malformed or wrong-host descriptor", async () => {
- for (const mutate of [
- (d) => ({ ...d, self: undefined, pubkey: d.self }),
- (d) => ({ ...d, supported_extensions: [] }),
- (d) => ({ ...d, workflows: { ...d.workflows, lifecycle: "1" } }),
- (d) => ({ ...d, workflows: { ...d.workflows, host: "other.test" } }),
- (d) => ({ ...d, workflows: null }),
- ]) {
- const h = await harness(undefined, (url, viewer) =>
- mutate(compatible(url, viewer)),
- );
- try {
- const t = await connectBrokerTransport(h.base);
- expect(t.workflows.lifecycleVersion).toBeUndefined();
- expect(t.writer.kinds).toEqual([9]);
- await expect(t.writer.sign(template(), signal())).rejects.toThrow();
- expect(h.calls).toHaveLength(0);
- } finally {
- await h.close();
- }
- }
-});
-
-const gate = () => {
- let resolve;
- const promise = new Promise((done) => {
- resolve = done;
- });
- return { promise, resolve };
-};
-it.each(["sign", "publish"])(
- "disconnect during fresh discovery cancels %s before any late signature or upstream write",
- async (operation) => {
- const entered = gate(),
- release = gate();
- let hold = false,
- metadataSignal;
- const h = await harness(
- ({ init }) =>
- Response.json({
- accepted: true,
- event_id: JSON.parse(init.body).id,
- message: "workflow-result",
- }),
- async (url, viewer, init) => {
- if (hold) {
- metadataSignal = init.signal;
- entered.resolve();
- // Deliberately ignore abort while held: the caller must also fence a late result.
- await release.promise;
- }
- return compatible(url, viewer);
- },
- );
- try {
- const t = await connectBrokerTransport(h.base);
- const input =
- operation === "sign"
- ? template()
- : await t.writer.sign(template(), signal());
- const route = `/api/relay/${operation}`;
- const completedBefore = h.completed.filter(
- (value) => value === route,
- ).length;
- const signaturesBefore = finalizeEvent.mock.calls.length;
- const cancel = new AbortController();
- hold = true;
- const pending = t.writer[operation](input, cancel.signal);
- const rejected = expect(pending).rejects.toThrow();
- await Promise.race([entered.promise, pending]);
- cancel.abort();
- await rejected;
- await vi.waitFor(() => expect(h.closed).toContain(route));
- const discoveryAborted = metadataSignal.aborted;
- release.resolve();
- await vi.waitFor(() =>
- expect(h.completed.filter((value) => value === route)).toHaveLength(
- completedBefore + 1,
- ),
- );
- expect(discoveryAborted).toBe(true);
- expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
- expect(h.calls).toHaveLength(0);
- expect(h.logs).toHaveLength(0);
- hold = false;
- const event = await t.writer.sign(template(), signal());
- expect(await t.writer.publish(event, signal())).toBe("workflow-result");
- expect(h.calls).toHaveLength(1);
- } finally {
- release.resolve();
- await h.close();
- }
- },
-);
-it("workflow requests pin each connection authority; reconnecting B never rebinds an open A session", async () => {
- let rotated = false;
- const h = await harness(
- ({ init }) =>
- Response.json({
- accepted: true,
- event_id: JSON.parse(init.body).id,
- message: "workflow-result",
- }),
- (url, viewer) => compatible(url, rotated ? "a".repeat(64) : viewer),
- );
- try {
- const a = await connectBrokerTransport(h.base);
- const eventA = await a.writer.sign(template(), signal());
- rotated = true;
- const signaturesBefore = finalizeEvent.mock.calls.length;
- await expect(a.writer.sign(template(), signal())).rejects.toThrow();
- await expect(a.writer.publish(eventA, signal())).rejects.toThrow();
- expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
- expect(h.calls).toHaveLength(0);
- const b = await connectBrokerTransport(h.base);
- expect(b.relayAuthor).toBe("a".repeat(64));
- const eventB = await b.writer.sign(template(), signal());
- expect(await b.writer.publish(eventB, signal())).toBe("workflow-result");
- await expect(a.writer.sign(template(), signal())).rejects.toThrow();
- await expect(a.writer.publish(eventA, signal())).rejects.toThrow();
- expect(h.calls).toHaveLength(1);
- } finally {
- await h.close();
- }
-});
-it.each([undefined, "", "not-a-key", "a".repeat(64)])(
- "broker rejects absent, malformed or mismatching request authority: %s",
- async (authority) => {
- const h = await harness(undefined, compatible);
- try {
- const t = await connectBrokerTransport(h.base);
- const event = await t.writer.sign(template(), signal());
- const signaturesBefore = finalizeEvent.mock.calls.length;
- const headers =
- authority === undefined
- ? {}
- : { "X-Buzz-Workflow-Authority": authority };
- for (const [route, body] of [
- ["sign", template()],
- ["publish", event],
- ]) {
- const response = await h.post(route, body, headers);
- expect(response.status).toBe(400);
- expect(await response.json()).toMatchObject({ sent: false });
- }
- expect(finalizeEvent.mock.calls).toHaveLength(signaturesBefore);
- expect(h.calls).toHaveLength(0);
- } finally {
- await h.close();
- }
- },
-);
diff --git a/docs/workflows.md b/docs/workflows.md
index 86ee85bb..8e685793 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -1,186 +1,50 @@
-# Workflows capability and bundled UI handoff
-
-Status: implemented and exercised in isolated browser/broker trials against a
-patched relay; not shipped or packaged-native validated.
-
-## Ownership
-
-The bundled page owns presentation and drafts. The shared session owns workflow
-reads, commands and receipt handling through its existing reader and outbox.
-Host adapters own signing, publication and relay compatibility discovery. The
-relay owns authorization, execution and atomic signed-definition/runtime state.
-
-The type contract is [types.ts](../src/features/workflows/types.ts).
-UI imports that capability by type and receives the captured session's
-`workflows` property. It uses the existing `pages` + `relay` injection and
-`useRelayConnection`, with no alternate connection, cache, outbox or router.
-
-## First complete UI slice
-
-- Channel-scoped **Saved configurations** list and raw YAML detail. Definitions
- expose canonical author/channel/UUID, signed event revision, timestamp and raw
- text. They do not fabricate runtime status or execution authority. `partial`
- signals the bounded query limit, not lifecycle verification.
-- New drafts explicitly disabled. Message/reaction triggers; Send Message/Delay.
- Reuse pure legacy YAML/form/duration/schedule/condition/template helpers and
- their tests selectively. Use shared design-system components and read its
- stewardship instructions before composition. Preserve unsupported YAML and
- incomplete header edits; no rewrite merely on opening or changing selection.
-- Save with existing definition for compare-and-swap; failed/conflicted/unknown
- writes retain drafts. Show accepted delivery separately from domain success.
- Delete requires confirmation and host availability; an old relay's generic
- accepted kind-5 receipt does not prove deletion.
-- Manual run and bounded run history/trace detail.
- Only returned run ID correlates a run; never choose newest run as recovery.
- Approval rows are read-only; their hash is not an approval token.
-- Form and YAML share restrictions. Webhook create/transition cannot bypass the
- one-time-secret capability. Never write secret into drafts, logs, ordinary
- journal, messages or clipboard automatically. Secret reveal is optional until
- its display lifecycle is tested; otherwise keep those saves unavailable.
-
-## Read and write lifetime
-
-Each read view starts idle; the UI subscribes and calls refresh on interest,
-then disposes on unmount/selection change. It owns no background poll unless an
-active-run detail is visible; pause on hidden and terminal state. Runs return one
-20-row page with an opaque exact `(before,beforeId)` pair; dispose the prior page
-before opening another. Never reconstruct the cursor from second-granularity rows.
-Failures show retry and do not become empty, deleted or permission-denied guesses.
-
-Views and operations purge before access-change callbacks. Remount by captured
-scope + generation. Draft keys use stable community/viewer/coordinate, never
-just channel/UUID; generation is not a durable key. Disable/unmount releases UI
-interest but neither disables server workflows nor discards accepted intent.
-
-`save`, `delete`, `trigger` return local signed-intent IDs synchronously; subscribe
-to operations for outcome. The shared outbox journals intent/signature before
-send. Event echo cannot cancel the only result-bearing receipt. Restored signed
-intent never auto-runs; retry uses exactly that event ID and does not promise
-recovery of lost secret/run receipts. Unknown outcome is actionable information,
-not permission to automatically submit a new trigger. Bounded ephemeral result
-state is separate from ordinary delivery persistence.
-
-## Relay compatibility and unresolved historical state
-
-The approved forward repair includes workflow save runtime/event transaction
-consistency and timestamp-ordered atomic deletion using coordinate serialization.
-It does not authorize generic command refactoring, blind old-delete replay,
-destructive historical reconciliation, or a new lifecycle endpoint. Historical
-configuration rows remain unverified; authorized runs reads prove presence only
-at the read. A repaired forward-delete capability must be positively identified
-before Delete is enabled. The exact host compatibility signal is implemented and
-tested with that relay change; do not infer it from version strings or kind lists.
-
-## Acceptance
-
-Production-seam receipt ordering/duplicate/unknown tests; revision and permission
-checks; access purge and A->B->A fencing; persistence and secret isolation; real
-DB rollback/stale/concurrent deletion tests in the relay; UI keyboard/focus,
-dirty-close/conflict drafts, narrow layouts and YAML ownership tests. Fixture
-feedback can precede final package gates. Live identity/signing/destructive
-workflow trials require a separate consented test, not this implementation approval.
-
-## Host adapters
-
-The app implements lazy structured history reads in both signed and dev-broker
-hosts. The broker exposes only `workflow-runs` / `workflow-approvals` POST inputs,
-constructs fixed upstream GET routes, preserves the exact cursor pair, and shares
-the captured principal's API admission. History capability means the adapter
-exists, not that an older relay serves the endpoint: failures remain explicit.
-Responses are stream-bounded to 1 MiB before parsing; command receipts to 16 KiB.
-
-Workflow writes require positively discovered `WorkflowHost.lifecycleVersion = 1`.
-Old or unverified relays remain browse-only; fixtures can supply that capability
-explicitly. Receipt tests do not prove a deployed database transaction.
-
-Revocation puts existing and newly opened denied views in `unavailable`, purges
-all data before callbacks, and cancels late results. A regrant requires explicit
-fresh interest; it never revives an old snapshot. UI must not reopen a recovered
-private draft from an unavailable view. Secret reveal remains disabled.
-
-A save receipt does not populate a read view. Refresh explicitly and match both
-`operation.workflow` and `operation.eventId === definition.revision` before
-permitting resave. A coordinate-only old/concurrent head is not save readback.
-Generic kind-5 deletes retain their previous behavior: only workflow-coordinate
-kind-5 operations use workflow validation and receipt semantics.
-
-## Forward lifecycle advertisement
-
-A repaired relay advertises the extension `buzz-workflows` in its host-scoped
-NIP-11 document, alongside its explicit stable `self` key and:
-
-```json
-{"workflows":{"lifecycle":1,"host":"relay.example:8443"}}
-```
-
-`host` is the normalized, successfully resolved request authority (no scheme or
-path; non-default port retained). The descriptor is absent when host resolution
-fails or the relay has no stable identity. Revision 1 promises atomic forward
-signed-definition/runtime saves and canonical, timestamp-ordered deletion with
-retained cutoff proofs. Legacy name, numeric-kind aliases, definition-e-target
-and admin-delete entrances reject; historical split state is not repaired or
-certified. Authorization, moderation, schema fencing and execution remain relay
-checks, not implications of this metadata.
-
-The app requires all three fields: extension, exact numeric lifecycle 1 and
-matching normalized host, plus a lowercase explicit `self` matching the session's
-relay authority. No software-version, kind-list or contact-key fallback. Metadata
-is fetched from the selected HTTPS origin without redirects, bounded to 1 MiB and
-10 seconds. Missing/malformed/failed evidence never grants workflow writes.
-
-Both signed and broker hosts discover per connection, and recheck before workflow
-signing and publication (no retained positive compatibility cache). Broker session
-DTOs project the validated descriptor; browser code validates it again. A downgrade
-can leave old UI controls visible until reconnect, but signing/publication rejects
-without sending the workflow. Broker signing permits only canonical owned
-workflow operations through the same shared validator as the session. Webhook
-saves and alternate deletion shapes are unavailable; ordinary message signing
-remains unchanged. Metadata is a compatibility assertion, not a cryptographic
-lease against a server replacement between the check and write.
-
-The application’s real browser/native dev shell currently uses the broker;
-`connectSignedTransport` is the alternative signer-owned adapter, not a claim
-that native Keychain production wiring has been exercised. The handshake and forward
-lifecycle were independently reviewed and exercised against a disposable local
-relay. This does not imply a production deployment or packaged sign-in support.
-
-## Authority and cancellation
-
-Workflow broker requests carry the connection's captured relay authority in
-`X-Buzz-Workflow-Authority`. The broker requires a lowercase public key and checks
-fresh host-scoped metadata against that value at both sign and publish. The pin is
-not an authorization token and is never forwarded upstream; a new B connection
-cannot rebind an older A connection. Identity changes require reconnecting.
-
-The broker owns response-close cancellation before reading the operation body or
-awaiting metadata, propagates it to discovery/admission, and checks it before
-signing and dispatch. A late metadata result cannot create a new signature or
-publication after cancellation. Already-dispatched writes retain unknown-outcome
-semantics. Explicit numeric workflow coordinates (including leading zeros and
-`+`) enter workflow validation, which rejects every noncanonical coordinate;
-unrelated kind-5 event/coordinate deletes are unchanged in the signed adapter.
-
-## Validation and remaining boundaries
-
-Isolated Chromium/WebKit trials exercised disabled creation/manual rejection,
-keyboard enable/save/cancel, reaction execution, edits, stale-editor conflict and
-draft retention, run/trace display, disable/re-enable, and confirmed deletion with
-fresh reads. A committed save with an unknown response was recovered by replaying
-the exact signed event and matching its revision on readback, not by issuing a
-replacement operation. This was not a UI retry-button acceptance test.
-
-The frozen pre-integration app `b62d617` passed 264 functional browser tests (132
-per engine). Real-relay trials used the `900f43b` production app bytes and relay
-`ab411c30b` with two loopback-only listener adaptations. Later mainline integration
-requires its own checks; these results do not certify later commits.
-
-Fresh accepted saves project the configured enabled flag into runtime state;
-omitting it defaults to enabled. Historical split rows and exact pre-fix event
-replays are not repaired. Trigger caches can still hold stale selections, but the
-[companion relay repair](https://github.com/block/buzz/pull/7621) at `c73db439d`
-checks the selected definition and creation incarnation atomically when admitting
-a new run. A committed disable, update or deletion rejects an ineligible or stale
-selection; callers cannot refresh only the admission record and execute the old
-definition. This is not cancellation of already-admitted runs. Packaged production
-host/sign-in, historical reconciliation and a complete
-schedule/webhook/approval/permission matrix remain outside this slice.
+# Workflows plugin
+
+The bundled page owns the editor and drafts. The existing relay session owns
+configuration reads and commands through its reader and durable outbox; the dev
+broker owns authentication and the fixed run-history route. The relay executes
+workflows. No separate connection, cache, outbox, scheduler or backend changes.
+See the [capability contract](../src/features/workflows/types.ts).
+
+## Scope
+
+- Channel-scoped saved configurations; new drafts start disabled.
+- Form editing for message/reaction triggers and Send Message/Delay actions.
+ Other definitions stay in YAML; opening them does not rewrite their contents.
+- Save with the original owner/channel/UUID and signed `expected-revision`.
+ Warn on broad message or schedule activation, not ordinary enabled edits.
+- Confirmed deletion request, manual run, and on-demand run/trace history in
+ 20-row pages with the relay's exact `(before,beforeId)` cursor.
+- No approval UI, webhook-secret handling, lifecycle negotiation, alternative
+ signed-host adapter or plugin command-replay API. Webhook-trigger saves are
+ blocked at both the editor and signing boundary, including raw YAML.
+
+## Recovery and limits
+
+The outbox journals intent/signature before publication. A verified echo does
+not replace the result-bearing receipt. Restored commands never run automatically;
+result text stays ephemeral, outside the journal.
+
+**Check saved configuration** resolves an unknown save only when a fresh verified
+head matches its owner, channel, UUID and exact signed revision. Missing/different
+heads retain the draft for review. Dismissal clears the notice and editor lock
+only after durable dismissal; it neither undoes nor repeats a command. Unknown
+runs stay unknown: only a returned run ID identifies a requested run.
+
+Saving a configured enabled flag does not prove runtime activation or cancellation.
+Legacy deletion can retain a visible definition; accepted delivery is not proof
+of runtime cleanup. These backend limitations are displayed, not repaired here.
+
+Reads begin on UI interest and stop on unmount or access loss; no background poll.
+Access loss purges private snapshots before callbacks. Drafts are editor-local,
+not durable, and never move between viewers or communities. The broker preserves
+same-origin checks, signature validation, captured principal quotas, cancellation,
+fixed upstream paths and bounded history/receipt bodies.
+
+## Validation boundary
+
+Offline regressions cover the editor, exact-save recovery, uncertain commands,
+session isolation, broker authorization and history. This is not live acceptance:
+create → edit → run → inspect against an unchanged backend and packaged-native
+acceptance remain unverified. Live credentials, native launch and real workflow
+writes require separate consent; no backend work belongs to this plugin PR.
diff --git a/src/bundled/workflows/ConfirmAction.tsx b/src/bundled/workflows/ConfirmAction.tsx
index 206857e1..dd8987cf 100644
--- a/src/bundled/workflows/ConfirmAction.tsx
+++ b/src/bundled/workflows/ConfirmAction.tsx
@@ -7,18 +7,22 @@ export function ConfirmAction({
action,
onConfirm,
onCancel,
+ pending = false,
+ error,
}: {
title: string;
description: string;
action: string;
onConfirm: () => void;
onCancel: () => void;
+ pending?: boolean;
+ error?: string | null;
}) {
return (
{
- if (!open) onCancel();
+ if (!open && !pending) onCancel();
}}
>
@@ -33,9 +37,16 @@ export function ConfirmAction({
{description}
+ {error && (
+
+ {error}
+
+ )}
- Keep editing
-
+
+ Keep editing
+
+
{action}
diff --git a/src/bundled/workflows/WorkflowChannel.tsx b/src/bundled/workflows/WorkflowChannel.tsx
index 920dc6c2..30af788f 100644
--- a/src/bundled/workflows/WorkflowChannel.tsx
+++ b/src/bundled/workflows/WorkflowChannel.tsx
@@ -136,6 +136,15 @@ export function WorkflowChannel({
setError(null);
}
}, [snapshot?.status]);
+ const dismiss = async (eventId: string) => {
+ await capability.operations.dismiss(eventId);
+ if (submission.current === eventId) submission.current = null;
+ setDraft((current) => {
+ if (current?.operationId !== eventId) return current;
+ const { operationId: _, ...retained } = current;
+ return retained;
+ });
+ };
const save = () => {
if (
!draft ||
@@ -229,16 +238,16 @@ export function WorkflowChannel({
blocked = "Saving is unavailable from this host.";
else if (unresolvedWrite && !draft?.operationId)
blocked =
- "An operation for this workflow is unresolved. Review its retained identity below; do not submit a replacement.";
+ "Check the saved configuration or review the unresolved request in Recent activity before continuing.";
else if (draft?.operationId)
blocked =
operation?.outcome === "succeeded"
? operation.action === "delete"
- ? "Deletion completed. Close this draft; the configuration list is being refreshed."
- : "Save completed; waiting for a readback of this exact signed revision. A different head must be reviewed before editing again."
+ ? "Deletion request accepted, not verified runtime deletion. The configuration may remain visible. Review Recent activity to continue."
+ : "Configuration saved; waiting for a readback of this exact revision. Check saved configuration or review the current version in Recent activity."
: operation?.outcome === "rejected"
- ? "Save rejected. Your draft is retained; review the error before retrying."
- : "This operation has not been resolved. Your draft and operation identity are retained.";
+ ? "Request rejected. Your draft is retained; review the error before continuing."
+ : "Your draft is retained. Check the saved configuration or review the request in Recent activity to continue.";
if (!snapshot) return Reading configurations…
;
return (
@@ -262,8 +271,9 @@ export function WorkflowChannel({
- Configured state is not runtime health. Historical configurations may no
- longer have a runtime workflow.
+ Configured activation may differ from the existing backend’s runtime
+ state. Saving a disabled configuration does not confirm that automatic
+ runs have stopped or cancel work already running.
{snapshot.status === "loading" && (
Reading configurations…
@@ -326,14 +336,15 @@ export function WorkflowChannel({
select("close")}>Close editor
{draft.original && (
- <>
+
+ Configuration details
Owner: {draft.original.owner}
Revision: {draft.original.revision}
- >
+
)}
{readonly && (
@@ -344,6 +355,7 @@ export function WorkflowChannel({
setDraft({ ...draft, yaml })}
onSave={save}
readOnly={readonly}
@@ -409,8 +421,7 @@ export function WorkflowChannel({
)}
{draft.original && !capability.availability.delete && !readonly && (
- Delete is unavailable until the relay proves support for
- consistent workflow deletion.
+ Delete requests are unavailable from this host.
)}
{draft.original && readRuns && (
@@ -422,16 +433,22 @@ export function WorkflowChannel({
)}
{confirmDelete && (
setConfirmDelete(false)}
/>
)}
)}
-
+
{pendingSelection &&
snapshot.status !== "idle" &&
snapshot.status !== "unavailable" && (
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
index 00907ffc..f5f82f2b 100644
--- a/src/bundled/workflows/WorkflowEditor.tsx
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -16,6 +16,7 @@ import {
export function WorkflowEditor({
yaml,
+ initialYaml,
onChange,
onSave,
readOnly = false,
@@ -24,6 +25,7 @@ export function WorkflowEditor({
locked = false,
}: {
yaml: string;
+ initialYaml?: string | undefined;
onChange: (yaml: string) => void;
onSave: () => void;
readOnly?: boolean;
@@ -74,7 +76,10 @@ export function WorkflowEditor({
const warning = getWorkflowActivationWarning(yaml);
const submit = () => {
if (readOnly || busy || locked || unavailable || error) return;
- if (fields.enabled !== false) setActivating(true);
+ const wasEnabled =
+ initialYaml !== undefined &&
+ readWorkflowDocumentFields(initialYaml).enabled !== false;
+ if (fields.enabled !== false && !wasEnabled && warning) setActivating(true);
else onSave();
};
return (
diff --git a/src/bundled/workflows/WorkflowForm.tsx b/src/bundled/workflows/WorkflowForm.tsx
index e66e0a40..a8188d2a 100644
--- a/src/bundled/workflows/WorkflowForm.tsx
+++ b/src/bundled/workflows/WorkflowForm.tsx
@@ -7,7 +7,6 @@ import { formWithStep } from "./editor-model";
import {
ACTION_LABELS,
nextStepId,
- withTriggerType,
type WorkflowFormState,
} from "./workflowFormTypes";
@@ -48,7 +47,7 @@ export function WorkflowForm({
!disabled &&
(value === "message_posted" || value === "reaction_added")
)
- onChange(withTriggerType(state, value));
+ onChange({ ...state, trigger: { on: value } });
}}
/>
{state.trigger.on === "reaction_added" && (
@@ -63,19 +62,23 @@ export function WorkflowForm({
/>
)}
-
- Trigger condition (optional)
-
- onChange({ ...state, trigger: { ...state.trigger, filter } })
- }
- />
-
- An evalexpr expression; leave empty to match every event of this type.
-
-
+
+ Trigger options
+
+ Trigger condition (optional)
+
+ onChange({ ...state, trigger: { ...state.trigger, filter } })
+ }
+ />
+
+ An evalexpr expression; leave empty to match every event of this
+ type.
+
+
+
{state.steps.map((step, index) => (
@@ -96,22 +99,12 @@ export function WorkflowForm({
Remove step {index + 1}
- {step.id}
-
- Step name (optional)
-
- onChange(formWithStep(state, step.id, { name }))
- }
- />
-
{step.action === "send_message" ? (
<>
-
- Message text
+
+ Message text
-
-
- Destination channel UUID (optional)
-
- onChange(formWithStep(state, step.id, { channel }))
- }
- />
-
- Blank uses this workflow’s channel. The relay checks
- destination access.
-
-
+
)}
-
- Step timeout (optional)
-
- onChange(formWithStep(state, step.id, { timeoutSecs }))
- }
- />
-
+
+ Step options
+ {step.id}
+
+ Step name (optional)
+
+ onChange(formWithStep(state, step.id, { name }))
+ }
+ />
+
+ {step.action === "send_message" && (
+
+ Destination channel UUID (optional)
+
+ onChange(formWithStep(state, step.id, { channel }))
+ }
+ />
+
+ Blank uses this workflow’s channel. The relay checks
+ destination access.
+
+
+ )}
+
+ Step timeout (optional)
+
+ onChange(formWithStep(state, step.id, { timeoutSecs }))
+ }
+ />
+ {" "}
+
))}
diff --git a/src/bundled/workflows/WorkflowOperations.tsx b/src/bundled/workflows/WorkflowOperations.tsx
index 3bd99baa..a7bf57da 100644
--- a/src/bundled/workflows/WorkflowOperations.tsx
+++ b/src/bundled/workflows/WorkflowOperations.tsx
@@ -1,73 +1,154 @@
import { useState } from "react";
import { Button } from "../../shared/design-system/ui/Button";
import type {
- WorkflowCapability,
+ WorkflowDefinition,
WorkflowOperation,
} from "../../features/workflows/types";
+import { ConfirmAction } from "./ConfirmAction";
+
+const messages = {
+ save: {
+ pending: "Saving configuration…",
+ succeeded: "Configuration saved.",
+ rejected: "Configuration was not saved.",
+ unknown:
+ "Save response was lost. Check the saved configuration before trying again.",
+ },
+ trigger: {
+ pending: "Requesting a run…",
+ succeeded: "Run requested. Inspect run history for its result.",
+ rejected: "Run request was rejected.",
+ unknown:
+ "The run may have started, but its response was lost. Checking configuration or dismissing this notice cannot confirm a run.",
+ },
+ delete: {
+ pending: "Requesting deletion…",
+ succeeded:
+ "Deletion request accepted. The saved configuration may remain visible; acceptance does not confirm runtime deletion.",
+ rejected: "Deletion request was rejected.",
+ unknown:
+ "The deletion outcome is unknown. The saved configuration may remain visible; do not assume the runtime workflow was deleted.",
+ },
+} as const;
export function WorkflowOperations({
operations,
- capability,
+ definitions,
+ onCheckSaved,
+ onReviewSaved,
+ onDismiss,
}: {
operations: readonly WorkflowOperation[];
- capability: WorkflowCapability;
+ definitions: readonly WorkflowDefinition[];
+ onCheckSaved: () => Promise;
+ onReviewSaved: (definition: WorkflowDefinition) => void;
+ onDismiss: (eventId: string) => Promise;
}) {
const [error, setError] = useState(null);
- if (!operations.length) return null;
+ const [acknowledge, setAcknowledge] = useState(
+ null,
+ );
+ const [working, setWorking] = useState(false);
+ const perform = async (action: () => Promise) => {
+ setWorking(true);
+ try {
+ await action();
+ setError(null);
+ setAcknowledge(null);
+ } catch (cause) {
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "The request could not be completed. Try again.",
+ );
+ } finally {
+ setWorking(false);
+ }
+ };
+ if (!operations.length && !acknowledge) return null;
return (
- Recent operations
- {error && {error}
}
- {operations.map((operation) => (
-
-
- {operation.action}: {operation.outcome} · delivery{" "}
- {operation.delivery}
-
-
{operation.eventId}
- {operation.error &&
{operation.error}
}
- {operation.outcome === "unknown" && (
-
- The outcome is unknown. Do not submit a new operation to repeat
- it; its signed identity is retained by the host. Exact replay may
- confirm delivery but cannot recover a lost run or secret receipt.
-
- )}
- {operation.outcome === "unknown" && (
-
{
- try {
- capability.operations.retry(operation.eventId);
- setError(null);
- } catch (cause) {
- setError(
- cause instanceof Error
- ? cause.message
- : "Retry could not be requested.",
- );
- }
- }}
- >
- Retry same signed operation
-
- )}
- {operation.runId && (
-
- Returned run ID:{" "}
-
- {operation.runId}
-
-
- )}
- {operation.secretAvailable && (
-
- A one-time secret is available, but secure reveal is not supported
- by this UI yet.
-
- )}
-
- ))}
+ Recent activity
+ {error && !acknowledge && {error}
}
+ {operations.map((operation) => {
+ const current = definitions.find(
+ (definition) =>
+ definition.id === operation.workflow.id &&
+ definition.owner === operation.workflow.owner &&
+ definition.channelId === operation.workflow.channelId,
+ );
+ return (
+
+
{messages[operation.action][operation.outcome]}
+ {operation.error && (
+
{operation.error}
+ )}
+
+ {operation.action === "save" &&
+ (operation.outcome === "unknown" ||
+ operation.outcome === "succeeded") && (
+ <>
+ void perform(onCheckSaved)}
+ >
+ Check saved configuration
+
+ {current && current.revision !== operation.eventId && (
+ onReviewSaved(current)}
+ >
+ Review current configuration
+
+ )}
+ >
+ )}
+ {operation.outcome !== "pending" && (
+ setAcknowledge(operation)}
+ >
+ Dismiss notice
+
+ )}
+
+
+ Delivery details
+
+ {operation.action}: {operation.outcome} · delivery{" "}
+ {operation.delivery}
+
+
+ Event ID: {operation.eventId}
+
+ {operation.runId && (
+
+ Returned run ID: {operation.runId}
+
+ )}
+
+
+ );
+ })}
+ {acknowledge && (
+ {
+ if (!working) void perform(() => onDismiss(acknowledge.eventId));
+ }}
+ onCancel={() => {
+ if (!working) setAcknowledge(null);
+ }}
+ />
+ )}
);
}
diff --git a/src/bundled/workflows/WorkflowRuns.tsx b/src/bundled/workflows/WorkflowRuns.tsx
index d0ad9255..887c7ece 100644
--- a/src/bundled/workflows/WorkflowRuns.tsx
+++ b/src/bundled/workflows/WorkflowRuns.tsx
@@ -49,7 +49,6 @@ function RunPage({
[capability, workflow, cursor],
),
);
- const [approvalRun, setApprovalRun] = useState(null);
if (!snapshot) return Reading runs…
;
return (
@@ -87,7 +86,6 @@ function RunPage({
{run.status.replaceAll("_", " ")} ·{" "}
{new Date(run.createdAt * 1000).toISOString()}
- {run.id}
Current step: {run.currentStep}
@@ -97,28 +95,12 @@ function RunPage({
)}
- Execution trace
+ Run details and trace
+ Run ID: {run.id}
{JSON.stringify(run.trace, null, 2)}
-
- setApprovalRun(approvalRun === run.id ? null : run.id)
- }
- >
- {approvalRun === run.id ? "Hide approvals" : "Read approvals"}
-
- {approvalRun === run.id && (
-
- )}
))}
@@ -138,49 +120,3 @@ function RunPage({
);
}
-function ApprovalRows({
- capability,
- workflow,
- runId,
-}: {
- capability: WorkflowCapability;
- workflow: WorkflowReference;
- runId: string;
-}) {
- const { snapshot, refresh } = useWorkflowView(
- useCallback(
- () => capability.approvals(workflow, runId),
- [capability, workflow, runId],
- ),
- );
- if (!snapshot) return
Reading approvals…
;
- return (
-
- {snapshot.status === "ready" ? (
- snapshot.data.length ? (
-
- {snapshot.data.map((row) => (
-
- {row.stepId}: {row.status}
- {row.note ? ` — ${row.note}` : ""}
-
- ))}
-
- ) : (
-
No approval history returned.
- )
- ) : (
-
- {snapshot.error ?? `Approval history: ${snapshot.status}.`}
-
- )}
-
void refresh()}
- >
- Refresh approvals
-
-
- );
-}
diff --git a/src/bundled/workflows/cronExpression.test.mjs b/src/bundled/workflows/cronExpression.test.mjs
deleted file mode 100644
index 9981c458..00000000
--- a/src/bundled/workflows/cronExpression.test.mjs
+++ /dev/null
@@ -1,54 +0,0 @@
-import assert from "node:assert/strict";
-import { test } from "vitest";
-
-import {
- CRON_FIELD_DEFINITIONS,
- cronExpressionError,
- cronExpressionFromFields,
- cronFieldsFromPaste,
- validateCronField,
- validateCronFields,
-} from "./cronExpression.ts";
-
-test("accepts supported five-field cron syntax", () => {
- for (const expression of [
- "0 9 * * 1-5",
- "*/15 * * * *",
- "0 */2 1,15 JAN,MAR MON-FRI",
- ]) {
- const result = cronFieldsFromPaste(expression);
- assert.equal(result.ok, true);
- assert.deepEqual(validateCronFields(result.fields), [
- null,
- null,
- null,
- null,
- null,
- ]);
- assert.equal(cronExpressionFromFields(result.fields), expression);
- assert.equal(cronExpressionError(expression), null);
- }
-});
-
-test("validates cron field ranges and structure locally", () => {
- assert.equal(
- validateCronField("60", CRON_FIELD_DEFINITIONS[0]),
- "Minute must be between 0 and 59.",
- );
- assert.equal(
- validateCronField("5-2", CRON_FIELD_DEFINITIONS[2]),
- "Day range must go from lower to higher.",
- );
- assert.equal(
- validateCronField("*/0", CRON_FIELD_DEFINITIONS[1]),
- "Hour step must be a positive whole number.",
- );
-});
-
-test("whole-expression validation requires exactly five fields", () => {
- assert.deepEqual(cronFieldsFromPaste("0 9 * *"), {
- error: "Paste a 5-field cron expression. Found 4 fields.",
- ok: false,
- });
- assert.match(cronExpressionError("not-a-cron"), /Found 1 field/);
-});
diff --git a/src/bundled/workflows/cronExpression.ts b/src/bundled/workflows/cronExpression.ts
deleted file mode 100644
index 8d607ad4..00000000
--- a/src/bundled/workflows/cronExpression.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-// Adapted from block/buzz desktop workflow helpers at b9392d9d.
-export const CRON_FIELD_DEFINITIONS = [
- { label: "Minute", max: 59, min: 0 },
- { label: "Hour", max: 23, min: 0 },
- { label: "Day", max: 31, min: 1 },
- {
- aliases: [
- "JAN",
- "FEB",
- "MAR",
- "APR",
- "MAY",
- "JUN",
- "JUL",
- "AUG",
- "SEP",
- "OCT",
- "NOV",
- "DEC",
- ],
- label: "Month",
- max: 12,
- min: 1,
- },
- {
- aliases: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"],
- label: "Weekday",
- max: 7,
- min: 0,
- },
-] as const;
-
-export type CronFields = [string, string, string, string, string];
-
-export function cronFieldsFromExpression(expression: string): CronFields {
- const values = expression.trim() ? expression.trim().split(/\s+/) : [];
- return [
- values[0] ?? "",
- values[1] ?? "",
- values[2] ?? "",
- values[3] ?? "",
- values[4] ?? "",
- ];
-}
-
-export function cronExpressionFromFields(fields: CronFields): string {
- return fields.join(" ");
-}
-
-export function normalizeCronExpression(expression: string): string {
- return expression.trim().replace(/\s+/g, " ");
-}
-
-export function cronFieldsFromPaste(
- pastedValue: string,
-): { fields: CronFields; ok: true } | { error: string; ok: false } {
- const values = pastedValue.trim().split(/\s+/);
- if (values.length !== CRON_FIELD_DEFINITIONS.length) {
- return {
- error: `Paste a 5-field cron expression. Found ${values.length} field${values.length === 1 ? "" : "s"}.`,
- ok: false,
- };
- }
- return { fields: values as CronFields, ok: true };
-}
-
-type CronFieldDefinition = (typeof CRON_FIELD_DEFINITIONS)[number];
-
-function atomError(
- atom: string,
- definition: CronFieldDefinition,
-): string | null {
- const upperAtom = atom.toUpperCase();
- if (
- "aliases" in definition &&
- definition.aliases.includes(upperAtom as never)
- ) {
- return null;
- }
- if (!/^\d+$/.test(atom)) {
- return `${definition.label} contains “${atom}”, which is not a supported value.`;
- }
-
- const value = Number(atom);
- if (value < definition.min || value > definition.max) {
- return `${definition.label} must be between ${definition.min} and ${definition.max}.`;
- }
- return null;
-}
-
-function segmentError(
- segment: string,
- definition: CronFieldDefinition,
-): string | null {
- const stepParts = segment.split("/");
- if (stepParts.length > 2 || stepParts.some((part) => !part)) {
- return `${definition.label} has an invalid step.`;
- }
-
- const [base = "", step] = stepParts;
- if (step !== undefined) {
- if (!/^\d+$/.test(step) || Number(step) < 1) {
- return `${definition.label} step must be a positive whole number.`;
- }
- }
-
- if (base === "*") return null;
-
- const rangeParts = base.split("-");
- if (rangeParts.length > 2 || rangeParts.some((part) => !part)) {
- return `${definition.label} has an invalid range.`;
- }
-
- const startError = atomError(rangeParts[0] ?? "", definition);
- if (startError) return startError;
- if (rangeParts.length === 1) return null;
-
- const endError = atomError(rangeParts[1] ?? "", definition);
- if (endError) return endError;
-
- const start = Number(rangeParts[0]);
- const end = Number(rangeParts[1]);
- if (Number.isFinite(start) && Number.isFinite(end) && start > end) {
- return `${definition.label} range must go from lower to higher.`;
- }
- return null;
-}
-
-export function validateCronField(
- value: string,
- definition: CronFieldDefinition,
-): string | null {
- if (!value) return `${definition.label} is required.`;
-
- const segments = value.split(",");
- if (segments.some((segment) => !segment)) {
- return `${definition.label} has an empty list item.`;
- }
-
- for (const segment of segments) {
- const error = segmentError(segment, definition);
- if (error) return error;
- }
- return null;
-}
-
-export function validateCronFields(fields: CronFields): Array
{
- return CRON_FIELD_DEFINITIONS.map((definition, index) =>
- validateCronField(fields[index] ?? "", definition),
- );
-}
-
-export function cronExpressionError(expression: string): string | null {
- const parsed = cronFieldsFromPaste(expression);
- if (!parsed.ok) return parsed.error;
- return validateCronFields(parsed.fields).find(Boolean) ?? null;
-}
diff --git a/src/bundled/workflows/editor-model.ts b/src/bundled/workflows/editor-model.ts
index d7c42f1d..0e2191dd 100644
--- a/src/bundled/workflows/editor-model.ts
+++ b/src/bundled/workflows/editor-model.ts
@@ -5,27 +5,8 @@ import type {
} from "../../features/workflows/types";
import { yamlToFormState, type WorkflowFormState } from "./workflowFormTypes";
-export const EDITOR_TRIGGERS = ["message_posted", "reaction_added"] as const;
-export const EDITOR_ACTIONS = ["send_message", "delay"] as const;
-
-/** A smaller visual menu must not silently take ownership of advanced YAML. */
-export function visualForm(yaml: string): ReturnType {
- const parsed = yamlToFormState(yaml);
- if (!parsed.ok) return parsed;
- if (
- !EDITOR_TRIGGERS.some((on) => on === parsed.state.trigger.on) ||
- parsed.state.steps.some(
- (step) => !EDITOR_ACTIONS.some((action) => action === step.action),
- )
- ) {
- return {
- ok: false,
- error:
- "This definition uses advanced triggers or actions. Keep editing its original YAML.",
- };
- }
- return parsed;
-}
+/** The form parser owns only the two triggers and two actions this UI edits. */
+export const visualForm = yamlToFormState;
/** Draft shape validation is not relay authorization or a promise of execution. */
export function draftError(yaml: string): string | null {
diff --git a/src/bundled/workflows/fixtures.ts b/src/bundled/workflows/fixtures.ts
index 21614540..67ad1721 100644
--- a/src/bundled/workflows/fixtures.ts
+++ b/src/bundled/workflows/fixtures.ts
@@ -5,7 +5,6 @@ import type {
WorkflowView,
WorkflowRun,
WorkflowRunCursor,
- WorkflowApproval,
WorkflowDefinitions,
} from "../../features/workflows/types";
@@ -105,11 +104,9 @@ export function createWorkflowFixture() {
delete: 0,
trigger: 0,
runs: 0,
- approvals: 0,
- retry: [] as string[],
+ dismiss: [] as string[],
};
const runViews: { disposed(): boolean }[] = [];
- const approvalViews: { disposed(): boolean }[] = [];
let runCursor: WorkflowRunCursor | undefined;
const publish = (next: readonly WorkflowOperation[]) => {
operations = next;
@@ -128,11 +125,14 @@ export function createWorkflowFixture() {
action,
delivery: "sending",
outcome: "pending",
- secretAvailable: false,
},
]);
return eventId;
};
+ let savedOnServer: WorkflowDefinition | undefined;
+ let dismissError: string | undefined;
+ let dismissGate: Promise | undefined;
+ let releaseDismiss: (() => void) | undefined;
const capability: WorkflowCapability = {
availability: {
definitions: true,
@@ -140,13 +140,32 @@ export function createWorkflowFixture() {
save: true,
trigger: true,
delete: true,
- webhookSecrets: false,
},
definitions: () => {
const owned = fixtureView(definitionState.data);
owned.update(definitionState);
definitionViews.push(owned);
- return owned.view;
+ return {
+ ...owned.view,
+ async refresh() {
+ if (savedOnServer && definitionState.status !== "unavailable") {
+ definitions.update({
+ status: "ready",
+ data: { items: [savedOnServer], partial: false },
+ });
+ publish(
+ operations.map((operation) =>
+ operation.action === "save" &&
+ operation.outcome === "unknown" &&
+ operation.eventId === savedOnServer?.revision
+ ? { ...operation, outcome: "succeeded" }
+ : operation,
+ ),
+ );
+ }
+ await owned.view.refresh();
+ },
+ };
},
runs: (_workflow, cursor) => {
calls.runs++;
@@ -158,21 +177,6 @@ export function createWorkflowFixture() {
runViews.push(next);
return next.view;
},
- approvals: () => {
- calls.approvals++;
- const next = fixtureView([
- {
- reference: "cc".repeat(32),
- runId: fixtureRun.id,
- stepId: "notify",
- status: "granted",
- note: "Fixture decision",
- createdAt: 1_789_224_000,
- },
- ]);
- approvalViews.push(next);
- return next.view;
- },
save(input) {
calls.save++;
savedInput = input;
@@ -201,13 +205,27 @@ export function createWorkflowFixture() {
listeners.delete(listener);
};
},
- retry(id) {
- calls.retry.push(id);
+ async dismiss(id) {
+ calls.dismiss.push(id);
+ if (
+ operations.some(
+ (operation) =>
+ operation.eventId === id && operation.outcome === "pending",
+ )
+ )
+ throw new Error("Still pending");
+ const previous = operations.find(
+ (operation) => operation.eventId === id,
+ );
+ // Match the outbox: remove/notify before persistence, restore on failure.
+ publish(operations.filter((operation) => operation.eventId !== id));
+ await dismissGate;
+ if (dismissError) {
+ if (previous && definitionState.status !== "unavailable")
+ publish([...operations, previous]);
+ throw new Error(dismissError);
+ }
},
- async dismiss() {},
- },
- takeWebhookSecret() {
- return undefined;
},
};
return {
@@ -217,7 +235,30 @@ export function createWorkflowFixture() {
input: () => savedInput,
runCursor: () => runCursor,
runViews,
- approvalViews,
+ setDismissError: (message?: string) => {
+ dismissError = message;
+ },
+ holdDismiss() {
+ dismissGate = new Promise((resolve) => {
+ releaseDismiss = resolve;
+ });
+ },
+ releaseDismiss() {
+ releaseDismiss?.();
+ dismissGate = undefined;
+ releaseDismiss = undefined;
+ },
+ saveOnServer(exact = true) {
+ const operation = operations.at(-1);
+ if (operation?.action !== "save" || !savedInput)
+ throw new Error("No save");
+ savedOnServer = {
+ ...fixtureDefinition,
+ ...operation.workflow,
+ yaml: savedInput.yaml,
+ revision: exact ? operation.eventId : "bb".repeat(32),
+ };
+ },
finish(outcome: WorkflowOperation["outcome"], exact = true) {
const operation = operations.at(-1);
if (!operation) throw new Error("No operation");
@@ -242,11 +283,7 @@ export function createWorkflowFixture() {
},
});
}
- if (outcome === "succeeded" && operation.action === "delete")
- definitions.update({
- status: "ready",
- data: { partial: false, items: [] },
- });
+ // Legacy deletion accepts the request without removing the signed definition.
publish(
operations.map((item) =>
item.eventId === operation.eventId
diff --git a/src/bundled/workflows/workflowActivationWarning.test.mjs b/src/bundled/workflows/workflowActivationWarning.test.mjs
index d307fdb1..a8dfd5b8 100644
--- a/src/bundled/workflows/workflowActivationWarning.test.mjs
+++ b/src/bundled/workflows/workflowActivationWarning.test.mjs
@@ -27,73 +27,18 @@ test("does not warn when a message trigger is narrowed", () => {
);
});
-test("warns for hourly-or-faster interval schedules with concrete copy", () => {
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(" on: schedule\n interval: 15m"),
- )?.description,
- "It is scheduled to run every 15 minutes. Review the schedule before turning it on.",
- );
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(" on: schedule\n interval: 2h"),
- ),
- null,
- );
-});
-
-test("warns for clear hourly-or-faster cron schedules", () => {
- for (const cron of ["*/5 * * * *", "0 */5 * * * *", "0 */5 * * * * *"]) {
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(` on: schedule\n cron: "${cron}"`),
- )?.description,
- "It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
- );
- }
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(' on: schedule\n cron: "*/10 * * * * *"'),
- )?.description,
- "It is scheduled to run multiple times a minute. Review the schedule before turning it on.",
- );
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(' on: schedule\n cron: "0 9 * * *"'),
- ),
- null,
- );
-});
-
-test("warns for stepped, ranged, and listed hourly cron schedules", () => {
- for (const cron of [
- "0 0 */1 * * *",
- "0 0 * * * *",
- "0 0 0-23 * * *",
- "0 0 0-11,12-23 * * *",
- "0 0 */1 * * * *",
+test("warns on schedule activation without interpreting its cadence", () => {
+ for (const schedule of [
+ "interval: 15m",
+ "interval: 2h",
+ 'cron: "*/5 * * * *"',
+ 'cron: "0 9 * * *"',
]) {
assert.equal(
getWorkflowActivationWarning(
- workflowYaml(` on: schedule\n cron: "${cron}"`),
- )?.description,
- "It is scheduled to run every hour. Review the schedule before turning it on.",
- cron,
- );
- }
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(' on: schedule\n cron: "*/5 0-23 * * *"'),
- )?.description,
- "It is scheduled to run every 5 minutes. Review the schedule before turning it on.",
- );
- for (const cron of ["0 */3 * * *", "0 0 0,12 * * *", "0 0 8-17 * * *"]) {
- assert.equal(
- getWorkflowActivationWarning(
- workflowYaml(` on: schedule\n cron: "${cron}"`),
- ),
- null,
- cron,
+ workflowYaml(` on: schedule\n ${schedule}`),
+ )?.title,
+ "Enable this scheduled workflow?",
);
}
});
diff --git a/src/bundled/workflows/workflowActivationWarning.ts b/src/bundled/workflows/workflowActivationWarning.ts
index f4a556f6..3a5ea833 100644
--- a/src/bundled/workflows/workflowActivationWarning.ts
+++ b/src/bundled/workflows/workflowActivationWarning.ts
@@ -1,161 +1,30 @@
-// Adapted from block/buzz desktop workflow helpers at b9392d9d.
import { parse as yamlParse } from "yaml";
-import {
- formatDurationSecondsVerbose,
- parseDurationSeconds,
-} from "./workflowDuration";
-
-type WorkflowActivationWarning = {
- description: string;
- title: string;
-};
-
-const FREQUENT_SCHEDULE_THRESHOLD_SECONDS = 60 * 60;
-
-function asRecord(value: unknown): Record | null {
- return value !== null && typeof value === "object" && !Array.isArray(value)
- ? (value as Record)
- : null;
-}
-
-function nonEmptyString(value: unknown): string | null {
- return typeof value === "string" && value.trim() ? value.trim() : null;
-}
-
-function frequentIntervalDescription(interval: string): string | null {
- const seconds = parseDurationSeconds(interval);
- if (
- seconds === null ||
- seconds <= 0 ||
- seconds > FREQUENT_SCHEDULE_THRESHOLD_SECONDS
- ) {
- return null;
- }
- return `It is scheduled to run every ${formatDurationSecondsVerbose(seconds)}. Review the schedule before turning it on.`;
-}
-
-function normalizedCronFields(cron: string): string[] | null {
- const fields = cron.trim().split(/\s+/);
- if (fields.length === 5) return ["0", ...fields, "*"];
- if (fields.length === 6) return [...fields, "*"];
- return fields.length === 7 ? fields : null;
-}
-
-function repeatedFieldCount(field: string, maximum: number): number | null {
- if (field === "*") return maximum + 1;
- const step = /^\*\/(\d+)$/.exec(field);
- if (step) {
- const size = Number(step[1]);
- return size >= 1 && size <= maximum + 1
- ? Math.ceil((maximum + 1) / size)
- : null;
- }
- if (field.includes(",") || field.includes("-")) return 2;
- return /^\d+$/.test(field) ? 1 : null;
-}
-
-/**
- * Reports whether an hour field selects every hour of the day, so schedules
- * written as `*`, `*\/1`, `0-23`, or an equivalent list still classify as
- * hourly. Unrecognized fields are treated as not matching every hour.
- */
-function matchesEveryHour(field: string): boolean {
- const HOURS = 24;
- const selected = new Set();
- for (const segment of field.split(",")) {
- const [base = "", step] = segment.split("/");
- if (step !== undefined && (!/^\d+$/.test(step) || Number(step) < 1)) {
- return false;
- }
- const size = step === undefined ? 1 : Number(step);
- let start: number, end: number;
- if (base === "*") {
- start = 0;
- end = HOURS - 1;
- } else {
- const bounds = base.split("-");
- if (bounds.length > 2 || bounds.some((part) => !/^\d+$/.test(part))) {
- return false;
- }
- start = Number(bounds[0]);
- end = bounds.length === 2 ? Number(bounds[1]) : start;
- if (start > end || end >= HOURS) return false;
- // A bare hour selects only itself; a stepped one runs to end of day.
- if (bounds.length === 1) {
- if (step === undefined) {
- selected.add(start);
- continue;
- }
- end = HOURS - 1;
- }
- }
- for (let hour = start; hour <= end; hour += size) selected.add(hour);
- }
- return selected.size === HOURS;
-}
-
-function frequentCronDescription(cron: string): string | null {
- const fields = normalizedCronFields(cron);
- if (!fields) return null;
- const [second = "", minute = "", hour = ""] = fields;
- const secondRuns = repeatedFieldCount(second, 59);
- const minuteRuns = repeatedFieldCount(minute, 59);
- if (secondRuns === null || minuteRuns === null) return null;
- if (!matchesEveryHour(hour)) return null;
-
- if (secondRuns > 1) {
- return "It is scheduled to run multiple times a minute. Review the schedule before turning it on.";
- }
- if (minute === "*") {
- return "It is scheduled to run every minute. Review the schedule before turning it on.";
- }
- const steppedMinute = /^\*\/(\d+)$/.exec(minute);
- if (steppedMinute) {
- const minutes = Number(steppedMinute[1]);
- return `It is scheduled to run every ${minutes} minute${minutes === 1 ? "" : "s"}. Review the schedule before turning it on.`;
- }
- if (minuteRuns === 1) {
- return "It is scheduled to run every hour. Review the schedule before turning it on.";
- }
- if (minuteRuns > 1) {
- return "It is scheduled to run multiple times an hour. Review the schedule before turning it on.";
- }
- return null;
-}
-
+/** Warn on activation; schedule interpretation and execution belong to the relay. */
export function getWorkflowActivationWarning(
yaml: string,
-): WorkflowActivationWarning | null {
- let definition: Record | null;
+): { title: string; description: string } | null {
try {
- definition = asRecord(yamlParse(yaml));
- } catch {
- return null;
- }
- const trigger = asRecord(definition?.trigger);
- const triggerType = nonEmptyString(trigger?.on);
-
- if (triggerType === "message_posted" && !nonEmptyString(trigger?.filter)) {
- return {
- description:
- "It will run for every new message in this channel. Review the trigger before turning it on.",
- title: "This workflow may run often",
- };
- }
-
- if (triggerType === "schedule") {
- const interval = nonEmptyString(trigger?.interval);
- const cron = nonEmptyString(trigger?.cron);
- const description = interval
- ? frequentIntervalDescription(interval)
- : cron
- ? frequentCronDescription(cron)
- : null;
- if (description) {
- return { description, title: "This workflow may run often" };
+ const trigger = yamlParse(yaml)?.trigger;
+ if (
+ trigger?.on === "message_posted" &&
+ !(typeof trigger.filter === "string" && trigger.filter.trim())
+ ) {
+ return {
+ title: "This workflow may run often",
+ description:
+ "It will run for every new message in this channel. Review the trigger before turning it on.",
+ };
+ }
+ if (trigger?.on === "schedule") {
+ return {
+ title: "Enable this scheduled workflow?",
+ description:
+ "The relay may run this workflow automatically on its configured schedule. Review the schedule in YAML before turning it on.",
+ };
}
+ } catch {
+ // Draft validation reports malformed YAML; it cannot be saved.
}
-
return null;
}
diff --git a/src/bundled/workflows/workflowDuration.test.mjs b/src/bundled/workflows/workflowDuration.test.mjs
index f84016f8..26536430 100644
--- a/src/bundled/workflows/workflowDuration.test.mjs
+++ b/src/bundled/workflows/workflowDuration.test.mjs
@@ -2,10 +2,7 @@ import assert from "node:assert/strict";
import { test } from "vitest";
import {
- DURATION_SLIDER_STOPS,
- durationSliderIndex,
formatDurationSeconds,
- formatDurationSecondsVerbose,
parseDurationSeconds,
} from "./workflowDuration.ts";
@@ -35,21 +32,3 @@ test("formatDurationSeconds produces compact labels with significant units", ()
assert.equal(formatDurationSeconds(172_800), "2d");
assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
});
-
-test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
- assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
- assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
- assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
- assert.equal(
- formatDurationSecondsVerbose(1_483_506),
- "2 weeks 3 days 4 hours 5 minutes 6 seconds",
- );
-});
-
-test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
- assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
- assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
- assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
- assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
- assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
-});
diff --git a/src/bundled/workflows/workflowDuration.ts b/src/bundled/workflows/workflowDuration.ts
index d018b19a..97e8fcc1 100644
--- a/src/bundled/workflows/workflowDuration.ts
+++ b/src/bundled/workflows/workflowDuration.ts
@@ -57,73 +57,3 @@ export function formatDurationSeconds(totalSeconds: number): string {
return parts.join(" ");
}
-
-function verboseUnit(value: number, unit: string): string {
- return `${value} ${unit}${value === 1 ? "" : "s"}`;
-}
-
-/** Format whole seconds with fully spelled-out units for summary UI. */
-export function formatDurationSecondsVerbose(totalSeconds: number): string {
- if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
- if (totalSeconds === 0) return "0 seconds";
-
- const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
- const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
- const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
- const minutes = Math.floor(
- (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
- );
- const seconds = totalSeconds % SECONDS_PER_MINUTE;
- const parts: string[] = [];
-
- if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
- if (days > 0) parts.push(verboseUnit(days, "day"));
- if (hours > 0) parts.push(verboseUnit(hours, "hour"));
- if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
- if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
-
- return parts.join(" ");
-}
-
-function steppedRange(start: number, end: number, step: number): number[] {
- const values: number[] = [];
- for (let value = start; value <= end; value += step) values.push(value);
- return values;
-}
-
-/**
- * Slider stops favor the short delays people use most, then relax precision as
- * the duration grows. The typed field still accepts exact values between stops.
- */
-export const DURATION_SLIDER_STOPS = [
- ...steppedRange(1, 120, 1),
- ...steppedRange(125, 600, 5),
- ...steppedRange(615, 1_800, 15),
- ...steppedRange(1_860, 7_200, 60),
- ...steppedRange(7_500, 10_800, 300),
-];
-
-export const DEFAULT_DURATION_SECONDS = 1;
-
-export function durationSliderIndex(totalSeconds: number): number {
- if (totalSeconds <= (DURATION_SLIDER_STOPS[0] ?? 1)) return 0;
-
- const lastIndex = DURATION_SLIDER_STOPS.length - 1;
- if (totalSeconds >= (DURATION_SLIDER_STOPS[lastIndex] ?? 10_800))
- return lastIndex;
-
- let low = 0;
- let high = lastIndex;
- while (low <= high) {
- const middle = Math.floor((low + high) / 2);
- const value = DURATION_SLIDER_STOPS[middle] ?? 0;
- if (value === totalSeconds) return middle;
- if (value < totalSeconds) low = middle + 1;
- else high = middle - 1;
- }
-
- return totalSeconds - (DURATION_SLIDER_STOPS[high] ?? 0) <=
- (DURATION_SLIDER_STOPS[low] ?? 10_800) - totalSeconds
- ? high
- : low;
-}
diff --git a/src/bundled/workflows/workflowFormTypes.test.mjs b/src/bundled/workflows/workflowFormTypes.test.mjs
index 59a269b7..394097dc 100644
--- a/src/bundled/workflows/workflowFormTypes.test.mjs
+++ b/src/bundled/workflows/workflowFormTypes.test.mjs
@@ -4,9 +4,6 @@ import { parse as parseYaml } from "yaml";
import {
formStateToYaml,
- isThreadReplyEligibleTrigger,
- supportsMessageTextCondition,
- withTriggerType,
yamlToFormState,
DEFAULT_FORM_STATE,
} from "./workflowFormTypes.ts";
@@ -20,11 +17,6 @@ function accepted(yaml) {
function normalizeBackendDefaults(value) {
const copy = structuredClone(value);
if (copy.enabled === undefined) copy.enabled = true;
- for (const step of copy.steps ?? []) {
- if (step.action === "call_webhook" && step.method === undefined) {
- step.method = "POST";
- }
- }
return copy;
}
@@ -44,22 +36,9 @@ function sendMessageState(overrides) {
};
}
-test("message-text conditions are limited to message-bearing triggers", () => {
- assert.equal(supportsMessageTextCondition("message_posted"), true);
- assert.equal(supportsMessageTextCondition("diff_posted"), true);
- assert.equal(supportsMessageTextCondition("reaction_added"), false);
- assert.equal(supportsMessageTextCondition("webhook"), false);
- assert.equal(supportsMessageTextCondition("schedule"), false);
-});
-
const acceptedFixtures = [
- `name: Notify\ntrigger:\n on: message_posted\nsteps:\n - id: notify_1\n action: send_message\n text: hello\n`,
- `name: React\ndescription: React to a message\nenabled: false\ntrigger:\n on: reaction_added\n emoji: eyes\n filter: trigger_message_id == "abc123"\nsteps:\n - id: react\n name: Add reaction\n timeout_secs: 30\n action: add_reaction\n emoji: white_check_mark\n`,
- `name: Webhook\ntrigger:\n on: webhook\nsteps:\n - id: call\n action: call_webhook\n url: https://example.com/hook\n method: PATCH\n headers:\n Authorization: secret\n X-Trace: trace\n body: '{"ok":true}'\n`,
- `name: Legacy actions\ntrigger:\n on: diff_posted\n filter: str_contains(trigger_text, "deploy")\nsteps:\n - id: dm\n action: send_dm\n to: abc123\n text: hello\n - id: approval\n action: request_approval\n from: manager\n message: Approve?\n timeout: 24h\n - id: topic\n action: set_channel_topic\n topic: Deployed\n - id: wait\n action: delay\n duration: 5m\n`,
- `name: Scheduled preset\ntrigger:\n on: schedule\n interval: 15m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
- `name: Scheduled custom\ntrigger:\n on: schedule\n cron: 0 */2 * * 1,3,5\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
- `name: Scheduled legacy interval\ntrigger:\n on: schedule\n interval: 2h30m\nsteps:\n - id: notify\n action: send_message\n text: hello\n`,
+ `name: Notify\ntrigger: { on: message_posted }\nsteps: [{ id: notify_1, action: send_message, text: hello }]\n`,
+ `name: React\ndescription: Reply to a reaction\nenabled: false\ntrigger: { on: reaction_added, emoji: eyes, filter: 'trigger_message_id == "abc123"' }\nsteps: [{ id: reply, name: Reply, timeout_secs: 30, action: send_message, text: hi, channel: channel-id, reply_in_thread: true }, { id: wait, action: delay, duration: 5m }]\n`,
];
test("accepted Form fixtures survive a semantic YAML round trip", () => {
@@ -74,10 +53,10 @@ test("accepted Form fixtures survive a semantic YAML round trip", () => {
test("recognized nodes with unknown fields are refused without touching YAML", () => {
const fixtures = [
- `name: Test\nunknown: true\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\nunknown: true\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted, future_filter: x }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
];
for (const yaml of fixtures) {
@@ -93,47 +72,47 @@ test("invalid IDs, shapes, and scalar types are refused", () => {
const cases = [
[
"missing ID",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ action: send_message, text: hi }]\n`,
],
[
"duplicate ID",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: same, action: send_message, text: hi }, { id: same, action: delay, duration: 5m }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: same, action: send_message, text: hi }, { id: same, action: delay, duration: 5m }]\n`,
],
[
"invalid ID",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: bad-id, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: bad-id, action: send_message, text: hi }]\n`,
],
[
"oversize ID",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: ${"a".repeat(65)}, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: ${"a".repeat(65)}, action: send_message, text: hi }]\n`,
],
[
"steps object",
- `name: Test\ntrigger: { on: webhook }\nsteps: { id: s1, action: send_message, text: hi }\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: { id: s1, action: send_message, text: hi }\n`,
],
[
"missing required action field",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message }]\n`,
],
[
"numeric text",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
],
[
"numeric header",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
],
[
"zero timeout",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
],
[
"fractional timeout",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
],
[
"unsupported method",
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
],
];
@@ -143,7 +122,7 @@ test("invalid IDs, shapes, and scalar types are refused", () => {
});
test("step condition capabilities stay in YAML mode", () => {
- const condition = `name: Conditional\ntrigger: { on: webhook }\nsteps: [{ id: s1, if: trigger_author == "abc", action: send_message, text: hi }]\n`;
+ const condition = `name: Conditional\ntrigger: { on: message_posted }\nsteps: [{ id: s1, if: trigger_author == "abc", action: send_message, text: hi }]\n`;
const conditionResult = yamlToFormState(condition);
assert.equal(conditionResult.ok, false);
@@ -168,22 +147,8 @@ test("malformed and unowned schedule definitions stay losslessly in YAML mode",
}
});
-test("the serializer emits only one schedule representation", () => {
- const yaml = formStateToYaml({
- name: "Exclusive",
- description: "",
- enabled: true,
- trigger: { on: "schedule", cron: "0 9 * * *", interval: "1h" },
- steps: [{ id: "s1", action: "send_message", text: "hi" }],
- });
- assert.deepEqual(parseYaml(yaml).trigger, {
- on: "schedule",
- cron: "0 9 * * *",
- });
-});
-
test("presents step timeout seconds as durations and serializes them numerically", () => {
- const yaml = `name: Timed\ntrigger: { on: webhook }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
+ const yaml = `name: Timed\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
const state = accepted(yaml);
assert.equal(state.steps[0].timeoutSecs, "1h 2s");
@@ -205,12 +170,12 @@ test("advanced message expressions survive unrelated Form serialization", () =>
test("values the Form serializer would normalize are refused", () => {
const fixtures = [
- `name: Test\ndescription: " spaced "\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Test\ndescription: ""\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ndescription: " spaced "\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
+ `name: Test\ndescription: ""\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: reaction_added, emoji: "" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
- `name: Test\ntrigger: { on: webhook }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
+ `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
];
for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false);
@@ -229,40 +194,6 @@ test("reply_in_thread is emitted only when the checkbox is on", () => {
assert.doesNotMatch(unset, /reply_in_thread/);
});
-test("switching from Message Posted clears reply_in_thread before save", () => {
- const messagePosted = sendMessageState({ replyInThread: true });
-
- for (const triggerType of ["schedule", "webhook"]) {
- const switched = withTriggerType(messagePosted, triggerType);
- assert.equal(switched.trigger.on, triggerType);
- assert.equal(switched.steps[0].replyInThread, false);
- assert.doesNotMatch(formStateToYaml(switched), /reply_in_thread/);
- }
-});
-
-test("an ineligible trigger cannot resurrect reply_in_thread through an action change", () => {
- // Full repro: Message Posted → Send Message → enable Reply → switch action to
- // Delay → switch trigger to an ineligible one → switch action back to Send
- // Message. The action picker changes only `action` (a plain spread, mirrored
- // here), so `withTriggerType` must clear the hidden flag on every step, not
- // just the ones whose current action is send_message.
- for (const triggerType of ["schedule", "webhook"]) {
- const enabled = sendMessageState({ replyInThread: true });
- const asDelay = {
- ...enabled,
- steps: [{ ...enabled.steps[0], action: "delay", duration: "5m" }],
- };
- const switched = withTriggerType(asDelay, triggerType);
- const backToSend = {
- ...switched,
- steps: [{ ...switched.steps[0], action: "send_message" }],
- };
-
- assert.equal(backToSend.steps[0].replyInThread, false, triggerType);
- assert.doesNotMatch(formStateToYaml(backToSend), /reply_in_thread/);
- }
-});
-
test("invalid reply_in_thread values are refused rather than normalized", () => {
const original = (yaml) => {
const result = yamlToFormState(yaml);
@@ -274,22 +205,8 @@ test("invalid reply_in_thread values are refused rather than normalized", () =>
// Non-boolean would be silently deleted on serialization.
const nonBoolean = `name: Coerced\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: "yes" }]\n`;
assert.match(original(nonBoolean).error, /reply_in_thread must be a boolean/);
-
- // true under an ineligible trigger would round-trip a backend-invalid definition.
- for (const trigger of ["schedule, cron: '0 9 * * *'", "webhook"]) {
- const yaml = `name: Ineligible\ntrigger: { on: ${trigger} }\nsteps: [{ id: s1, action: send_message, text: hi, reply_in_thread: true }]\n`;
- assert.match(
- original(yaml).error,
- /reply_in_thread is not supported for (schedule|webhook) triggers/,
- );
- }
});
-test("reply_in_thread eligibility follows trigger capability", () => {
- assert.equal(isThreadReplyEligibleTrigger("message_posted"), true);
- assert.equal(isThreadReplyEligibleTrigger("schedule"), false);
- assert.equal(isThreadReplyEligibleTrigger("webhook"), false);
-});
test("reply_in_thread round-trips YAML -> form -> YAML", () => {
const yaml = formStateToYaml(sendMessageState({ replyInThread: true }));
const parsed = yamlToFormState(yaml);
@@ -320,3 +237,26 @@ test("new workflow drafts start explicitly disabled", () => {
assert.equal(DEFAULT_FORM_STATE.enabled, false);
assert.equal(parseYaml(formStateToYaml(DEFAULT_FORM_STATE)).enabled, false);
});
+
+test("unsupported legacy triggers and actions remain YAML-only without parsing into form state", () => {
+ for (const trigger of ["diff_posted", "webhook", "schedule"]) {
+ const yaml = `# retained\nname: Advanced\ntrigger: { on: ${trigger}, cron: '0 9 * * *' }\nsteps: [{id: s1, action: send_message, text: hi}]\n`;
+ const result = yamlToFormState(yaml);
+ assert.equal(result.ok, false);
+ assert.match(result.error, /Unsupported trigger.*YAML editor/);
+ assert.match(yaml, /# retained/);
+ }
+ for (const action of [
+ "send_dm",
+ "call_webhook",
+ "request_approval",
+ "add_reaction",
+ "set_channel_topic",
+ ]) {
+ const result = yamlToFormState(
+ `name: Advanced\ntrigger: {on: message_posted}\nsteps: [{id: s1, action: ${action}}]\n`,
+ );
+ assert.equal(result.ok, false);
+ assert.match(result.error, /Unsupported action.*YAML editor/);
+ }
+});
diff --git a/src/bundled/workflows/workflowFormTypes.ts b/src/bundled/workflows/workflowFormTypes.ts
index 64385653..dda7bcab 100644
--- a/src/bundled/workflows/workflowFormTypes.ts
+++ b/src/bundled/workflows/workflowFormTypes.ts
@@ -1,86 +1,32 @@
// Adapted from block/buzz desktop workflow helpers at b9392d9d.
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
-import { cronExpressionError } from "./cronExpression";
import {
formatDurationSeconds,
parseDurationSeconds,
} from "./workflowDuration";
-export const TRIGGER_TYPES = [
- "message_posted",
- "reaction_added",
- "diff_posted",
- "webhook",
- "schedule",
-] as const;
+export const TRIGGER_TYPES = ["message_posted", "reaction_added"] as const;
export type TriggerType = (typeof TRIGGER_TYPES)[number];
-export function supportsMessageTextCondition(
- triggerType: TriggerType,
-): boolean {
- return triggerType === "message_posted" || triggerType === "diff_posted";
-}
-
-export const SELECTABLE_TRIGGER_TYPES = [
- "message_posted",
- "reaction_added",
- "diff_posted",
- "webhook",
- "schedule",
-] as const satisfies readonly TriggerType[];
-
-export const ACTION_TYPES = [
- "delay",
- "send_message",
- "send_dm",
- "call_webhook",
- "request_approval",
- "add_reaction",
- "set_channel_topic",
-] as const;
+export const ACTION_TYPES = ["delay", "send_message"] as const;
export type ActionType = (typeof ACTION_TYPES)[number];
-export const SELECTABLE_ACTION_TYPES = [
- "send_message",
- "delay",
- "call_webhook",
-] as const satisfies readonly ActionType[];
-
export type TriggerConfig = {
on: TriggerType;
filter?: string | undefined;
emoji?: string | undefined;
- cron?: string | undefined;
- interval?: string | undefined;
-};
-
-export type HeaderFormState = {
- id: string;
- key: string;
- value: string;
};
export type StepFormState = {
id: string;
name?: string | undefined;
action: ActionType;
- condition?: string | undefined;
timeoutSecs?: string | undefined;
duration?: string | undefined;
text?: string | undefined;
channel?: string | undefined;
replyInThread?: boolean | undefined;
- to?: string | undefined;
- url?: string | undefined;
- method?: string | undefined;
- headers?: HeaderFormState[] | undefined;
- body?: string | undefined;
- emoji?: string | undefined;
- topic?: string | undefined;
- from?: string | undefined;
- message?: string | undefined;
- timeout?: string | undefined;
};
export type WorkflowFormState = {
@@ -99,54 +45,11 @@ export const DEFAULT_FORM_STATE: WorkflowFormState = {
steps: [],
};
-export const TRIGGER_LABELS: Record = {
- message_posted: "Message Posted",
- reaction_added: "Reaction Added",
- diff_posted: "Diff Posted",
- webhook: "Webhook",
- schedule: "Schedule",
-};
-
export const ACTION_LABELS: Record = {
delay: "Delay",
send_message: "Send Message",
- send_dm: "Send DM",
- call_webhook: "Call Webhook",
- request_approval: "Request Approval",
- add_reaction: "Add Reaction",
- set_channel_topic: "Set Channel Topic",
};
-function toHeaderRows(
- headers: unknown,
- stepId: string,
-): HeaderFormState[] | undefined {
- if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
- return undefined;
- }
-
- const rows = Object.entries(headers).map(([key, value], index) => ({
- id: `${stepId}_header_${index + 1}`,
- key,
- value: typeof value === "string" ? value : String(value),
- }));
-
- return rows.length > 0 ? rows : undefined;
-}
-
-function headersToRecord(
- headers: HeaderFormState[] | undefined,
-): Record | undefined {
- if (!headers) return undefined;
-
- const entries = headers
- .map(({ key, value }) => [key.trim(), value] as const)
- .filter(([key]) => key.length > 0);
-
- if (entries.length === 0) return undefined;
- return Object.fromEntries(entries);
-}
-
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
if (!timeoutSecs) return undefined;
const parsed = parseDurationSeconds(timeoutSecs);
@@ -156,7 +59,6 @@ function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
function actionFieldsForStep(step: StepFormState): Record {
const fields: Record = {};
if (step.name?.trim()) fields.name = step.name.trim();
- if (step.condition?.trim()) fields.if = step.condition.trim();
const timeoutSecs = parseTimeoutSecs(step.timeoutSecs);
if (timeoutSecs !== undefined) fields.timeout_secs = timeoutSecs;
@@ -169,76 +71,16 @@ function actionFieldsForStep(step: StepFormState): Record {
if (step.channel) fields.channel = step.channel;
if (step.replyInThread) fields.reply_in_thread = true;
break;
- case "send_dm":
- if (step.to) fields.to = step.to;
- if (step.text) fields.text = step.text;
- break;
- case "call_webhook":
- if (step.url) fields.url = step.url;
- fields.method = step.method || "POST";
- {
- const headers = headersToRecord(step.headers);
- if (headers) fields.headers = headers;
- }
- if (step.body) fields.body = step.body;
- break;
- case "request_approval":
- if (step.from) fields.from = step.from;
- if (step.message) fields.message = step.message;
- if (step.timeout) fields.timeout = step.timeout;
- break;
- case "add_reaction":
- if (step.emoji) fields.emoji = step.emoji;
- break;
- case "set_channel_topic":
- if (step.topic) fields.topic = step.topic;
- break;
}
return fields;
}
-export function isThreadReplyEligibleTrigger(trigger: TriggerType): boolean {
- return trigger !== "webhook" && trigger !== "schedule";
-}
-
-export function withTriggerType(
- state: WorkflowFormState,
- triggerType: TriggerType,
-): WorkflowFormState {
- return {
- ...state,
- trigger: { on: triggerType },
- // Clear threaded-reply state on every step, not just send_message ones:
- // a hidden `replyInThread` on a step whose action was changed away from
- // send_message would otherwise resurrect when the action is switched back.
- steps: isThreadReplyEligibleTrigger(triggerType)
- ? state.steps
- : state.steps.map((step) =>
- step.replyInThread ? { ...step, replyInThread: false } : step,
- ),
- };
-}
-
export function formStateToYaml(state: WorkflowFormState): string {
const trigger: Record = { on: state.trigger.on };
- if (
- (state.trigger.on === "message_posted" ||
- state.trigger.on === "diff_posted" ||
- state.trigger.on === "reaction_added") &&
- state.trigger.filter
- ) {
- trigger.filter = state.trigger.filter;
- }
+ if (state.trigger.filter) trigger.filter = state.trigger.filter;
if (state.trigger.on === "reaction_added" && state.trigger.emoji) {
trigger.emoji = state.trigger.emoji;
}
- if (state.trigger.on === "schedule") {
- if (state.trigger.cron) {
- trigger.cron = state.trigger.cron;
- } else if (state.trigger.interval) {
- trigger.interval = state.trigger.interval;
- }
- }
const steps = state.steps.map((step) => ({
id: step.id,
@@ -286,9 +128,6 @@ const TOP_LEVEL_KEYS = new Set([
const TRIGGER_KEYS: Record> = {
message_posted: new Set(["on", "filter"]),
reaction_added: new Set(["on", "emoji", "filter"]),
- diff_posted: new Set(["on", "filter"]),
- webhook: new Set(["on"]),
- schedule: new Set(["on", "cron", "interval"]),
};
const COMMON_STEP_KEYS = ["id", "name", "action", "if", "timeout_secs"];
const ACTION_STEP_KEYS: Record> = {
@@ -299,42 +138,15 @@ const ACTION_STEP_KEYS: Record> = {
"channel",
"reply_in_thread",
]),
- send_dm: new Set([...COMMON_STEP_KEYS, "to", "text"]),
- call_webhook: new Set([
- ...COMMON_STEP_KEYS,
- "url",
- "method",
- "headers",
- "body",
- ]),
- request_approval: new Set([
- ...COMMON_STEP_KEYS,
- "from",
- "message",
- "timeout",
- ]),
- add_reaction: new Set([...COMMON_STEP_KEYS, "emoji"]),
- set_channel_topic: new Set([...COMMON_STEP_KEYS, "topic"]),
};
const REQUIRED_ACTION_STRING_KEYS: Record = {
delay: ["duration"],
send_message: ["text"],
- send_dm: ["to", "text"],
- call_webhook: ["url"],
- request_approval: ["from", "message"],
- add_reaction: ["emoji"],
- set_channel_topic: ["topic"],
};
const OPTIONAL_ACTION_STRING_KEYS: Record = {
delay: [],
send_message: ["channel"],
- send_dm: [],
- call_webhook: ["method", "body"],
- request_approval: ["timeout"],
- add_reaction: [],
- set_channel_topic: [],
};
-const WEBHOOK_METHODS = new Set(["POST", "GET", "PUT", "PATCH", "DELETE"]);
const STEP_ID_PATTERN_STRICT = /^[A-Za-z0-9_]{1,64}$/;
type UnknownRecord = Record;
@@ -432,45 +244,14 @@ export function yamlToFormState(
error: `Unsupported ${triggerOn} trigger field "${triggerUnknown}" — use the YAML editor`,
};
}
- for (const key of ["filter", "emoji", "cron", "interval"] as const) {
+ for (const key of ["filter", "emoji"] as const) {
const error = optionalOwnedStringError(rawTrigger, key, `trigger.${key}`);
- if (error) {
- return {
- ok: false,
- error:
- triggerOn === "schedule" && !error.includes("YAML editor")
- ? `${error} — use the YAML editor`
- : error,
- };
- }
- }
- if (triggerOn === "schedule") {
- const hasCron = rawTrigger.cron !== undefined;
- const hasInterval = rawTrigger.interval !== undefined;
- if (hasCron === hasInterval) {
- return {
- ok: false,
- error: hasCron
- ? "Schedule triggers cannot specify both cron and interval — use the YAML editor"
- : "Schedule triggers require either cron or interval — use the YAML editor",
- };
- }
- if (typeof rawTrigger.cron === "string") {
- const error = cronExpressionError(rawTrigger.cron);
- if (error) {
- return {
- ok: false,
- error: `Unsupported cron expression: ${error} Use the YAML editor`,
- };
- }
- }
+ if (error) return { ok: false, error };
}
const trigger: TriggerConfig = {
on: triggerOn,
filter: rawTrigger.filter as string | undefined,
emoji: rawTrigger.emoji as string | undefined,
- cron: rawTrigger.cron as string | undefined,
- interval: rawTrigger.interval as string | undefined,
};
if (!Array.isArray(parsed.steps)) {
@@ -563,41 +344,6 @@ export function yamlToFormState(
);
if (error) return { ok: false, error };
}
- if (
- action === "call_webhook" &&
- step.method !== undefined &&
- !WEBHOOK_METHODS.has(step.method as string)
- ) {
- return {
- ok: false,
- error: `Unsupported webhook method "${String(step.method)}" — use the YAML editor`,
- };
- }
- if (step.headers !== undefined) {
- const headers = objectRecord(step.headers);
- if (
- !headers ||
- Object.keys(headers).length === 0 ||
- Object.values(headers).some((header) => typeof header !== "string")
- ) {
- return {
- ok: false,
- error:
- "Webhook headers must be a non-empty object containing string values",
- };
- }
- const unsafeHeader = Object.keys(headers).find(
- (key) => key.length === 0 || key.trim() !== key,
- );
- if (unsafeHeader !== undefined) {
- return {
- ok: false,
- error:
- "Webhook header names cannot be empty or have surrounding whitespace in Form mode",
- };
- }
- }
-
if (step.reply_in_thread !== undefined) {
if (typeof step.reply_in_thread !== "boolean") {
return {
@@ -605,12 +351,6 @@ export function yamlToFormState(
error: `Step ${number} reply_in_thread must be a boolean — use the YAML editor`,
};
}
- if (step.reply_in_thread && !isThreadReplyEligibleTrigger(triggerOn)) {
- return {
- ok: false,
- error: `reply_in_thread is not supported for ${triggerOn} triggers — use the YAML editor`,
- };
- }
}
steps.push({
@@ -625,16 +365,6 @@ export function yamlToFormState(
text: step.text as string | undefined,
channel: step.channel as string | undefined,
replyInThread: step.reply_in_thread === true,
- to: step.to as string | undefined,
- url: step.url as string | undefined,
- method: step.method as string | undefined,
- headers: toHeaderRows(step.headers, step.id),
- body: step.body as string | undefined,
- emoji: step.emoji as string | undefined,
- topic: step.topic as string | undefined,
- from: step.from as string | undefined,
- message: step.message as string | undefined,
- timeout: step.timeout as string | undefined,
});
}
diff --git a/src/bundled/workflows/workflowYamlDocument.test.mjs b/src/bundled/workflows/workflowYamlDocument.test.mjs
index ce1fc9eb..f92db5ac 100644
--- a/src/bundled/workflows/workflowYamlDocument.test.mjs
+++ b/src/bundled/workflows/workflowYamlDocument.test.mjs
@@ -3,25 +3,10 @@ import { test } from "vitest";
import {
readWorkflowDocumentFields,
- readWorkflowHeaderState,
yamlWithWorkflowEnabled,
yamlWithWorkflowName,
} from "./workflowYamlDocument.ts";
-/** The name the dialog header would render for `yaml`. */
-function headerName(yaml, fallbackName) {
- return readWorkflowHeaderState(yaml, { enabled: true, name: fallbackName })
- .name;
-}
-
-/** The enabled state the dialog header would render for `yaml`. */
-function headerEnabled(yaml, fallbackEnabled) {
- return readWorkflowHeaderState(yaml, {
- enabled: fallbackEnabled,
- name: undefined,
- }).enabled;
-}
-
// A step that has just been added from the builder carries no message text yet,
// so the definition fails full form validation while the user is still on the
// step pane. The header must keep working against that document.
@@ -40,7 +25,7 @@ test("reads the name of a definition whose steps are still incomplete", () => {
name: "mock-horse-battery",
});
assert.equal(
- headerName(INCOMPLETE_STEP_YAML, undefined),
+ readWorkflowDocumentFields(INCOMPLETE_STEP_YAML).name,
"mock-horse-battery",
);
});
@@ -56,24 +41,7 @@ test("treats an empty definition as an editable blank name", () => {
enabled: null,
name: null,
});
- assert.equal(headerName("", undefined), "");
-});
-
-test("falls back to the saved name only when the document has none", () => {
- assert.equal(headerName("", "Saved name"), "Saved name");
- assert.equal(
- headerName("name: ''\ntrigger:\n on: webhook\n", "Saved name"),
- "Saved name",
- );
- assert.equal(
- headerName(INCOMPLETE_STEP_YAML, "Saved name"),
- "mock-horse-battery",
- );
-});
-
-test("trims the rendered name and the saved fallback", () => {
- assert.equal(headerName('name: " spaced "\n', undefined), "spaced");
- assert.equal(headerName("", " saved "), "saved");
+ assert.equal(readWorkflowDocumentFields("").name, null);
});
test("marks unparseable or non-map documents as uneditable", () => {
@@ -93,60 +61,60 @@ test("marks unparseable or non-map documents as uneditable", () => {
test("ignores a non-string name rather than rendering it", () => {
assert.equal(readWorkflowDocumentFields("name: 42\n").name, null);
- assert.equal(headerName("name: 42\n", "Saved name"), "Saved name");
});
-test("keeps the header editable while a step is still incomplete", () => {
- assert.deepEqual(
- readWorkflowHeaderState(INCOMPLETE_STEP_YAML, {
- enabled: true,
- name: "Saved name",
- }),
- { canEdit: true, enabled: true, name: "mock-horse-battery" },
- );
+test("enabled is explicit or absent; incomplete steps do not block header edits", () => {
+ assert.equal(readWorkflowDocumentFields(INCOMPLETE_STEP_YAML).editable, true);
+ assert.equal(readWorkflowDocumentFields(INCOMPLETE_STEP_YAML).enabled, null);
assert.equal(
- readWorkflowHeaderState("name: [unclosed\n", {
- enabled: false,
- name: "Saved name",
- }).canEdit,
+ readWorkflowDocumentFields(`enabled: false\n${INCOMPLETE_STEP_YAML}`)
+ .enabled,
false,
);
});
-test("derives enabled from the document, defaulting to enabled", () => {
- assert.equal(headerEnabled(INCOMPLETE_STEP_YAML, false), true);
- assert.equal(
- headerEnabled(`enabled: false\n${INCOMPLETE_STEP_YAML}`, true),
- false,
- );
- // Only an unreadable document may fall back to the saved value.
- assert.equal(headerEnabled("- just\n- a list\n", false), false);
- assert.equal(headerEnabled("- just\n- a list\n", true), true);
-});
-
test("writes the name back without disturbing the rest of the document", () => {
const next = yamlWithWorkflowName(INCOMPLETE_STEP_YAML, "renamed");
- assert.equal(headerName(next, undefined), "renamed");
+ assert.equal(readWorkflowDocumentFields(next).name, "renamed");
assert.match(next, /action: send_message/);
assert.match(next, /on: message_posted/);
});
test("seeds a definition when naming an empty document", () => {
const next = yamlWithWorkflowName("", "fresh");
- assert.equal(headerName(next, undefined), "fresh");
+ assert.equal(readWorkflowDocumentFields(next).name, "fresh");
assert.match(next, /trigger:/);
});
test("adds and removes the enabled key without touching the name", () => {
const disabled = yamlWithWorkflowEnabled(INCOMPLETE_STEP_YAML, false);
assert.match(disabled, /enabled: false/);
- assert.equal(headerName(disabled, undefined), "mock-horse-battery");
+ assert.equal(readWorkflowDocumentFields(disabled).name, "mock-horse-battery");
const reEnabled = yamlWithWorkflowEnabled(disabled, true);
assert.doesNotMatch(reEnabled, /enabled:/);
- assert.equal(headerName(reEnabled, undefined), "mock-horse-battery");
+ assert.equal(
+ readWorkflowDocumentFields(reEnabled).name,
+ "mock-horse-battery",
+ );
});
test("naming a new document cannot activate it", () => {
- assert.equal(headerEnabled(yamlWithWorkflowName("", "Fresh"), true), false);
+ assert.equal(
+ readWorkflowDocumentFields(yamlWithWorkflowName("", "Fresh")).enabled,
+ false,
+ );
+});
+
+test("header edits preserve advanced YAML and comments without form ownership", () => {
+ const yaml =
+ "# keep\nname: Advanced\ntrigger: {on: schedule, cron: '0 9 * * *'}\nsteps: [{id: call, action: call_webhook, url: 'https://example.com', headers: {X-Custom: keep}}]\nfuture: untouched\n";
+ const renamed = yamlWithWorkflowName(yaml, "Renamed");
+ const disabled = yamlWithWorkflowEnabled(renamed, false);
+ for (const source of [renamed, disabled]) {
+ assert.match(source, /# keep/);
+ assert.match(source, /X-Custom: keep/);
+ assert.match(source, /future: untouched/);
+ assert.match(source, /on: schedule/);
+ }
});
diff --git a/src/bundled/workflows/workflowYamlDocument.ts b/src/bundled/workflows/workflowYamlDocument.ts
index 58c12061..28537132 100644
--- a/src/bundled/workflows/workflowYamlDocument.ts
+++ b/src/bundled/workflows/workflowYamlDocument.ts
@@ -56,30 +56,6 @@ export function readWorkflowDocumentFields(
};
}
-/** What the dialog header renders for a definition, in a single parse. */
-export type WorkflowHeaderState = {
- /** Whether the name can be edited and the enabled toggle can be written. */
- canEdit: boolean;
- enabled: boolean;
- name: string;
-};
-
-/**
- * Derives the header presentation from the working definition, falling back to
- * the saved workflow only for what the document itself does not supply.
- */
-export function readWorkflowHeaderState(
- yaml: string,
- fallback: { enabled: boolean; name: string | undefined },
-): WorkflowHeaderState {
- const fields = readWorkflowDocumentFields(yaml);
- return {
- canEdit: fields.editable,
- enabled: fields.editable ? fields.enabled !== false : fallback.enabled,
- name: fields.name?.trim() || (fallback.name?.trim() ?? ""),
- };
-}
-
/** Writes `name` into the definition, preserving unrelated YAML nodes (not byte-exact formatting). */
export function yamlWithWorkflowName(
yaml: string,
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index fc7a51c4..c0f3622c 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
-import { createServer } from "vite";
+import { createServer } from "../../../tests/browser/vite-server.mjs";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
@@ -240,6 +240,14 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a
await expect(button("Save workflow")).toBeEnabled();
await expect(enabled).toBeChecked();
await expect(reply).toBeChecked();
+ await page
+ .getByLabel("Message text", { exact: true })
+ .fill("Ordinary enabled edit");
+ await button("Save workflow").click();
+ await expect.poll(saves).toBe(2);
+ await expect(dialog).toHaveCount(0);
+ await page.evaluate(() => window.workflowFixture.finish("succeeded"));
+ await expect(button("Save workflow")).toBeEnabled();
await name.focus();
await page.keyboard.press(tab);
await expect(enabled).toBeFocused();
@@ -250,7 +258,7 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a
await expect(reply).not.toBeChecked();
await focusByTab(button("Save workflow"));
await page.keyboard.press("Enter");
- await expect.poll(saves).toBe(2);
+ await expect.poll(saves).toBe(3);
await expect(dialog).toHaveCount(0);
expect((await savedYaml()).enabled).toBe(false);
expect((await savedYaml()).steps[0].reply_in_thread).not.toBe(true);
@@ -258,10 +266,15 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a
await expect(enabled).toBeEnabled();
await expect(enabled).not.toBeChecked();
await expect(reply).not.toBeChecked();
+ await enabled.click();
+ await button("Save workflow").click();
+ await expect(dialog).toContainText("It will run for every new message");
+ expect(await saves()).toBe(3);
+ await page.keyboard.press("Escape");
expect(errors).toEqual([]);
});
-test("history reads are lazy, paged by exact cursor and released; unknown operations never get a replacement ID", async ({
+test("history stays lazy and paged; acknowledging an unknown run never repeats it", async ({
page,
}) => {
await page.goto(url);
@@ -270,19 +283,6 @@ test("history reads are lazy, paged by exact cursor and released; unknown operat
expect(await page.evaluate(() => window.workflowFixture.calls.runs)).toBe(0);
await button("Read runs").click();
await expect(page.getByText("Current step: 1")).toBeVisible();
- expect(
- await page.evaluate(() => window.workflowFixture.calls.approvals),
- ).toBe(0);
- await button("Read approvals").click();
- await expect(
- page.getByText("notify: granted — Fixture decision"),
- ).toBeVisible();
- await button("Hide approvals").click();
- expect(
- await page.evaluate(() =>
- window.workflowFixture.approvalViews.every((view) => view.disposed()),
- ),
- ).toBe(true);
await button("Older runs").click();
await expect(page.getByText("No runs returned on this page.")).toBeVisible();
expect(await page.evaluate(() => window.workflowFixture.runCursor())).toEqual(
@@ -307,14 +307,11 @@ test("history reads are lazy, paged by exact cursor and released; unknown operat
await button("Run now").click();
await button("Unknown operation").click();
await expect(button("Run now")).toBeDisabled();
+ await expect(page.getByText(/The run may have started/)).toBeVisible();
const id = await page.evaluate(
() =>
window.workflowFixture.capability.operations.snapshot().at(-1).eventId,
);
- await button("Retry same signed operation").click();
- expect(await page.evaluate(() => window.workflowFixture.calls.retry)).toEqual(
- [id],
- );
await button("Close editor").click();
await button("Message helper").click();
await expect(button("Run now")).toBeDisabled();
@@ -322,6 +319,19 @@ test("history reads are lazy, paged by exact cursor and released; unknown operat
expect(await page.evaluate(() => window.workflowFixture.calls.trigger)).toBe(
1,
);
+ await button("Dismiss notice").click();
+ await expect(page.getByRole("alertdialog")).toContainText(
+ "does not undo, cancel or repeat",
+ );
+ await button("Dismiss notice and continue").click();
+ await expect(button("Run now")).toBeEnabled();
+ await expect(button("Save workflow")).toBeEnabled();
+ expect(
+ await page.evaluate(() => window.workflowFixture.calls.dismiss),
+ ).toEqual([id]);
+ expect(await page.evaluate(() => window.workflowFixture.calls.trigger)).toBe(
+ 1,
+ );
});
test("real session page under StrictMode fences community changes, warns for dirty channel navigation and purges access", async ({
@@ -394,3 +404,183 @@ test("real session page under StrictMode fences community changes, warns for dir
);
expect(errors).toEqual([]);
});
+
+test("a lost save response can be checked and adopted without resubmitting", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page
+ .getByLabel("Workflow name", { exact: true })
+ .fill("Saved without response");
+ await button("Save workflow").click();
+ await page.evaluate(() => {
+ window.workflowFixture.saveOnServer();
+ window.workflowFixture.finish("unknown");
+ });
+ await expect(button("Save workflow")).toBeDisabled();
+ await button("Check saved configuration").click();
+ await expect(button("Save workflow")).toBeEnabled();
+ await expect(page.getByLabel("Workflow name", { exact: true })).toHaveValue(
+ "Saved without response",
+ );
+ expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1);
+ await page
+ .getByLabel("Message text", { exact: true })
+ .fill("Edit after recovery");
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(2);
+});
+
+test("different-head recovery needs explicit review; failed dismissal keeps the draft locked", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page
+ .getByLabel("Workflow name", { exact: true })
+ .fill("Retained local draft");
+ await button("Save workflow").click();
+ await page.evaluate(() => {
+ window.workflowFixture.saveOnServer(false);
+ window.workflowFixture.finish("unknown");
+ });
+ await button("Check saved configuration").click();
+ await expect(button("Save workflow")).toBeDisabled();
+ await expect(button("Review current configuration")).toBeVisible();
+ await button("Dismiss notice").click();
+ await page.keyboard.press("Escape");
+ await expect(button("Save workflow")).toBeDisabled();
+ expect(
+ await page.evaluate(() => window.workflowFixture.calls.dismiss),
+ ).toEqual([]);
+ await page.evaluate(() =>
+ window.workflowFixture.setDismissError("Fixture dismissal failed"),
+ );
+ await button("Dismiss notice").click();
+ await button("Dismiss notice and continue").click();
+ await expect(page.getByRole("alert")).toHaveText("Fixture dismissal failed");
+ await page.keyboard.press("Escape");
+ await expect(button("Save workflow")).toBeDisabled();
+ await page.evaluate(() => window.workflowFixture.setDismissError());
+ await button("Dismiss notice").click();
+ await button("Dismiss notice and continue").click();
+ await expect(button("Save workflow")).toBeEnabled();
+ await expect(page.getByLabel("Workflow name", { exact: true })).toHaveValue(
+ "Retained local draft",
+ );
+ expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1);
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(2);
+});
+
+test("optimistic dismissal keeps confirmation mounted until persistence settles", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page.getByLabel("Workflow name", { exact: true }).fill("Kept draft");
+ await button("Save workflow").click();
+ await page.evaluate(() => window.workflowFixture.finish("unknown"));
+ const operationId = await page.evaluate(
+ () => window.workflowFixture.capability.operations.snapshot()[0].eventId,
+ );
+ const dialog = page.getByRole("alertdialog", {
+ name: "Dismiss this notice?",
+ });
+ for (const fail of [true, false]) {
+ await page.evaluate((fail) => {
+ window.workflowFixture.setDismissError(
+ fail ? "Journal unavailable" : undefined,
+ );
+ window.workflowFixture.holdDismiss();
+ }, fail);
+ await button("Dismiss notice").click();
+ try {
+ await button("Dismiss notice and continue").click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ window.workflowFixture.capability.operations.snapshot().length,
+ ),
+ )
+ .toBe(0);
+ await expect(dialog).toBeVisible();
+ await expect(button("Dismissing…")).toBeDisabled();
+ await expect(button("Keep editing")).toBeDisabled();
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeVisible();
+ // The modal must keep navigation/submission inaccessible during the gap.
+ await expect(button("Close editor")).toHaveCount(0);
+ await expect(button("New workflow")).toHaveCount(0);
+ expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(
+ 1,
+ );
+ } finally {
+ await page.evaluate(() => window.workflowFixture.releaseDismiss());
+ }
+ if (fail) {
+ await expect(dialog.getByRole("alert")).toHaveText("Journal unavailable");
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ window.workflowFixture.capability.operations
+ .snapshot()
+ .map((op) => op.eventId),
+ ),
+ )
+ .toEqual([operationId]);
+ await page.keyboard.press("Escape");
+ await expect(button("Save workflow")).toBeDisabled();
+ await expect(
+ page.getByLabel("Workflow name", { exact: true }),
+ ).toHaveValue("Kept draft");
+ } else {
+ await expect(dialog).toHaveCount(0);
+ await expect(button("Save workflow")).toBeEnabled();
+ await expect(
+ page.getByLabel("Workflow name", { exact: true }),
+ ).toHaveValue("Kept draft");
+ }
+ }
+ expect(
+ await page.evaluate(() => window.workflowFixture.calls.dismiss),
+ ).toEqual([operationId, operationId]);
+ expect(await page.evaluate(() => window.workflowFixture.calls.save)).toBe(1);
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(2);
+});
+
+test("legacy deletion is a request, not verified runtime removal", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await button("Delete workflow").click();
+ await expect(page.getByRole("alertdialog")).toContainText(
+ "does not confirm runtime deletion",
+ );
+ await button("Request deletion").click();
+ await page.evaluate(() => window.workflowFixture.finish("succeeded"));
+ await expect(
+ page.getByText(/Deletion request accepted\. The saved configuration/),
+ ).toBeVisible();
+ await expect(button("Message helper")).toBeVisible();
+ await button("Dismiss notice").click();
+ await button("Dismiss notice and continue").click();
+ await expect(button("Save workflow")).toBeEnabled();
+ expect(await page.evaluate(() => window.workflowFixture.calls.delete)).toBe(
+ 1,
+ );
+});
diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts
index a463a10e..3401286c 100644
--- a/src/features/relay/outbox-receipts.test.ts
+++ b/src/features/relay/outbox-receipts.test.ts
@@ -187,29 +187,21 @@ it("bounds receipt bytes while streaming before JSON decoding", async () => {
);
});
-it("seen commands can retry the exact signed event and dismiss without losing observation evidence", async () => {
+it("seen commands can dismiss retained receipts without another publication", async () => {
const h = setup();
const id = h.send();
await flush();
h.observe([h.published()]);
h.reject(new Error("lost"));
await flush();
- const original = h.published();
expect(h.outbox.snapshot()).toEqual([]);
- h.outbox.retry(id);
- await flush();
- expect(h.sign).toHaveBeenCalledTimes(1);
- expect(h.publish).toHaveBeenCalledTimes(2);
- expect(h.published()).toEqual(original);
- h.reject(new PublishRejected("response:{secret:PRIVATE}"));
- await flush();
expect(h.local.snapshot()[0]?.delivery).toBe("seen");
- expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
await h.outbox.dismiss(id);
expect(h.local.snapshot()).toEqual([]);
expect(h.saved()).toEqual([]);
+ expect(h.publish).toHaveBeenCalledTimes(1);
});
-it("rejection text never journals command secrets; successful seen retry stays seen", async () => {
+it("rejection text never journals command secrets; successful retry stays seen", async () => {
const h = setup();
const id = h.send();
await flush();
@@ -222,10 +214,6 @@ it("rejection text never journals command secrets; successful seen retry stays s
h.observe([h.published()]);
h.settle("response:{}");
await flush();
- h.outbox.retry(id);
- await flush();
- h.settle("duplicate:");
- await flush();
expect(h.local.snapshot()[0]?.delivery).toBe("seen");
expect(h.outbox.snapshot()).toEqual([]);
});
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 90a40b50..de30817b 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -449,17 +449,8 @@ export function createOutbox(
return event.id;
},
retry(id: string) {
- const retained = completed.peek(id);
- const item =
- find(id) ??
- (retained && awaitsReceipt(retained.event) ? retained : undefined);
+ const item = find(id);
if (!closed && item && !attempts.has(id)) {
- if (!find(id)) {
- if (snapshot.length >= MAX_PENDING)
- throw new Error("Too many outstanding operations");
- completed.delete(id);
- snapshot = Object.freeze([...snapshot, item]);
- }
replace({ ...item, delivery: "sending", error: undefined });
schedule(id, undefined, item.delivery);
}
diff --git a/src/features/relay/signed-admission.test.ts b/src/features/relay/signed-admission.test.ts
index ab9d25d5..eff9c064 100644
--- a/src/features/relay/signed-admission.test.ts
+++ b/src/features/relay/signed-admission.test.ts
@@ -149,9 +149,3 @@ it("explicit quota rejection is retryable; missing response stays unknown with n
await vi.advanceTimersByTimeAsync(500);
expect(fetcher).toHaveBeenCalledTimes(2);
});
-
-// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
-vi.mock("../workflows/compatibility", async (original) => ({
- ...(await original()),
- discoverWorkflowLifecycle: async () => undefined,
-}));
diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts
index 68299ed2..5eb8633f 100644
--- a/src/features/relay/signed-boundary.test.ts
+++ b/src/features/relay/signed-boundary.test.ts
@@ -294,9 +294,3 @@ it("async auth ownership is bounded across signed constructor recreation", async
await next;
expect(fetcher).toHaveBeenCalledTimes(1);
});
-
-// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
-vi.mock("../workflows/compatibility", async (original) => ({
- ...(await original()),
- discoverWorkflowLifecycle: async () => undefined,
-}));
diff --git a/src/features/relay/signed-priority.test.ts b/src/features/relay/signed-priority.test.ts
index 1cd5f749..8d9ca13a 100644
--- a/src/features/relay/signed-priority.test.ts
+++ b/src/features/relay/signed-priority.test.ts
@@ -61,9 +61,3 @@ it("production reader priority reaches actual signed fetch admission", async ()
r.dispose();
}
});
-
-// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
-vi.mock("../workflows/compatibility", async (original) => ({
- ...(await original()),
- discoverWorkflowLifecycle: async () => undefined,
-}));
diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts
index d1b1571e..d4e54f3e 100644
--- a/src/features/relay/transport.test.ts
+++ b/src/features/relay/transport.test.ts
@@ -184,9 +184,3 @@ it("uses each signed transport's own origin for protected media, never a deploym
);
expect(a.media("http://images.example/insecure.png")).toBeUndefined();
});
-
-// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
-vi.mock("../workflows/compatibility", async (original) => ({
- ...(await original()),
- discoverWorkflowLifecycle: async () => undefined,
-}));
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index 05db2bb8..9d561c17 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -1,12 +1,4 @@
-import {
- isWorkflowOperation,
- validateWorkflowEvent,
-} from "../workflows/protocol";
-import {
- discoverWorkflowLifecycle,
- workflowLifecycleVersion,
-} from "../workflows/compatibility";
-import { workflowHost, workflowReadPath } from "../workflows/http";
+import { workflowHost } from "../workflows/http";
import type { WorkflowHost } from "../workflows/host";
import { readReceiptText } from "./receipt";
import type { ReadStateHost, ReadStateSigning } from "./read-state-host";
@@ -160,7 +152,6 @@ export async function connectBrokerTransport(
archiveAuthority?: unknown;
writeKinds?: number[];
workflowReads?: boolean;
- workflowInfo?: unknown;
relayUrl?: string;
live?: boolean;
sidebarPreferences?: boolean;
@@ -198,20 +189,14 @@ export async function connectBrokerTransport(
: {}),
...(session.workflowReads === true
? {
- workflows: workflowHost(
- (route, body, signal) =>
- fetch(`${endpoint}/${route}`, {
- method: "POST",
- credentials: "same-origin",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- signal,
- }),
- workflowLifecycleVersion(
- session.workflowInfo,
- session.relayUrl ?? "",
- session.relayAuthor,
- ),
+ workflows: workflowHost((route, body, signal) =>
+ fetch(`${endpoint}/${route}`, {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal,
+ }),
),
}
: {}),
@@ -334,15 +319,7 @@ export async function connectBrokerTransport(
const result = await fetch(`${endpoint}/sign`, {
method: "POST",
credentials: "same-origin",
- headers: {
- "Content-Type": "application/json",
- ...(template.kind !== 9
- ? {
- "X-Buzz-Workflow-Authority":
- session.relayAuthor as string,
- }
- : {}),
- },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(template),
signal,
});
@@ -356,15 +333,7 @@ export async function connectBrokerTransport(
const result = await fetch(`${endpoint}/publish`, {
method: "POST",
credentials: "same-origin",
- headers: {
- "Content-Type": "application/json",
- ...(event.kind !== 9
- ? {
- "X-Buzz-Workflow-Authority":
- session.relayAuthor as string,
- }
- : {}),
- },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
signal,
});
@@ -423,27 +392,8 @@ export async function connectSignedTransport(
): Promise {
const viewer = await signer.getPublicKey();
httpOrigin = relayOrigin(httpOrigin);
- const lifecycleVersion = await discoverWorkflowLifecycle(
- httpOrigin,
- relayAuthor,
- );
const principal = () => signedAdmissions(httpOrigin, viewer);
const profiling = createRelayProfiler();
- async function checkWorkflow(event: EventTemplate, signal: AbortSignal) {
- signal.throwIfAborted();
- if (!isWorkflowOperation(event)) return;
- if (
- (await discoverWorkflowLifecycle(httpOrigin, relayAuthor, signal)) !== 1
- )
- throw new PublishRejected(
- "Reliable workflow writes are unavailable on this relay",
- );
- signal.throwIfAborted();
- validateWorkflowEvent({ ...event, id: "", pubkey: viewer }, viewer, {
- delete: true,
- webhookSecrets: false,
- });
- }
return {
profiling,
subscribe: (callbacks) => {
@@ -474,38 +424,15 @@ export async function connectSignedTransport(
},
};
},
- workflows: workflowHost(
- (route, body, signal) =>
- signedRequest(
- signer,
- `${httpOrigin}${workflowReadPath(route, body)}`,
- undefined,
- signal,
- profiling,
- route,
- principal().api,
- "foreground",
- "GET",
- ),
- lifecycleVersion,
- ),
scope: httpOrigin,
viewer,
relayAuthor,
media: (url) => mediaUrl(url, undefined, httpOrigin),
writer: {
- async sign(event, signal) {
- await checkWorkflow(event, signal);
- return signer.signEvent(event);
- },
+ sign: (event) => signer.signEvent(event),
async publish(event, signal) {
- await checkWorkflow(event, signal);
- if (isWorkflowOperation(event) && event.pubkey !== viewer)
- throw new PublishRejected(
- "Workflow signer does not match this session",
- );
- return acceptPublish(
- await signedRequest(
+ await acceptPublish(
+ await signedPost(
signer,
`${httpOrigin}/events`,
event,
@@ -523,7 +450,7 @@ export async function connectSignedTransport(
},
},
async query(filters, signal, requestId = "read", priority = "foreground") {
- const result = await signedRequest(
+ const result = await signedPost(
signer,
`${httpOrigin}/query`,
filters,
@@ -552,7 +479,7 @@ export async function connectSignedTransport(
};
}
-async function signedRequest(
+async function signedPost(
signer: Signer,
url: string,
value: unknown,
@@ -561,20 +488,13 @@ async function signedRequest(
id: string,
admission: Parameters[0],
priority: "foreground" | "background" = "foreground",
- method: "POST" | "GET" = "POST",
) {
signal?.throwIfAborted();
return admission.prepare(async () => {
- const body = method === "POST" ? JSON.stringify(value) : undefined;
- const payload =
- body === undefined
- ? undefined
- : hex(
- await crypto.subtle.digest(
- "SHA-256",
- new TextEncoder().encode(body),
- ),
- );
+ const body = JSON.stringify(value);
+ const payload = hex(
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
+ );
if (signal?.aborted) throw signal.reason;
const auth = await profiling.measureAsync("http.auth", id, () =>
signer.signEvent({
@@ -583,8 +503,8 @@ async function signedRequest(
content: "",
tags: [
["u", url],
- ["method", method],
- ...(payload === undefined ? [] : [["payload", payload]]),
+ ["method", "POST"],
+ ["payload", payload],
["nonce", crypto.randomUUID()],
],
}),
@@ -605,13 +525,12 @@ async function signedRequest(
);
return profiling.measureAsync("http.fetch", id, () =>
fetch(url, {
- method,
- redirect: "error",
+ method: "POST",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(auth))}`,
"Content-Type": "application/json",
},
- ...(body === undefined ? {} : { body }),
+ body,
signal: signal ?? null,
}),
);
diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts
index ab3a317e..b3c4f2a5 100644
--- a/src/features/workflows/capability.test.ts
+++ b/src/features/workflows/capability.test.ts
@@ -52,9 +52,7 @@ function setup() {
outbox: outbox.outbox,
local: outbox.local,
host: {
- lifecycleVersion: 1,
runs: async () => ({ runs: [], next: null }),
- approvals: async () => ({ approvals: [] }),
},
canAccess: () => allowed,
});
@@ -145,7 +143,7 @@ it.each([
);
},
);
-it("lost receipt plus echo stays unknown; same signed retry and dismiss preserve operation identity", async () => {
+it("lost receipt plus echo stays unknown; dismissal never repeats the command", async () => {
const h = setup();
const operation = h.capability.trigger(h.definition);
await flush();
@@ -159,16 +157,10 @@ it("lost receipt plus echo stays unknown; same signed retry and dismiss preserve
delivery: "seen",
outcome: "unknown",
});
- h.capability.operations.retry(operation);
- await flush();
- expect(h.publish).toHaveBeenCalledTimes(2);
- expect(h.sign).toHaveBeenCalledTimes(1);
- expect(h.publish.mock.calls[1]?.[0]).toEqual(event);
- h.settle("duplicate: already processed");
- await flush();
- expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown");
await h.capability.operations.dismiss(operation);
expect(h.capability.operations.snapshot()).toEqual([]);
+ expect(h.publish).toHaveBeenCalledTimes(1);
+ expect(h.sign).toHaveBeenCalledTimes(1);
});
it("explicit rejection is rejected, not unknown; revocation fences late receipts without discarding durable intent", async () => {
const h = setup();
@@ -206,3 +198,133 @@ it("webhook saves are blocked through raw YAML; stale/legacy deletion receipt ne
await flush();
expect(h.capability.operations.snapshot()[1]?.outcome).toBe("succeeded");
});
+
+it.each([true, false])(
+ "fresh exact saved configuration resolves a lost save receipt without replay (echo=%s)",
+ async (echo) => {
+ const h = setup();
+ const operation = h.capability.save({
+ channelId,
+ yaml,
+ existing: h.definition,
+ });
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ if (!event) throw new Error("missing publication");
+ if (echo) h.outbox.observe([event]);
+ h.reject(new Error("lost receipt"));
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown");
+ h.read.mockResolvedValue([event]);
+ const view = h.capability.definitions(channelId);
+ expect(h.read).not.toHaveBeenCalled();
+ await view.refresh();
+ expect(h.read).toHaveBeenCalledWith(
+ [{ kinds: [30620], "#h": [channelId], limit: 100 }],
+ expect.objectContaining({ fresh: true }),
+ );
+ expect(view.snapshot()).toMatchObject({
+ status: "ready",
+ data: { items: [{ revision: operation }] },
+ });
+ expect(h.capability.operations.snapshot()[0]).toMatchObject({
+ eventId: operation,
+ outcome: "succeeded",
+ });
+ expect(h.capability.operations.snapshot()[0]?.error).toBeUndefined();
+ expect(h.sign).toHaveBeenCalledTimes(1);
+ expect(h.publish).toHaveBeenCalledTimes(1);
+ },
+);
+it.each([
+ "revision",
+ "owner",
+ "channel",
+ "workflow",
+ "newer-head",
+ "read-failure",
+ "revoked",
+ "disposed",
+])(
+ "fresh read does not resolve an unknown save on %s mismatch or lost interest",
+ async (caseName) => {
+ const h = setup();
+ h.capability.save({ channelId, yaml, existing: h.definition });
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ if (!event) throw new Error("missing publication");
+ h.reject(new Error("lost receipt"));
+ await flush();
+ const view = h.capability.definitions(channelId);
+ let row = event;
+ if (caseName === "revision") row = { ...event, id: "f".repeat(64) };
+ if (caseName === "owner") row = { ...event, pubkey: "f".repeat(64) };
+ if (caseName === "channel" || caseName === "workflow")
+ row = {
+ ...event,
+ tags: event.tags.map((tag) =>
+ tag[0] === (caseName === "channel" ? "h" : "d")
+ ? [tag[0], runId]
+ : tag,
+ ),
+ };
+ let resolve!: (events: RelayEvent[]) => void;
+ h.read.mockImplementationOnce(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ const reading = view.refresh();
+ await flush();
+ if (caseName === "revoked") h.revoke();
+ if (caseName === "disposed") view.dispose();
+ resolve(
+ caseName === "newer-head"
+ ? [
+ event,
+ { ...event, id: "f".repeat(64), created_at: event.created_at + 1 },
+ ]
+ : caseName === "read-failure"
+ ? [{ ...event, kind: 9 }]
+ : [row],
+ );
+ await reading;
+ expect(
+ h.capability.operations
+ .snapshot()
+ .some((op) => op.outcome === "succeeded"),
+ ).toBe(false);
+ expect(h.publish).toHaveBeenCalledTimes(1);
+ },
+);
+it("fresh saved configuration never resolves an unknown manual run", async () => {
+ const h = setup();
+ h.capability.trigger(h.definition);
+ await flush();
+ h.reject(new Error("lost receipt"));
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ if (!event) throw new Error("missing publication");
+ h.read.mockResolvedValue([{ ...event, kind: 30620, content: yaml }]);
+ await h.capability.definitions(channelId).refresh();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown");
+});
+
+it("dismissal cannot unlock an active echoed command", async () => {
+ const h = setup();
+ const operation = h.capability.trigger(h.definition);
+ await flush();
+ const event = h.publish.mock.calls[0]?.[0];
+ if (!event) throw new Error("missing publication");
+ h.outbox.observe([event]);
+ await expect(h.capability.operations.dismiss(operation)).rejects.toThrow(
+ "still being delivered",
+ );
+ expect(h.capability.operations.snapshot()[0]?.eventId).toBe(operation);
+ h.settle(`response:${JSON.stringify({ run_id: runId })}`);
+ await flush();
+ expect(h.capability.operations.snapshot()[0]?.outcome).toBe("succeeded");
+ await h.capability.operations.dismiss(operation);
+ expect(h.capability.operations.snapshot()).toEqual([]);
+});
diff --git a/src/features/workflows/capability.ts b/src/features/workflows/capability.ts
index 19ad608b..d5ce8146 100644
--- a/src/features/workflows/capability.ts
+++ b/src/features/workflows/capability.ts
@@ -12,7 +12,6 @@ import type {
import {
definition,
isWorkflowOperation,
- parseApprovals,
parseRuns,
record,
validateReference,
@@ -49,14 +48,12 @@ export function createWorkflows({
};
const results = new Map();
const receiptInterest = new Set();
- // Webhook save/reveal stays unavailable until the explicit UI secret lifetime is integrated.
const availability = Object.freeze({
definitions: !!reader,
history: !!host,
- save: host?.lifecycleVersion === 1 && !!outbox?.supports(30620),
- trigger: host?.lifecycleVersion === 1 && !!outbox?.supports(46020),
- delete: host?.lifecycleVersion === 1 && !!outbox?.supports(5),
- webhookSecrets: false,
+ save: !!host && !!outbox?.supports(30620),
+ trigger: !!host && !!outbox?.supports(46020),
+ delete: !!host && !!outbox?.supports(5),
});
let operations: readonly WorkflowOperation[] = Object.freeze([]);
function rebuild() {
@@ -82,6 +79,10 @@ export function createWorkflows({
: item.delivery === "failed"
? "rejected"
: "unknown");
+ const error =
+ result?.outcome === "succeeded"
+ ? undefined
+ : (result?.error ?? item.error);
return [
Object.freeze({
eventId: item.event.id,
@@ -94,11 +95,8 @@ export function createWorkflows({
: "trigger",
delivery: item.delivery,
outcome,
- secretAvailable: false,
...(result?.runId ? { runId: result.runId } : {}),
- ...((result?.error ?? item.error) !== undefined
- ? { error: (result?.error ?? item.error) as string }
- : {}),
+ ...(error !== undefined ? { error } : {}),
}),
];
})
@@ -122,6 +120,7 @@ export function createWorkflows({
available: boolean,
empty: T,
load: (signal: AbortSignal) => Promise,
+ accept?: (data: T) => void,
): WorkflowView {
if (!UUID.test(channelId)) throw new Error("Invalid workflow channel");
if (views.size >= 16)
@@ -203,6 +202,7 @@ export function createWorkflows({
)
return;
snapshot = Object.freeze({ status: "ready", data });
+ accept?.(data);
emit();
})
.catch(() => {
@@ -242,7 +242,7 @@ export function createWorkflows({
? availability.trigger
: availability.delete;
if (!enabled)
- throw new Error("Reliable workflow writes are unavailable on this relay");
+ throw new Error("Workflow writes are unavailable on this connection");
}
function send(
kind: 30620 | 46020 | 5,
@@ -263,7 +263,6 @@ export function createWorkflows({
validateWorkflowEvent(
{ ...input, pubkey: viewer, id: "", created_at: 0 },
viewer,
- availability,
);
if (!outbox) throw new Error("Workflow publishing unavailable");
if (receiptInterest.size >= 256)
@@ -307,6 +306,29 @@ export function createWorkflows({
partial: events.length >= 100,
});
},
+ ({ items }) => {
+ // Only a fresh, verified exact configuration head resolves an unknown
+ // save. An echo, another revision, or run history cannot do so.
+ let changed = false;
+ for (const op of operations) {
+ if (
+ op.action !== "save" ||
+ op.outcome !== "unknown" ||
+ !items.some(
+ (row) =>
+ row.revision === op.eventId &&
+ row.owner === op.workflow.owner &&
+ row.channelId === op.workflow.channelId &&
+ row.id === op.workflow.id,
+ )
+ )
+ continue;
+ results.set(op.eventId, { outcome: "succeeded" });
+ receiptInterest.delete(op.eventId);
+ changed = true;
+ }
+ if (changed) rebuild();
+ },
);
},
runs(workflow, cursor) {
@@ -324,22 +346,6 @@ export function createWorkflows({
},
);
},
- approvals(workflow, runId) {
- assertAccess(workflow);
- return view(
- workflow.channelId,
- !!host,
- Object.freeze([]),
- async (signal) => {
- if (!host) throw new Error("Workflow history unavailable");
- return parseApprovals(
- await host.approvals(workflow.id, runId, signal),
- workflow.id,
- runId,
- );
- },
- );
- },
save({ channelId, yaml, existing }) {
if (existing && existing.channelId !== channelId)
throw new Error("Workflow channel cannot change");
@@ -364,32 +370,26 @@ export function createWorkflows({
listeners.delete(listener);
};
},
- retry(id) {
- const op = operations.find((row) => row.eventId === id);
- if (!op || op.outcome === "succeeded" || op.delivery === "sending")
- return;
- assertAccess(op.workflow);
- receiptInterest.add(id);
- results.delete(id);
- outbox?.retry(id);
- },
async dismiss(id) {
await outbox?.dismiss(id);
+ // The outbox cannot dismiss an active attempt, including one already
+ // echoed by the relay. Do not tell the editor it may unlock that intent.
+ if (local?.snapshot().some((item) => item.event.id === id))
+ throw new Error(
+ "Command is still being delivered; wait before dismissing it",
+ );
results.delete(id);
receiptInterest.delete(id);
rebuild();
},
}),
- takeWebhookSecret() {
- return undefined;
- },
});
return {
capability,
validate(event: EventData) {
if (!isWorkflowOperation(event)) return;
assertOperation(event.kind);
- const reference = validateWorkflowEvent(event, viewer, availability);
+ const reference = validateWorkflowEvent(event, viewer);
assertAccess(reference);
},
receipt(event: EventData, message: string | undefined) {
@@ -424,11 +424,7 @@ export function createWorkflows({
UUID.test(value.run_id)
)
result = { outcome: "succeeded", runId: value.run_id };
- else if (
- event.kind === 5 &&
- host?.lifecycleVersion === 1 &&
- value.deleted === true
- )
+ else if (event.kind === 5 && value.deleted === true)
result = { outcome: "succeeded" };
}
}
diff --git a/src/features/workflows/compatibility.test.ts b/src/features/workflows/compatibility.test.ts
deleted file mode 100644
index f8a459fb..00000000
--- a/src/features/workflows/compatibility.test.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-import { afterEach, assert, expect, it, vi } from "vitest";
-import {
- connectBrokerTransport,
- connectSignedTransport,
-} from "../relay/transport";
-import { keypair, signed } from "../relay/testing";
-import { workflowLifecycleVersion } from "./compatibility";
-const origin = "https://workflow-compat.test";
-const key = keypair();
-const info = {
- self: key.pubkey,
- supported_extensions: ["buzz-workflows"],
- workflows: { lifecycle: 1, host: "workflow-compat.test" },
-};
-const invalid = [
- null,
- {},
- { ...info, self: undefined, pubkey: key.pubkey },
- { ...info, self: "b".repeat(64) },
- { ...info, supported_extensions: [] },
- { ...info, workflows: undefined },
- ...[0, 2, "1", true, null].map((lifecycle) => ({
- ...info,
- workflows: { ...info.workflows, lifecycle },
- })),
- ...[
- "other.test",
- "workflow-compat.test:443",
- "workflow-compat.test:1234",
- "https://workflow-compat.test",
- "workflow-compat.test/path",
- ].map((host) => ({ ...info, workflows: { ...info.workflows, host } })),
-];
-afterEach(() => vi.unstubAllGlobals());
-it("requires explicit version, extension, self and exact normalized host", () => {
- expect(workflowLifecycleVersion(info, origin, key.pubkey)).toBe(1);
- expect(
- workflowLifecycleVersion(info, "wss://WORKFLOW-COMPAT.test./", key.pubkey),
- ).toBe(1);
- for (const data of invalid)
- expect(workflowLifecycleVersion(data, origin, key.pubkey)).toBeUndefined();
-});
-it.each([info, ...invalid])(
- "real broker session projects only verified compatibility %#",
- async (workflowInfo) => {
- vi.stubGlobal("fetch", async () =>
- Response.json({
- viewer: key.pubkey,
- relayAuthor: key.pubkey,
- relayUrl: origin,
- workflowReads: true,
- workflowInfo,
- writeKinds: [9, 30620, 46020, 5],
- }),
- );
- const transport = await connectBrokerTransport();
- expect(transport.workflows?.lifecycleVersion).toBe(
- workflowInfo === info ? 1 : undefined,
- );
- },
-);
-it.each([info, ...invalid])(
- "signed host discovers against its own origin without signing or forwarding identity %#",
- async (metadata) => {
- const fetcher = vi.fn(async () => Response.json(metadata));
- const signEvent = vi.fn(async (t: Parameters[1]) =>
- signed(key, t),
- );
- vi.stubGlobal("fetch", fetcher);
- const transport = await connectSignedTransport(
- { getPublicKey: async () => key.pubkey, signEvent },
- origin,
- key.pubkey,
- );
- expect(transport.workflows?.lifecycleVersion).toBe(
- metadata === info ? 1 : undefined,
- );
- expect(fetcher).toHaveBeenCalledTimes(1);
- expect(fetcher.mock.calls[0]).toEqual([
- origin,
- {
- headers: { Accept: "application/nostr+json" },
- redirect: "error",
- credentials: "omit",
- signal: expect.any(AbortSignal),
- },
- ]);
- expect(signEvent).not.toHaveBeenCalled();
- },
-);
-it("metadata failure, malformed JSON and reconnect downgrade keep signed host reads available, writes off", async () => {
- for (const result of [
- new Response("bad json"),
- new Response("", { status: 503 }),
- new Error("offline"),
- ]) {
- vi.stubGlobal("fetch", async () => {
- if (result instanceof Error) throw result;
- return result;
- });
- const transport = await connectSignedTransport(
- {
- getPublicKey: async () => key.pubkey,
- signEvent: async (t) => signed(key, t),
- },
- origin,
- key.pubkey,
- );
- expect(transport.workflows?.lifecycleVersion).toBeUndefined();
- expect(transport.workflows?.runs).toBeTypeOf("function");
- }
-});
-it("direct signer checks real metadata again at both signing and publishing, preserving exact event and receipt", async () => {
- let compatible = true;
- const calls: string[] = [];
- const signEvent = vi.fn(async (t: Parameters[1]) =>
- signed(key, t),
- );
- const template = {
- kind: 46020,
- created_at: 1,
- content: "",
- tags: [
- ["h", "11111111-1111-4111-8111-111111111111"],
- ["d", "22222222-2222-4222-8222-222222222222"],
- ],
- };
- vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
- if (url === origin)
- return Response.json(compatible ? info : { self: key.pubkey });
- calls.push(url);
- expect(init?.body).toBe(JSON.stringify(event));
- return Response.json({
- accepted: true,
- event_id: event.id,
- message: "run-result",
- });
- });
- const t = await connectSignedTransport(
- { getPublicKey: async () => key.pubkey, signEvent },
- origin,
- key.pubkey,
- );
- assert.exists(t.writer);
- const event = await t.writer.sign(template, new AbortController().signal);
- expect(event.kind).toBe(46020);
- expect(await t.writer.publish(event, new AbortController().signal)).toBe(
- "run-result",
- );
- expect(calls).toEqual([`${origin}/events`]);
- compatible = false;
- signEvent.mockClear();
- await expect(
- t.writer.sign(template, new AbortController().signal),
- ).rejects.toThrow("unavailable");
- await expect(
- t.writer.publish(event, new AbortController().signal),
- ).rejects.toThrow("unavailable");
- expect(signEvent).not.toHaveBeenCalled();
- expect(calls).toHaveLength(1);
-});
-it("positive metadata alone cannot authorize invalid direct workflow commands", async () => {
- const signEvent = vi.fn(async (t: Parameters[1]) =>
- signed(key, t),
- );
- const fetcher = vi.fn(async () => Response.json(info));
- vi.stubGlobal("fetch", fetcher);
- const t = await connectSignedTransport(
- { getPublicKey: async () => key.pubkey, signEvent },
- origin,
- key.pubkey,
- );
- assert.exists(t.writer);
- await expect(
- t.writer.sign(
- { kind: 30620, created_at: 1, content: "invalid", tags: [] },
- new AbortController().signal,
- ),
- ).rejects.toThrow();
- expect(signEvent).not.toHaveBeenCalled();
- expect(fetcher).toHaveBeenCalledTimes(2);
-});
-
-it.each(
- [false, true].flatMap((compatible) =>
- ["030620", "+30620", "+00030620"].flatMap((alias) =>
- ["sign", "publish"].map((operation) => ({
- compatible,
- alias,
- operation,
- })),
- ),
- ),
-)(
- "signed host rejects $alias at $operation with compatibility=$compatible",
- async ({ compatible, alias, operation }) => {
- const signEvent = vi.fn(async (t: Parameters[1]) =>
- signed(key, t),
- );
- const calls: string[] = [];
- vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
- if (url === origin)
- return Response.json(compatible ? info : { self: key.pubkey });
- calls.push(url);
- const event = JSON.parse(String(init?.body));
- return Response.json({
- accepted: true,
- event_id: event.id,
- message: "accepted",
- });
- });
- const t = await connectSignedTransport(
- { getPublicKey: async () => key.pubkey, signEvent },
- origin,
- key.pubkey,
- );
- assert.exists(t.writer);
- const input = {
- kind: 5,
- created_at: 1,
- content: "",
- tags: [
- ["h", "11111111-1111-4111-8111-111111111111"],
- ["a", `${alias}:${key.pubkey}:22222222-2222-4222-8222-222222222222`],
- ],
- };
- await expect(
- operation === "sign"
- ? t.writer.sign(input, new AbortController().signal)
- : t.writer.publish(signed(key, input), new AbortController().signal),
- ).rejects.toThrow();
- expect(signEvent).not.toHaveBeenCalled();
- expect(calls).toHaveLength(0);
- },
-);
-it.each([false, true])(
- "unrelated deletes retain signed host behavior with compatibility=%s",
- async (compatible) => {
- const calls: string[] = [];
- vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
- if (url === origin)
- return Response.json(compatible ? info : { self: key.pubkey });
- calls.push(url);
- const event = JSON.parse(String(init?.body));
- return Response.json({
- accepted: true,
- event_id: event.id,
- message: "accepted",
- });
- });
- const t = await connectSignedTransport(
- {
- getPublicKey: async () => key.pubkey,
- signEvent: async (t) => signed(key, t),
- },
- origin,
- key.pubkey,
- );
- assert.exists(t.writer);
- for (const target of [
- ["e", "b".repeat(64)],
- ["a", `030000:${key.pubkey}:other`],
- ]) {
- const event = await t.writer.sign(
- { kind: 5, created_at: 1, content: "", tags: [target] },
- new AbortController().signal,
- );
- await t.writer.publish(event, new AbortController().signal);
- }
- expect(calls).toEqual([`${origin}/events`, `${origin}/events`]);
- },
-);
diff --git a/src/features/workflows/compatibility.ts b/src/features/workflows/compatibility.ts
deleted file mode 100644
index c2d361a2..00000000
--- a/src/features/workflows/compatibility.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { relayOrigin } from "../communities/destination.ts";
-import { record } from "./protocol.ts";
-import { workflowReadText } from "./http.ts";
-
-/** HTTPS-origin evidence only; contact keys, kind lists and software versions never qualify. */
-export function workflowLifecycleVersion(
- info: unknown,
- origin: string,
- expectedAuthor?: string,
-): 1 | undefined {
- if (
- !record(info) ||
- typeof info.self !== "string" ||
- !/^[0-9a-f]{64}$/.test(info.self) ||
- (expectedAuthor !== undefined && info.self !== expectedAuthor) ||
- !Array.isArray(info.supported_extensions) ||
- !info.supported_extensions.includes("buzz-workflows") ||
- !record(info.workflows) ||
- info.workflows.lifecycle !== 1
- )
- return undefined;
- try {
- return info.workflows.host === new URL(relayOrigin(origin)).host
- ? 1
- : undefined;
- } catch {
- return undefined;
- }
-}
-
-/** One bounded, unsigned NIP-11 GET to the captured origin; no redirects or credential forwarding. */
-export async function discoverWorkflowLifecycle(
- origin: string,
- author: string,
- signal?: AbortSignal,
-): Promise<1 | undefined> {
- try {
- const response = await fetch(origin, {
- headers: { Accept: "application/nostr+json" },
- redirect: "error",
- credentials: "omit",
- signal: signal
- ? AbortSignal.any([signal, AbortSignal.timeout(10000)])
- : AbortSignal.timeout(10000),
- });
- if (!response.ok) return undefined;
- return workflowLifecycleVersion(
- JSON.parse(await workflowReadText(response)),
- origin,
- author,
- );
- } catch {
- // Metadata failure cannot grant writes or prevent ordinary history access.
- return undefined;
- }
-}
diff --git a/src/features/workflows/host.ts b/src/features/workflows/host.ts
index 308dae53..e2d1a129 100644
--- a/src/features/workflows/host.ts
+++ b/src/features/workflows/host.ts
@@ -2,12 +2,9 @@ import type { WorkflowRunCursor } from "./types.ts";
/** Host-owned authenticated reads on the captured relay principal/admission lane. */
export interface WorkflowHost {
- /** Positive forward lifecycle contract evidence, never inferred from kind support. */
- readonly lifecycleVersion?: 1;
runs(
id: string,
cursor: WorkflowRunCursor | undefined,
signal: AbortSignal,
): Promise;
- approvals(id: string, runId: string, signal: AbortSignal): Promise;
}
diff --git a/src/features/workflows/http.test.ts b/src/features/workflows/http.test.ts
index ddcfdcba..5e1590c0 100644
--- a/src/features/workflows/http.test.ts
+++ b/src/features/workflows/http.test.ts
@@ -1,58 +1,5 @@
-import { afterEach, expect, it, vi } from "vitest";
-import { verifyEvent } from "nostr-tools";
-import { connectSignedTransport } from "../relay/transport";
-import { keypair, signed } from "../relay/testing";
-import { WORKFLOW_READ_BYTES, workflowReadText } from "./http";
-const id = "11111111-1111-4111-8111-111111111111";
-const runId = "22222222-2222-4222-8222-222222222222";
-afterEach(() => vi.unstubAllGlobals());
-it("direct signed transport history uses exact GET URL, no payload and the existing principal quota lane", async () => {
- const key = keypair();
- const signer = {
- getPublicKey: async () => key.pubkey,
- signEvent: async (template: Parameters[1]) =>
- signed(key, template),
- };
- const fetcher = vi.fn(async (url: string, init?: RequestInit) => {
- const auth = JSON.parse(
- atob(new Headers(init?.headers).get("Authorization")?.slice(6) ?? ""),
- );
- expect(verifyEvent(auth)).toBe(true);
- expect(auth.pubkey).toBe(key.pubkey);
- expect(auth.tags).toContainEqual(["u", url]);
- expect(auth.tags).toContainEqual(["method", "GET"]);
- expect(auth.tags.some(([name]: string[]) => name === "payload")).toBe(
- false,
- );
- expect(init?.body).toBeUndefined();
- expect(init?.redirect).toBe("error");
- return Response.json(
- { error: "rate-limited: quota exceeded; retry in 0s" },
- { status: 429 },
- );
- });
- vi.stubGlobal("fetch", fetcher);
- const transport = await connectSignedTransport(
- signer,
- "https://workflow-direct.test",
- key.pubkey,
- );
- expect(fetcher).not.toHaveBeenCalled();
- const cursor = {
- before: "2026-09-12T14:44:19.123456+00:00",
- beforeId: runId,
- };
- await expect(
- transport.workflows?.runs(id, cursor, new AbortController().signal),
- ).rejects.toMatchObject({ status: 429 });
- expect(fetcher.mock.calls[0]?.[0]).toBe(
- `https://workflow-direct.test/workflows/${id}/runs?limit=20&before=2026-09-12T14%3A44%3A19.123456%2B00%3A00&before_id=${runId}`,
- );
- await expect(
- transport.query([{ kinds: [0], limit: 1 }]),
- ).rejects.toMatchObject({ status: 429 });
- expect(fetcher).toHaveBeenCalledTimes(1);
-});
+import { expect, it, vi } from "vitest";
+import { WORKFLOW_READ_BYTES, workflowReadText, workflowHost } from "./http";
it("structured body budget counts stream bytes, cancels overflow, rejects invalid UTF8", async () => {
const cancel = vi.fn();
const response = new Response(
@@ -69,32 +16,16 @@ it("structured body budget counts stream bytes, cancels overflow, rejects invali
workflowReadText(new Response(new Uint8Array([0xff]))),
).rejects.toThrow();
});
-it("direct workflow cancellation/invalid arguments never sign or dispatch", async () => {
- const key = keypair(),
- signEvent = vi.fn(async (template: Parameters[1]) =>
- signed(key, template),
- );
- const fetcher = vi.fn();
- vi.stubGlobal("fetch", fetcher);
- const transport = await connectSignedTransport(
- { getPublicKey: async () => key.pubkey, signEvent },
- "https://workflow-cancel.test",
- key.pubkey,
- );
+it("broker host rejects cancelled or invalid history reads before dispatch", async () => {
+ const request = vi.fn();
+ const host = workflowHost(request);
const cancel = new AbortController();
cancel.abort();
await expect(
- transport.workflows?.runs(id, undefined, cancel.signal),
+ host.runs("11111111-1111-4111-8111-111111111111", undefined, cancel.signal),
).rejects.toThrow();
await expect(
- transport.workflows?.approvals(id, "../", new AbortController().signal),
+ host.runs("../", undefined, new AbortController().signal),
).rejects.toThrow();
- expect(signEvent).not.toHaveBeenCalled();
- expect(fetcher).not.toHaveBeenCalled();
+ expect(request).not.toHaveBeenCalled();
});
-
-// These are transport/admission tests; the real metadata seam is exercised in compatibility.test.ts.
-vi.mock("./compatibility", async (original) => ({
- ...(await original()),
- discoverWorkflowLifecycle: async () => undefined,
-}));
diff --git a/src/features/workflows/http.ts b/src/features/workflows/http.ts
index a5adcdfa..09101701 100644
--- a/src/features/workflows/http.ts
+++ b/src/features/workflows/http.ts
@@ -1,37 +1,28 @@
import { ReadError } from "../relay/errors.ts";
import { readApiFailure } from "../relay/http-admission.ts";
import type { WorkflowHost } from "./host.ts";
-import { approvalsPath, record, runsPath } from "./protocol.ts";
+import { record, runsPath } from "./protocol.ts";
export const WORKFLOW_READ_BYTES = 1024 * 1024;
-/** Fixed routes only: browser input can never select an upstream URL or page size. */
-export function workflowReadPath(route: string, body: unknown): string {
- if (!record(body) || typeof body.id !== "string")
+/** Only the fixed run-history route; input cannot choose an upstream URL or size. */
+export function workflowRunsPath(body: unknown): string {
+ if (
+ !record(body) ||
+ typeof body.id !== "string" ||
+ Object.keys(body).some((key) => !["id", "cursor"].includes(key))
+ )
throw new Error("Invalid workflow read");
- if (route === "workflow-runs") {
- if (Object.keys(body).some((key) => !["id", "cursor"].includes(key)))
- throw new Error("Invalid workflow read fields");
- const cursor = body.cursor;
- if (
- cursor !== undefined &&
- (!record(cursor) ||
- typeof cursor.before !== "string" ||
- typeof cursor.beforeId !== "string" ||
- Object.keys(cursor).some(
- (key) => !["before", "beforeId"].includes(key),
- ))
- )
- throw new Error("Invalid workflow cursor");
- return runsPath(body.id, cursor as Parameters[1]);
- }
+ const cursor = body.cursor;
if (
- route === "workflow-approvals" &&
- typeof body.runId === "string" &&
- Object.keys(body).every((key) => ["id", "runId"].includes(key))
+ cursor !== undefined &&
+ (!record(cursor) ||
+ typeof cursor.before !== "string" ||
+ typeof cursor.beforeId !== "string" ||
+ Object.keys(cursor).some((key) => !["before", "beforeId"].includes(key)))
)
- return approvalsPath(body.id, body.runId);
- throw new Error("Invalid workflow read route");
+ throw new Error("Invalid workflow cursor");
+ return runsPath(body.id, cursor as Parameters[1]);
}
/** Stream-bound before parsing. Never include upstream text in an error/log. */
@@ -63,37 +54,32 @@ export function workflowHost(
body: unknown,
signal: AbortSignal,
) => Promise,
- lifecycleVersion?: 1,
): WorkflowHost {
- async function read(route: string, body: unknown, signal: AbortSignal) {
- workflowReadPath(route, body);
- signal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
- signal.throwIfAborted();
- const response = await request(route, body, signal);
- if (!response.ok) {
- const failure = await readApiFailure(response);
- throw new ReadError(
- response.status === 401 || response.status === 403
- ? "denied"
- : "unavailable",
- failure.error,
- response.status,
- failure.retryAfterMs,
- );
- }
- const text = await workflowReadText(response);
- signal.throwIfAborted();
- try {
- return JSON.parse(text) as unknown;
- } catch {
- throw new Error("Invalid workflow response");
- }
- }
return Object.freeze({
- ...(lifecycleVersion === 1 ? { lifecycleVersion } : {}),
- runs: (id, cursor, signal) =>
- read("workflow-runs", { id, ...(cursor ? { cursor } : {}) }, signal),
- approvals: (id, runId, signal) =>
- read("workflow-approvals", { id, runId }, signal),
+ async runs(id, cursor, signal) {
+ const body = { id, ...(cursor ? { cursor } : {}) };
+ workflowRunsPath(body);
+ signal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
+ signal.throwIfAborted();
+ const response = await request("workflow-runs", body, signal);
+ if (!response.ok) {
+ const failure = await readApiFailure(response);
+ throw new ReadError(
+ response.status === 401 || response.status === 403
+ ? "denied"
+ : "unavailable",
+ failure.error,
+ response.status,
+ failure.retryAfterMs,
+ );
+ }
+ const text = await workflowReadText(response);
+ signal.throwIfAborted();
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ throw new Error("Invalid workflow response");
+ }
+ },
} satisfies WorkflowHost);
}
diff --git a/src/features/workflows/protocol.test.ts b/src/features/workflows/protocol.test.ts
index aabc83d6..cf7e69aa 100644
--- a/src/features/workflows/protocol.test.ts
+++ b/src/features/workflows/protocol.test.ts
@@ -3,7 +3,6 @@ import {
isWorkflowOperation,
validateWorkflowEvent,
parseRuns,
- parseApprovals,
workflowYaml,
} from "./protocol";
const owner = "a".repeat(64),
@@ -23,9 +22,11 @@ const base = {
content: yaml,
};
it("strict authoring boundary rejects malformed coordinates/tags and webhook raw bypass, without taking generic deletions", () => {
- expect(
- validateWorkflowEvent(base, owner, { delete: true, webhookSecrets: false }),
- ).toEqual({ id, owner, channelId: id });
+ expect(validateWorkflowEvent(base, owner)).toEqual({
+ id,
+ owner,
+ channelId: id,
+ });
for (const event of [
{ ...base, pubkey: "c".repeat(64) },
{
@@ -42,12 +43,7 @@ it("strict authoring boundary rejects malformed coordinates/tags and webhook raw
{ ...base, kind: 46020 },
{ ...base, created_at: Infinity },
])
- expect(() =>
- validateWorkflowEvent(event, owner, {
- delete: true,
- webhookSecrets: false,
- }),
- ).toThrow();
+ expect(() => validateWorkflowEvent(event, owner)).toThrow();
expect(isWorkflowOperation({ kind: 5, tags: [["e", "b".repeat(64)]] })).toBe(
false,
);
@@ -66,9 +62,8 @@ it("strict authoring boundary rejects malformed coordinates/tags and webhook raw
],
},
owner,
- { delete: false, webhookSecrets: false },
),
- ).toThrow("deletion");
+ ).not.toThrow();
});
it("YAML remains unchanged and malformed input/duplicate steps are rejected", () => {
expect(workflowYaml(yaml)).toMatchObject({ enabled: false });
@@ -94,16 +89,15 @@ it("legacy omitted enabled and explicit toggles cross the real event boundary un
const content = yaml.replace("enabled: false\n", line);
const event = { ...base, content };
expect(workflowYaml(content).enabled).toBe(enabled);
- expect(
- validateWorkflowEvent(event, owner, {
- delete: false,
- webhookSecrets: false,
- }),
- ).toEqual({ id, owner, channelId: id });
+ expect(validateWorkflowEvent(event, owner)).toEqual({
+ id,
+ owner,
+ channelId: id,
+ });
expect(event.content).toBe(content);
}
});
-it("run/approval rows require matching identities and preserve exact cursor precision", () => {
+it("run rows require matching identities and preserve exact cursor precision", () => {
const row = {
id: runId,
workflow_id: id,
@@ -128,19 +122,4 @@ it("run/approval rows require matching identities and preserve exact cursor prec
]) {
expect(() => parseRuns(value, id)).toThrow();
}
- const approval = {
- workflow_id: id,
- run_id: runId,
- approval_ref: owner,
- step_id: "a",
- status: "pending",
- created_at: 1,
- note: null,
- };
- expect(
- parseApprovals({ approvals: [approval] }, id, runId)[0]?.reference,
- ).toBe(owner);
- expect(() =>
- parseApprovals({ approvals: [{ ...approval, run_id: id }] }, id, runId),
- ).toThrow();
});
diff --git a/src/features/workflows/protocol.ts b/src/features/workflows/protocol.ts
index 417f01a9..3984b80f 100644
--- a/src/features/workflows/protocol.ts
+++ b/src/features/workflows/protocol.ts
@@ -5,7 +5,6 @@ import type {
WorkflowReference,
WorkflowRunCursor,
WorkflowRunPage,
- WorkflowApproval,
} from "./types.ts";
export const WORKFLOW_KINDS = [30620, 46020, 5] as const;
@@ -109,11 +108,7 @@ export function workflowYaml(text: string) {
};
}
/** Shared session/broker signing boundary. Only canonical workflow operations, never generic kind 5. */
-export function validateWorkflowEvent(
- event: EventData,
- viewer: string,
- options: { delete: boolean; webhookSecrets: boolean },
-) {
+export function validateWorkflowEvent(event: EventData, viewer: string) {
if (
typeof event.content !== "string" ||
!Number.isSafeInteger(event.created_at) ||
@@ -142,8 +137,6 @@ export function validateWorkflowEvent(
event.tags.some((tag) => !allowed.includes(tag[0] ?? ""))
)
throw new Error("Unsupported workflow command tag");
- if (event.kind === 5 && !options.delete)
- throw new Error("Reliable workflow deletion is unavailable on this relay");
if (event.kind !== 30620 && event.content !== "")
throw new Error("Workflow command content must be empty");
if (event.kind === 30620) {
@@ -152,7 +145,7 @@ export function validateWorkflowEvent(
);
if (revisions.length && !HEX.test(one(event, "expected-revision")))
throw new Error("Invalid expected workflow revision");
- if (workflowYaml(event.content).webhook && !options.webhookSecrets)
+ if (workflowYaml(event.content).webhook)
throw new Error("Webhook saves require secure one-time-secret handling");
} else if (event.tags.some(([name]) => name === "expected-revision"))
throw new Error("Unexpected workflow revision tag");
@@ -168,11 +161,6 @@ export function runsPath(id: string, cursor?: WorkflowRunCursor) {
}
return `/workflows/${id}/runs?${query}`;
}
-export function approvalsPath(id: string, runId: string) {
- if (!UUID.test(id) || !UUID.test(runId))
- throw new Error("Invalid workflow/run ID");
- return `/workflows/${id}/runs/${runId}/approvals`;
-}
function validateCursor(cursor: WorkflowRunCursor) {
if (
!UUID.test(cursor.beforeId) ||
@@ -248,42 +236,3 @@ export function parseRuns(raw: unknown, workflowId: string): WorkflowRunPage {
}
return Object.freeze({ runs: Object.freeze(runs), next });
}
-export function parseApprovals(
- raw: unknown,
- workflowId: string,
- runId: string,
-): readonly WorkflowApproval[] {
- if (
- !record(raw) ||
- !Array.isArray(raw.approvals) ||
- raw.approvals.length > 1000
- )
- throw new Error("Invalid workflow approvals response");
- return Object.freeze(
- raw.approvals.map((row) => {
- if (
- !record(row) ||
- row.workflow_id !== workflowId ||
- row.run_id !== runId ||
- typeof row.approval_ref !== "string" ||
- !HEX.test(row.approval_ref) ||
- typeof row.step_id !== "string" ||
- row.step_id.length > 256 ||
- !["pending", "granted", "denied", "expired"].includes(
- String(row.status),
- ) ||
- !nullableText(row.note) ||
- !number(row.created_at)
- )
- throw new Error("Invalid workflow approval row");
- return Object.freeze({
- reference: row.approval_ref,
- runId,
- stepId: row.step_id,
- status: row.status as WorkflowApproval["status"],
- note: row.note,
- createdAt: row.created_at,
- });
- }),
- );
-}
diff --git a/src/features/workflows/session.test.ts b/src/features/workflows/session.test.ts
index 428a9eac..d6a56eb4 100644
--- a/src/features/workflows/session.test.ts
+++ b/src/features/workflows/session.test.ts
@@ -32,7 +32,7 @@ function setup() {
);
const owner = createRelaySession({
...wire.transport,
- workflows: { runs, approvals: async () => ({ approvals: [] }) },
+ workflows: { runs },
subscribe(callbacks) {
incoming = callbacks.receive;
return { update() {}, retry() {}, dispose() {} };
@@ -154,22 +154,31 @@ it("a loading observer can revoke without leaving a wedged pending read", async
await fresh;
expect(history.snapshot().status).toBe("ready");
});
-it("old host keeps every workflow command unavailable, even through direct session outbox", async () => {
- const wire = scriptedTransport(viewer.pubkey, relay.pubkey),
- sign = vi.fn(async (template: Parameters[1]) =>
- signed(viewer, template),
- );
+it("existing backend permits workflow commands without lifecycle metadata while retaining session access and shape guards", async () => {
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let incoming!: (events: readonly RelayEvent[]) => void;
+ const sign = vi.fn(async (template: Parameters[1]) =>
+ signed(viewer, template),
+ );
+ const publish = vi.fn(async () => "");
const owner = createRelaySession(
- { ...wire.transport, writer: { sign, publish: async () => "" } },
{
- outboxStorage: { load: async () => [], save: async () => {} },
+ ...wire.transport,
+ writer: { kinds: [9, 30620, 46020, 5], sign, publish },
+ workflows: { runs: async () => ({ runs: [], next: null }) },
+ subscribe(callbacks) {
+ incoming = callbacks.receive;
+ return { update() {}, retry() {}, dispose() {} };
+ },
},
+ { outboxStorage: { load: async () => [], save: async () => {} } },
);
owners.push(owner);
+ incoming([roster(relay, channelId, [viewer.pubkey], 1)]);
expect(owner.session.workflows.availability).toMatchObject({
- save: false,
- delete: false,
- trigger: false,
+ save: true,
+ delete: true,
+ trigger: true,
});
const workflow = {
...reference,
@@ -177,10 +186,35 @@ it("old host keeps every workflow command unavailable, even through direct sessi
revision: definition.id,
createdAt: 10,
};
+ const operation = owner.session.workflows.trigger(workflow);
+ await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() =>
+ expect(owner.session.workflows.operations.snapshot()[0]).toMatchObject({
+ eventId: operation,
+ outcome: "unknown",
+ }),
+ );
+ expect(sign).toHaveBeenCalledTimes(1);
+ const invalid = owner.session.outbox?.send({
+ kind: 46020,
+ tags: [
+ ["h", channelId],
+ ["d", "not-a-uuid"],
+ ],
+ content: "",
+ });
+ await vi.waitFor(() =>
+ expect(
+ owner.session.outbox?.snapshot().find((row) => row.event.id === invalid)
+ ?.delivery,
+ ).toBe("failed"),
+ );
+ expect(sign).toHaveBeenCalledTimes(1);
+ incoming([roster(relay, channelId, [], 2)]);
expect(() => owner.session.workflows.trigger(workflow)).toThrow(
"unavailable",
);
- owner.session.outbox?.send({
+ const denied = owner.session.outbox?.send({
kind: 46020,
tags: [
["h", channelId],
@@ -189,7 +223,74 @@ it("old host keeps every workflow command unavailable, even through direct sessi
content: "",
});
await vi.waitFor(() =>
- expect(owner.session.outbox?.snapshot()[0]?.delivery).toBe("failed"),
+ expect(
+ owner.session.outbox?.snapshot().find((row) => row.event.id === denied)
+ ?.delivery,
+ ).toBe("failed"),
);
- expect(sign).not.toHaveBeenCalled();
+ expect(sign).toHaveBeenCalledTimes(1);
+ expect(publish).toHaveBeenCalledTimes(1);
+});
+
+it("session fresh definition read resolves a lost save, while ordinary echo does not", async () => {
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let incoming!: (events: readonly RelayEvent[]) => void;
+ let reject!: (error: Error) => void;
+ let published: RelayEvent | undefined;
+ const sign = vi.fn(async (template: Parameters[1]) =>
+ signed(viewer, template),
+ );
+ const publish = vi.fn((event: RelayEvent) => {
+ published = event;
+ return new Promise((_resolve, fail) => {
+ reject = fail;
+ });
+ });
+ const owner = createRelaySession(
+ {
+ ...wire.transport,
+ writer: { kinds: [30620], sign, publish },
+ workflows: { runs: async () => ({ runs: [], next: null }) },
+ subscribe(callbacks) {
+ incoming = callbacks.receive;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ },
+ { outboxStorage: { load: async () => [], save: async () => {} } },
+ );
+ owners.push(owner);
+ incoming([roster(relay, channelId, [viewer.pubkey], 1)]);
+ const workflows = owner.session.workflows;
+ const op = workflows.save({
+ channelId,
+ yaml: "name: Saved\nenabled: false\ntrigger:\n on: message_posted\nsteps:\n - id: wait\n action: delay\n duration: 1s\n",
+ });
+ await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1));
+ if (!published) throw new Error("missing signed event");
+ incoming([published]);
+ reject(new Error("lost receipt"));
+ await vi.waitFor(() =>
+ expect(workflows.operations.snapshot()[0]).toMatchObject({
+ eventId: op,
+ outcome: "unknown",
+ delivery: "seen",
+ }),
+ );
+ // Settle the existing outbox confirmation read; it is not task-level recovery.
+ await vi.waitFor(() => expect(wire.pending).toHaveLength(1));
+ wire.next().respond([published]);
+ const view = workflows.definitions(channelId);
+ expect(workflows.operations.snapshot()[0]?.outcome).toBe("unknown");
+ const loading = view.refresh();
+ await vi.waitFor(() => expect(wire.pending).toHaveLength(1));
+ const request = wire.next();
+ expect(request.filters).toEqual([
+ { kinds: [30620], "#h": [channelId], limit: 100 },
+ ]);
+ request.respond([published]);
+ await loading;
+ expect(view.snapshot().data.items[0]?.revision).toBe(op);
+ expect(workflows.operations.snapshot()[0]?.outcome).toBe("succeeded");
+ expect(sign).toHaveBeenCalledTimes(1);
+ expect(publish).toHaveBeenCalledTimes(1);
});
diff --git a/src/features/workflows/types.ts b/src/features/workflows/types.ts
index 37cf0711..2fb94a5e 100644
--- a/src/features/workflows/types.ts
+++ b/src/features/workflows/types.ts
@@ -57,16 +57,6 @@ export type WorkflowRunPage = Readonly<{
runs: readonly WorkflowRun[];
next: WorkflowRunCursor | null;
}>;
-export type WorkflowApproval = Readonly<{
- /** Hashed reference, NEVER an actionable approval token. */
- reference: string;
- runId: string;
- stepId: string;
- status: "pending" | "granted" | "denied" | "expired";
- note: string | null;
- createdAt: number;
-}>;
-
/** Delivery evidence and domain outcome are deliberately separate. No secret here. */
export type WorkflowOperation = Readonly<{
eventId: string;
@@ -76,8 +66,6 @@ export type WorkflowOperation = Readonly<{
outcome: "pending" | "succeeded" | "rejected" | "unknown";
error?: string;
runId?: string;
- /** A secret can be consumed once, never journaled or automatically copied. */
- secretAvailable: boolean;
}>;
/** Host availability, NOT per-row permission; the relay remains authoritative. */
@@ -86,9 +74,7 @@ export type WorkflowAvailability = Readonly<{
history: boolean;
save: boolean;
trigger: boolean;
- /** Requires the repaired relay lifecycle contract, not merely kind-5 support. */
delete: boolean;
- webhookSecrets: boolean;
}>;
/** Bundled UI contract. No socket, signer, arbitrary HTTP, scheduler or approval writes. */
@@ -99,10 +85,6 @@ export interface WorkflowCapability {
workflow: WorkflowReference,
cursor?: WorkflowRunCursor,
): WorkflowView;
- approvals(
- workflow: WorkflowReference,
- runId: string,
- ): WorkflowView;
/** Synchronous local intent ID; follow operations for delivery and domain completion.
* Existing definitions preserve author/channel/id and use their signed revision.
* New definitions get a new UUID. YAML mode uses the same host restrictions.
@@ -119,12 +101,6 @@ export interface WorkflowCapability {
operations: Readonly<{
snapshot(): readonly WorkflowOperation[];
subscribe(listener: () => void): () => void;
- /** Exact signed replay only; never creates a new event or recovers a lost receipt. */
- retry(eventId: string): void;
dismiss(eventId: string): Promise;
}>;
- /** Consume in response to explicit reveal; caller must clear display on scope/access loss.
- * Returns undefined after consumption, clear-cache, access revocation or disposal.
- */
- takeWebhookSecret(eventId: string): string | undefined;
}
From 793a296d8fef2b402b284b18064e28c9df01ee44 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Mon, 14 Sep 2026 08:19:53 -0600
Subject: [PATCH 18/20] fix(workflows): retain invalid timeout drafts and bound
step IDs
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/bundled/workflows/WorkflowEditor.tsx | 12 ++-
src/bundled/workflows/editor-model.test.ts | 41 ++++++++--
src/bundled/workflows/editor-model.ts | 10 ++-
src/bundled/workflows/session-fixture.tsx | 6 --
.../workflows/workflowFormTypes.test.mjs | 56 ++++++-------
src/bundled/workflows/workflowFormTypes.ts | 19 ++---
src/bundled/workflows/workflows.journey.mjs | 81 +++++++++++++++++++
7 files changed, 165 insertions(+), 60 deletions(-)
diff --git a/src/bundled/workflows/WorkflowEditor.tsx b/src/bundled/workflows/WorkflowEditor.tsx
index f5f82f2b..deb18fbe 100644
--- a/src/bundled/workflows/WorkflowEditor.tsx
+++ b/src/bundled/workflows/WorkflowEditor.tsx
@@ -5,9 +5,13 @@ import { Switch } from "../../shared/design-system/ui/Switch";
import { Tabs } from "../../shared/design-system/ui/Tabs";
import { ConfirmAction } from "./ConfirmAction";
import { WorkflowForm } from "./WorkflowForm";
-import { draftError, hasWebhookTrigger, visualForm } from "./editor-model";
+import { draftError, hasWebhookTrigger } from "./editor-model";
import { getWorkflowActivationWarning } from "./workflowActivationWarning";
-import { formStateToYaml, type WorkflowFormState } from "./workflowFormTypes";
+import {
+ formStateToYaml,
+ yamlToFormState,
+ type WorkflowFormState,
+} from "./workflowFormTypes";
import {
readWorkflowDocumentFields,
yamlWithWorkflowEnabled,
@@ -35,14 +39,14 @@ export function WorkflowEditor({
}) {
const id = useId();
const [mode, setMode] = useState<"form" | "yaml">(() =>
- visualForm(yaml).ok ? "form" : "yaml",
+ yamlToFormState(yaml).ok ? "form" : "yaml",
);
const [formDraft, setFormDraft] = useState(null);
const [formYaml, setFormYaml] = useState(yaml);
const [activating, setActivating] = useState(false);
const [modeError, setModeError] = useState(null);
const fields = readWorkflowDocumentFields(yaml);
- const parsed = visualForm(yaml);
+ const parsed = yamlToFormState(yaml);
// An incomplete form is still an editable draft. An external YAML change must
// be reparsed instead of reviving stale form state.
const form =
diff --git a/src/bundled/workflows/editor-model.test.ts b/src/bundled/workflows/editor-model.test.ts
index 29df9f6b..a7c11a9c 100644
--- a/src/bundled/workflows/editor-model.test.ts
+++ b/src/bundled/workflows/editor-model.test.ts
@@ -1,5 +1,6 @@
import { expect, test } from "vitest";
-import { draftError, exactSaveReadback, visualForm } from "./editor-model";
+import { draftError, exactSaveReadback } from "./editor-model";
+import { yamlToFormState } from "./workflowFormTypes";
import {
createWorkflowFixture,
fixtureDefinition,
@@ -7,13 +8,14 @@ import {
} from "./fixtures";
test("advanced fields stay raw; opening does not transform source", () => {
- expect(visualForm(fixtureYaml).ok).toBe(true);
- expect(visualForm(`${fixtureYaml}future: retain-me\n`).ok).toBe(false);
- expect(visualForm(fixtureYaml.replace("message_posted", "webhook")).ok).toBe(
- false,
- );
+ expect(yamlToFormState(fixtureYaml).ok).toBe(true);
+ expect(yamlToFormState(`${fixtureYaml}future: retain-me\n`).ok).toBe(false);
expect(
- visualForm(fixtureYaml.replace(" text:", " if: true\n text:")).ok,
+ yamlToFormState(fixtureYaml.replace("message_posted", "webhook")).ok,
+ ).toBe(false);
+ expect(
+ yamlToFormState(fixtureYaml.replace(" text:", " if: true\n text:"))
+ .ok,
).toBe(false);
expect(draftError(fixtureYaml)).toBeNull();
expect(
@@ -62,3 +64,28 @@ test("draft boundaries match signing: UTF-8 bytes, steps and legacy enabled defa
draftError(fixtureYaml.replace("enabled: false", `enabled: ${value}`)),
).toMatch(/true or false/);
});
+
+test("invalid timeout values block draft submission", () => {
+ for (const timeout of [
+ "'oops'",
+ "'0s'",
+ "0",
+ "1.5",
+ "null",
+ "9007199254740992",
+ ]) {
+ expect(
+ draftError(
+ fixtureYaml.replace(
+ " text:",
+ ` timeout_secs: ${timeout}\n text:`,
+ ),
+ ),
+ ).toMatch(/timeout.*positive whole number/);
+ }
+ expect(
+ draftError(
+ fixtureYaml.replace(" text:", " timeout_secs: 300\n text:"),
+ ),
+ ).toBeNull();
+});
diff --git a/src/bundled/workflows/editor-model.ts b/src/bundled/workflows/editor-model.ts
index 0e2191dd..ec076f6a 100644
--- a/src/bundled/workflows/editor-model.ts
+++ b/src/bundled/workflows/editor-model.ts
@@ -3,10 +3,7 @@ import type {
WorkflowDefinition,
WorkflowOperation,
} from "../../features/workflows/types";
-import { yamlToFormState, type WorkflowFormState } from "./workflowFormTypes";
-
-/** The form parser owns only the two triggers and two actions this UI edits. */
-export const visualForm = yamlToFormState;
+import type { WorkflowFormState } from "./workflowFormTypes";
/** Draft shape validation is not relay authorization or a promise of execution. */
export function draftError(yaml: string): string | null {
@@ -44,6 +41,11 @@ export function draftError(yaml: string): string | null {
return "Step IDs must be unique, with 1–64 letters, digits or underscores.";
ids.add(step.id);
if (typeof step.action !== "string") return "Each step needs an action.";
+ if (
+ step.timeout_secs !== undefined &&
+ (!Number.isSafeInteger(step.timeout_secs) || step.timeout_secs <= 0)
+ )
+ return "Step timeout must be a positive whole number of seconds (for example, 30s in Form mode), or left blank.";
if (
step.action === "send_message" &&
(typeof step.text !== "string" || !step.text.trim())
diff --git a/src/bundled/workflows/session-fixture.tsx b/src/bundled/workflows/session-fixture.tsx
index e8952e82..53529621 100644
--- a/src/bundled/workflows/session-fixture.tsx
+++ b/src/bundled/workflows/session-fixture.tsx
@@ -27,7 +27,6 @@ const channels = [fixtureChannel, secondChannel];
let incoming: ((events: readonly RelayEvent[]) => void) | undefined;
let generation = 0;
let currentScope = "Fixture A";
-let definitionReads = 0;
function session(scope: string) {
let journal: ReadJournal | undefined;
const events = channels.flatMap((channel, index) => [
@@ -54,8 +53,6 @@ function session(scope: string) {
relayAuthor: authority.pubkey,
media: () => undefined,
async query(filters) {
- if (filters.some((filter) => filter.kinds?.includes(30620)))
- definitionReads++;
return events.filter((event) =>
filters.some(
(filter) =>
@@ -118,9 +115,6 @@ function switchScope() {
};
for (const listener of listeners) listener();
}
-Object.assign(window, {
- sessionFixture: { definitionReads: () => definitionReads },
-});
function Fixture() {
useKeyboardFocusVisibility();
const [mounted, setMounted] = useState(true);
diff --git a/src/bundled/workflows/workflowFormTypes.test.mjs b/src/bundled/workflows/workflowFormTypes.test.mjs
index 394097dc..fa9bef1d 100644
--- a/src/bundled/workflows/workflowFormTypes.test.mjs
+++ b/src/bundled/workflows/workflowFormTypes.test.mjs
@@ -4,6 +4,7 @@ import { parse as parseYaml } from "yaml";
import {
formStateToYaml,
+ nextStepId,
yamlToFormState,
DEFAULT_FORM_STATE,
} from "./workflowFormTypes.ts";
@@ -56,7 +57,6 @@ test("recognized nodes with unknown fields are refused without touching YAML", (
`name: Test\nunknown: true\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted, future_filter: x }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, retry: 3 }]\n`,
- `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, auth: bearer }]\n`,
];
for (const yaml of fixtures) {
@@ -98,10 +98,6 @@ test("invalid IDs, shapes, and scalar types are refused", () => {
"numeric text",
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: 42 }]\n`,
],
- [
- "numeric header",
- `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { X-Retry: 3 } }]\n`,
- ],
[
"zero timeout",
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 0, action: send_message, text: hi }]\n`,
@@ -110,10 +106,6 @@ test("invalid IDs, shapes, and scalar types are refused", () => {
"fractional timeout",
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 1.5, action: send_message, text: hi }]\n`,
],
- [
- "unsupported method",
- `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, method: OPTIONS }]\n`,
- ],
];
for (const [name, yaml] of cases) {
@@ -129,24 +121,6 @@ test("step condition capabilities stay in YAML mode", () => {
assert.match(conditionResult.error, /conditions.*YAML editor/);
});
-test("malformed and unowned schedule definitions stay losslessly in YAML mode", () => {
- const fixtures = [
- `name: Missing\ntrigger: { on: schedule }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Both\ntrigger: { on: schedule, cron: "0 9 * * *", interval: 1h }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Numeric\ntrigger: { on: schedule, interval: 30 }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Unknown\ntrigger: { on: schedule, cron: "0 9 * * *", timezone: UTC }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- `name: Invalid cron\ntrigger: { on: schedule, cron: "60 9 * * *" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
- ];
-
- for (const yaml of fixtures) {
- const original = yaml;
- const result = yamlToFormState(yaml);
- assert.equal(result.ok, false);
- assert.match(result.error, /YAML editor/);
- assert.equal(yaml, original);
- }
-});
-
test("presents step timeout seconds as durations and serializes them numerically", () => {
const yaml = `name: Timed\ntrigger: { on: message_posted }\nsteps: [{ id: s1, timeout_secs: 3602, action: send_message, text: hi }]\n`;
const state = accepted(yaml);
@@ -175,7 +149,6 @@ test("values the Form serializer would normalize are refused", () => {
`name: Test\ntrigger: { on: reaction_added, emoji: "" }\nsteps: [{ id: s1, action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, name: " spaced ", action: send_message, text: hi }]\n`,
`name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: send_message, text: hi, channel: "" }]\n`,
- `name: Test\ntrigger: { on: message_posted }\nsteps: [{ id: s1, action: call_webhook, url: https://example.com, headers: { " padded ": value } }]\n`,
];
for (const yaml of fixtures) assert.equal(yamlToFormState(yaml).ok, false);
@@ -260,3 +233,30 @@ test("unsupported legacy triggers and actions remain YAML-only without parsing i
assert.match(result.error, /Unsupported action.*YAML editor/);
}
});
+
+test("step IDs fill the first gap without interpreting numeric suffixes", () => {
+ const steps = ["step_9007199254740992", "step_1", "step_3"].map((id) => ({
+ id,
+ action: "delay",
+ duration: "1s",
+ }));
+ assert.equal(nextStepId(steps), "step_2");
+ assert.equal(nextStepId([]), "step_1");
+});
+
+test("invalid nonblank timeout text survives serialization for validation", () => {
+ for (const timeoutSecs of ["oops", "0s", "1.5", "9007199254740992"]) {
+ assert.equal(
+ parseYaml(formStateToYaml(sendMessageState({ timeoutSecs }))).steps[0]
+ .timeout_secs,
+ timeoutSecs,
+ );
+ }
+ for (const timeoutSecs of [undefined, "", " "]) {
+ assert.equal(
+ parseYaml(formStateToYaml(sendMessageState({ timeoutSecs }))).steps[0]
+ .timeout_secs,
+ undefined,
+ );
+ }
+});
diff --git a/src/bundled/workflows/workflowFormTypes.ts b/src/bundled/workflows/workflowFormTypes.ts
index dda7bcab..38314a5c 100644
--- a/src/bundled/workflows/workflowFormTypes.ts
+++ b/src/bundled/workflows/workflowFormTypes.ts
@@ -50,10 +50,14 @@ export const ACTION_LABELS: Record = {
send_message: "Send Message",
};
-function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
- if (!timeoutSecs) return undefined;
+function parseTimeoutSecs(
+ timeoutSecs: string | undefined,
+): number | string | undefined {
+ if (!timeoutSecs?.trim()) return undefined;
const parsed = parseDurationSeconds(timeoutSecs);
- return parsed !== null && parsed > 0 ? parsed : undefined;
+ // Keep invalid input in the YAML draft so validation rejects it instead of
+ // silently saving a definition with no timeout. It also remains dirty on leave.
+ return parsed !== null && parsed > 0 ? parsed : timeoutSecs;
}
function actionFieldsForStep(step: StepFormState): Record {
@@ -104,16 +108,9 @@ export function formStateToYaml(state: WorkflowFormState): string {
return yamlStringify(workflow);
}
-const STEP_ID_PATTERN = /^step_(\d+)$/;
-
export function nextStepId(existingSteps: StepFormState[]): string {
const existingIds = new Set(existingSteps.map((s) => s.id));
- let maxN = 0;
- for (const id of existingIds) {
- const match = STEP_ID_PATTERN.exec(id);
- if (match) maxN = Math.max(maxN, Number(match[1]));
- }
- let n = maxN + 1;
+ let n = 1;
while (existingIds.has(`step_${n}`)) n++;
return `step_${n}`;
}
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index c0f3622c..fded6ab2 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -584,3 +584,84 @@ test("legacy deletion is a request, not verified runtime removal", async ({
1,
);
});
+
+test("invalid timeout text stays in the draft and blocks saves in both editor modes", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page.getByText("Step options", { exact: true }).click();
+ const timeout = page.getByLabel("Step timeout (optional)", { exact: true });
+ const yaml = page.getByLabel("Workflow YAML", { exact: true });
+ for (const input of ["oops", "0s", "1.5", "9007199254740992"]) {
+ await timeout.fill(input);
+ await expect(timeout).toHaveValue(input);
+ await expect(button("Save workflow")).toBeDisabled();
+ await expect(
+ page.getByRole("status").filter({ hasText: /timeout/ }),
+ ).toContainText("positive whole number");
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ expect(parseYaml(await yaml.inputValue()).steps[0].timeout_secs).toBe(
+ input,
+ );
+ await expect(button("Save workflow")).toBeDisabled();
+ await page.getByRole("tab", { name: "Form", exact: true }).click();
+ await page.getByText("Step options", { exact: true }).click();
+ await expect(timeout).toHaveValue(input);
+ }
+ await button("Close editor").click();
+ await expect(
+ page.getByRole("alertdialog", { name: "Leave this draft?" }),
+ ).toBeVisible();
+ await button("Keep editing").click();
+ await expect(timeout).toHaveValue("9007199254740992");
+ await timeout.fill("5m");
+ await expect(button("Save workflow")).toBeEnabled();
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(1);
+ expect(
+ parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml))
+ .steps[0].timeout_secs,
+ ).toBe(300);
+ await page.evaluate(() => window.workflowFixture.finish("succeeded"));
+ await expect(button("Save workflow")).toBeEnabled();
+ if (!(await timeout.isVisible()))
+ await page.getByText("Step options", { exact: true }).click();
+ await timeout.fill(" ");
+ await button("Save workflow").click();
+ await expect
+ .poll(() => page.evaluate(() => window.workflowFixture.calls.save))
+ .toBe(2);
+ expect(
+ parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml))
+ .steps[0],
+ ).not.toHaveProperty("timeout_secs");
+});
+
+test("both Add actions allocate unused IDs after a very large parsed ID", async ({
+ page,
+}) => {
+ await page.goto(url);
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await button("Message helper").click();
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ const yaml = page.getByLabel("Workflow YAML", { exact: true });
+ const definition = parseYaml(await yaml.inputValue());
+ definition.steps[0].id = "step_9007199254740992";
+ // JSON is YAML, and avoids testing a second serializer in this browser fixture.
+ await yaml.fill(JSON.stringify(definition));
+ await page.getByRole("tab", { name: "Form", exact: true }).click();
+ await button("Add Send Message").click();
+ await page
+ .getByLabel("Message text", { exact: true })
+ .nth(1)
+ .fill("Another message");
+ await button("Add Delay").click();
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ expect(
+ parseYaml(await yaml.inputValue()).steps.map((step) => step.id),
+ ).toEqual(["step_9007199254740992", "step_1", "step_2"]);
+});
From 6bc134dc4f282943e80f56e302aac4b57760d499 Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Mon, 14 Sep 2026 08:22:22 -0600
Subject: [PATCH 19/20] docs(workflows): align command errors with inspect-only
recovery
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
src/features/relay/outbox.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index de30817b..cfc2bef1 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -356,8 +356,8 @@ export function createOutbox(
: failedDelivery(attempt),
error: awaitsReceipt(latest.event)
? publishing && !(error instanceof PublishRejected)
- ? "Workflow delivery could not be confirmed; retain this operation to retry."
- : "Workflow command rejected; retain the draft and refresh before retrying."
+ ? "Workflow delivery could not be confirmed; inspect recent activity before submitting another command."
+ : "Workflow command rejected; retain the draft and refresh the saved configuration."
: `${
attempt.previousDelivery === "unknown" ||
attempt.previousDelivery === "accepted"
From 24dc8fbc706671d81addaa07ac6dc702ec090a9b Mon Sep 17 00:00:00 2001
From: Brain
<1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Date: Mon, 14 Sep 2026 09:32:55 -0600
Subject: [PATCH 20/20] fix(workflows): preserve reconnect intent and prevent
generic replay
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
---
dev/relay-broker.mjs | 2 +-
dev/workflow-broker.test.mjs | 4 +-
docs/workflows.md | 7 +-
src/bundled/channels/OutboxStatus.tsx | 15 ++-
src/bundled/workflows/session-fixture.tsx | 55 +++++++++-
src/bundled/workflows/workflows.journey.mjs | 115 ++++++++++++++++++++
src/features/relay/outbox-receipts.test.ts | 66 +++++++----
src/features/relay/outbox.ts | 9 +-
src/features/relay/session.ts | 2 +-
src/features/workflows/capability.test.ts | 17 +++
src/features/workflows/capability.ts | 27 ++++-
src/features/workflows/session.test.ts | 101 ++++++++++++++++-
12 files changed, 381 insertions(+), 39 deletions(-)
diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs
index 4e1a2777..d3203a30 100644
--- a/dev/relay-broker.mjs
+++ b/dev/relay-broker.mjs
@@ -876,7 +876,7 @@ export function relayBrokerPlugin({
const signing = route === "/api/relay/sign";
const publishing = route === "/api/relay/publish";
if (signing || publishing) {
- if (filters?.kind !== 9) {
+ if (![7, 9].includes(filters?.kind)) {
try {
validateWorkflowEvent(
{ ...filters, pubkey: signing ? viewer : filters.pubkey },
diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs
index 7beb74c1..3e29dfc2 100644
--- a/dev/workflow-broker.test.mjs
+++ b/dev/workflow-broker.test.mjs
@@ -91,7 +91,7 @@ it("real broker scoped history signs exact GET path/cursor and captured principa
const first = await connectBrokerTransport(h.base);
const other = await connectBrokerTransport(h.base, undefined, "secondary");
expect(h.calls).toHaveLength(0);
- expect(first.writer.kinds).toEqual([9, 30620, 46020, 5]);
+ expect(first.writer.kinds).toEqual([7, 9, 30620, 46020, 5]);
await first.workflows.runs(id, cursor, signal());
await other.workflows.runs(id, undefined, signal());
await first.workflows.runs(id, undefined, signal());
@@ -254,7 +254,7 @@ it("existing backend signs only canonical workflow sign/publish with exact own e
}, existingBackend);
try {
const t = await connectBrokerTransport(h.base);
- expect(t.writer.kinds).toEqual([9, 30620, 46020, 5]);
+ expect(t.writer.kinds).toEqual([7, 9, 30620, 46020, 5]);
for (const input of [
template(),
template(46020),
diff --git a/docs/workflows.md b/docs/workflows.md
index 8e685793..3b3924b8 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -23,7 +23,8 @@ See the [capability contract](../src/features/workflows/types.ts).
The outbox journals intent/signature before publication. A verified echo does
not replace the result-bearing receipt. Restored commands never run automatically;
-result text stays ephemeral, outside the journal.
+result text stays ephemeral, outside the journal. Workflow intents cannot be replayed
+through generic Outbox Retry either; inspection and dismissal remain available.
**Check saved configuration** resolves an unknown save only when a fresh verified
head matches its owner, channel, UUID and exact signed revision. Missing/different
@@ -36,7 +37,9 @@ Legacy deletion can retain a visible definition; accepted delivery is not proof
of runtime cleanup. These backend limitations are displayed, not repaired here.
Reads begin on UI interest and stop on unmount or access loss; no background poll.
-Access loss purges private snapshots before callbacks. Drafts are editor-local,
+Transient socket recovery cancels stale reads but retains the draft and active
+HTTP receipt correlation; refresh checks current data. Actual access loss purges
+private snapshots before callbacks. Drafts are editor-local,
not durable, and never move between viewers or communities. The broker preserves
same-origin checks, signature validation, captured principal quotas, cancellation,
fixed upstream paths and bounded history/receipt bodies.
diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx
index a6091060..a52b6003 100644
--- a/src/bundled/channels/OutboxStatus.tsx
+++ b/src/bundled/channels/OutboxStatus.tsx
@@ -1,3 +1,4 @@
+import { isWorkflowOperation } from "../../features/workflows/protocol";
import { useState, useSyncExternalStore } from "react";
import type { RelayProfiler } from "../../features/relay/profiling";
import { RelayTimings } from "./RelayTimings";
@@ -43,11 +44,15 @@ export function OutboxStatus({
}[item.delivery]
}
{" "}
- {(item.delivery === "failed" || item.delivery === "unknown") && (
- outbox.retry(item.event.id)}>
- Retry
-
- )}{" "}
+ {!isWorkflowOperation(item.event) &&
+ (item.delivery === "failed" || item.delivery === "unknown") && (
+ outbox.retry(item.event.id)}
+ >
+ Retry
+
+ )}{" "}
{item.delivery !== "sending" && (
void) | undefined;
+let failPublish: ((error: Error) => void) | undefined;
const viewer = keypair();
const authority = keypair();
const secondChannel = "88888888-8888-4888-8888-888888888888";
@@ -52,6 +60,23 @@ function session(scope: string) {
viewer: viewer.pubkey,
relayAuthor: authority.pubkey,
media: () => undefined,
+ ...(writable
+ ? {
+ writer: {
+ kinds: [9, 30620, 46020, 5],
+ sign: async (template: Parameters[1]) =>
+ signed(viewer, template),
+ publish: () => {
+ publishCount++;
+ return new Promise((resolve, reject) => {
+ finishPublish = resolve;
+ failPublish = reject;
+ });
+ },
+ },
+ workflows: { runs: async () => ({ runs: [], next: null }) },
+ }
+ : {}),
async query(filters) {
return events.filter((event) =>
filters.some(
@@ -66,10 +91,13 @@ function session(scope: string) {
},
subscribe(callbacks) {
incoming = callbacks.receive;
+ traffic = callbacks;
+ callbacks.state({ status: "connected", routes: [] });
return { update() {}, retry() {}, dispose() {} };
},
},
{
+ outboxStorage: { load: () => [], save() {} },
readStateStorage: {
async update(change) {
journal = change(journal);
@@ -81,6 +109,25 @@ function session(scope: string) {
);
}
let owner = session(currentScope);
+Object.assign(window, {
+ workflowSessionFixture: {
+ publications: () => publishCount,
+ state: (status: "connected" | "retrying") =>
+ traffic.state({ status, routes: [] }),
+ settle: () =>
+ finishPublish?.(
+ 'response:{"run_id":"33333333-3333-4333-8333-333333333333"}',
+ ),
+ reject: () => failPublish?.(new PublishRejected("Fixture non-delivery")),
+ operations: () => owner.session.workflows.operations.snapshot(),
+ sendMessage: () =>
+ owner.session.outbox?.send({
+ kind: 9,
+ content: "Retryable message",
+ tags: [["h", fixtureChannel]],
+ }),
+ },
+});
let snapshot: RelaySnapshot = {
status: "ready",
generation,
@@ -145,6 +192,12 @@ function Fixture() {
{mounted && }
+ {writable && owner.session.outbox && (
+
+ )}
);
}
diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs
index fded6ab2..a8eb40b6 100644
--- a/src/bundled/workflows/workflows.journey.mjs
+++ b/src/bundled/workflows/workflows.journey.mjs
@@ -665,3 +665,118 @@ test("both Add actions allocate unused IDs after a very large parsed ID", async
parseYaml(await yaml.inputValue()).steps.map((step) => step.id),
).toEqual(["step_9007199254740992", "step_1", "step_2"]);
});
+
+test("real session reconnect retains unsaved YAML and an in-flight returned run ID", async ({
+ page,
+}) => {
+ await page.goto(url.replace("/fixture.html", "/session-fixture.html?writes"));
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await page.getByRole("combobox", { name: "Channel", exact: true }).click();
+ await page
+ .getByRole("option", { name: "First channel", exact: true })
+ .click();
+ await button("Fixture A helper").click();
+ await page.getByRole("tab", { name: "YAML", exact: true }).click();
+ const yaml = page.getByLabel("Workflow YAML", { exact: true });
+ const original = await yaml.inputValue();
+ const edited = original.replace(
+ "Hello from a fixture",
+ "Unsaved reconnect draft",
+ );
+ expect(edited).not.toBe(original);
+ await yaml.fill(edited);
+ await page.evaluate(() => window.workflowSessionFixture.state("retrying"));
+ await expect(page.getByText(/Connection interrupted/)).toBeVisible();
+ await expect(yaml).toHaveValue(edited);
+ await page.evaluate(() => window.workflowSessionFixture.state("connected"));
+ await button("Refresh configurations").click();
+ await expect(page.getByText(/Connection interrupted/)).toHaveCount(0);
+ await expect(yaml).toHaveValue(edited);
+ await yaml.fill(original);
+ await button("Run now").click();
+ try {
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowSessionFixture.publications()),
+ )
+ .toBe(1);
+ await page.evaluate(() => window.workflowSessionFixture.state("retrying"));
+ await expect(
+ page.getByText("Requesting a run…", { exact: true }),
+ ).toBeVisible();
+ } finally {
+ await page.evaluate(() => window.workflowSessionFixture.settle());
+ }
+ await expect(
+ page.getByText("Run requested. Inspect run history for its result.", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await page.getByText("Delivery details", { exact: true }).click();
+ await expect(
+ page.getByText("Returned run ID: 33333333-3333-4333-8333-333333333333", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(yaml).toHaveValue(original);
+ await button("Revoke selected channel").click();
+ await expect(yaml).toHaveCount(0);
+ await expect(
+ page.getByRole("region", { name: "Workflow operations" }),
+ ).toHaveCount(0);
+});
+
+test("generic Outbox offers message retry but no workflow replay", async ({
+ page,
+}) => {
+ await page.goto(url.replace("/fixture.html", "/session-fixture.html?writes"));
+ const button = (name) => page.getByRole("button", { name, exact: true });
+ await page.getByRole("combobox", { name: "Channel", exact: true }).click();
+ await page
+ .getByRole("option", { name: "First channel", exact: true })
+ .click();
+ await button("Fixture A helper").click();
+ await button("Run now").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowSessionFixture.publications()),
+ )
+ .toBe(1);
+ await page.evaluate(() => window.workflowSessionFixture.reject());
+ await expect(
+ page.getByText("Run request was rejected.", { exact: true }),
+ ).toBeVisible();
+ await page.getByText("Outbox · 1 items", { exact: true }).click();
+ const outbox = page
+ .locator("details")
+ .filter({ has: page.locator("summary", { hasText: /^Outbox ·/ }) });
+ await expect(outbox.getByText(/Not sent/)).toBeVisible();
+ await expect(
+ outbox.getByRole("button", { name: "Retry", exact: true }),
+ ).toHaveCount(0);
+ await page.evaluate(() => window.workflowSessionFixture.sendMessage());
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowSessionFixture.publications()),
+ )
+ .toBe(2);
+ await page.evaluate(() => window.workflowSessionFixture.reject());
+ const message = outbox
+ .getByRole("listitem")
+ .filter({ hasText: "Retryable message" });
+ await expect(message).toContainText("Not sent");
+ await message.getByRole("button", { name: "Retry", exact: true }).click();
+ try {
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.workflowSessionFixture.publications()),
+ )
+ .toBe(3);
+ } finally {
+ await page.evaluate(() => window.workflowSessionFixture.settle());
+ }
+ await expect(message).toContainText("Sent");
+ await expect(
+ outbox.getByRole("button", { name: "Retry", exact: true }),
+ ).toHaveCount(0);
+});
diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts
index 3401286c..64ac3c63 100644
--- a/src/features/relay/outbox-receipts.test.ts
+++ b/src/features/relay/outbox-receipts.test.ts
@@ -36,7 +36,7 @@ function setup(timeoutMs = 10000) {
},
};
const owner = createOutbox(key.pubkey, { sign, publish }, storage, {
- needsReceipt: (event) => [30620, 46020].includes(event.kind),
+ needsReceipt: (event) => [30620, 46020, 5].includes(event.kind),
onReceipt,
timeoutMs,
});
@@ -71,7 +71,9 @@ function setup(timeoutMs = 10000) {
content: "disabled workflow",
tags: [
["h", "channel"],
- ["d", "workflow"],
+ ...(kind === 5
+ ? [["a", `30620:${key.pubkey}:workflow`]]
+ : [["d", "workflow"]]),
],
}),
};
@@ -142,7 +144,7 @@ it("receipt waits are bounded and late results after disposal never publish", as
await vi.advanceTimersByTimeAsync(0);
expect(late.onReceipt).not.toHaveBeenCalled();
});
-it("restores signed command intent without sending, exact retry receives only duplicate outcome", async () => {
+it("restores signed command intent for inspection without generic replay", async () => {
const h = setup();
const id = h.send(46020);
await flush();
@@ -164,11 +166,9 @@ it("restores signed command intent without sending, exact retry receives only du
restored.outbox.retry(id);
await flush();
expect(sign).not.toHaveBeenCalled();
- expect(publish.mock.calls[0]?.[0]).toEqual(h.published());
- expect(receipt).toHaveBeenCalledExactlyOnceWith(
- h.published(),
- "duplicate: already processed",
- );
+ expect(publish).not.toHaveBeenCalled();
+ expect(receipt).not.toHaveBeenCalled();
+ expect(restored.outbox.snapshot()[0]?.delivery).toBe("unknown");
});
it("bounds receipt bytes while streaming before JSON decoding", async () => {
const cancel = vi.fn();
@@ -201,19 +201,37 @@ it("seen commands can dismiss retained receipts without another publication", as
expect(h.saved()).toEqual([]);
expect(h.publish).toHaveBeenCalledTimes(1);
});
-it("rejection text never journals command secrets; successful retry stays seen", async () => {
- const h = setup();
- const id = h.send();
- await flush();
- h.reject(new PublishRejected("PRIVATE"));
- await flush();
- expect(h.outbox.snapshot()[0]?.delivery).toBe("failed");
- expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
- h.outbox.retry(id);
- await flush();
- h.observe([h.published()]);
- h.settle("response:{}");
- await flush();
- expect(h.local.snapshot()[0]?.delivery).toBe("seen");
- expect(h.outbox.snapshot()).toEqual([]);
-});
+it.each([30620, 46020, 5])(
+ "rejection text never journals command secrets and generic retry cannot replay kind %s",
+ async (kind) => {
+ const h = setup();
+ const id = h.send(kind);
+ await flush();
+ h.reject(new PublishRejected("PRIVATE"));
+ await flush();
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed");
+ expect(JSON.stringify(h.saved())).not.toContain("PRIVATE");
+ h.outbox.retry(id);
+ await flush();
+ expect(h.publish).toHaveBeenCalledTimes(1);
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed");
+ },
+);
+
+it.each([30620, 46020, 5])(
+ "unknown kind %s is inspect-only while dismissal remains available",
+ async (kind) => {
+ const h = setup();
+ const id = h.send(kind);
+ await flush();
+ h.reject(new Error("connection lost"));
+ await flush();
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("unknown");
+ h.outbox.retry(id);
+ await flush();
+ expect(h.publish).toHaveBeenCalledTimes(1);
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("unknown");
+ await h.outbox.dismiss(id);
+ expect(h.outbox.snapshot()).toEqual([]);
+ },
+);
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index cfc2bef1..6679c769 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -1,3 +1,4 @@
+import { isWorkflowOperation } from "../workflows/protocol";
import { yieldToHost } from "./yield";
import { getEventHash, type EventTemplate } from "nostr-tools";
import { eventDto, type EventData, type RelayEvent } from "./events";
@@ -450,7 +451,13 @@ export function createOutbox(
},
retry(id: string) {
const item = find(id);
- if (!closed && item && !attempts.has(id)) {
+ // Workflow recovery is inspect/dismiss only, including restored intents.
+ if (
+ !closed &&
+ item &&
+ !isWorkflowOperation(item.event) &&
+ !attempts.has(id)
+ ) {
replace({ ...item, delivery: "sending", error: undefined });
schedule(id, undefined, item.delivery);
}
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 9bb2ee8b..5da6872b 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -1043,7 +1043,7 @@ export function createRelaySession(
requests.invalidate();
agentLibrary.clear();
archives.clear();
- workflows.clear();
+ workflows.interrupt();
channels.staleHeads();
unread.stale();
}
diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts
index b3c4f2a5..f7a3762f 100644
--- a/src/features/workflows/capability.test.ts
+++ b/src/features/workflows/capability.test.ts
@@ -328,3 +328,20 @@ it("dismissal cannot unlock an active echoed command", async () => {
await h.capability.operations.dismiss(operation);
expect(h.capability.operations.snapshot()).toEqual([]);
});
+
+it.each(["save", "trigger", "delete"] as const)(
+ "foreign definitions stay browsable but cannot be used for %s",
+ async (action) => {
+ const h = setup();
+ const foreign = { ...h.definition, owner: keypair().pubkey };
+ const write =
+ action === "save"
+ ? () => h.capability.save({ channelId, yaml, existing: foreign })
+ : () => h.capability[action](foreign);
+ expect(write).toThrow(/owner/i);
+ await flush();
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(() => h.capability.runs(foreign)).not.toThrow();
+ },
+);
diff --git a/src/features/workflows/capability.ts b/src/features/workflows/capability.ts
index d5ce8146..e2d85942 100644
--- a/src/features/workflows/capability.ts
+++ b/src/features/workflows/capability.ts
@@ -39,7 +39,12 @@ export function createWorkflows({
notify?: (listener: () => void) => void;
}) {
let closed = false;
- const views = new Set<{ clear(): void; emit(): void; dispose(): void }>();
+ const views = new Set<{
+ clear(): void;
+ interrupt(): void;
+ emit(): void;
+ dispose(): void;
+ }>();
const listeners = new Set<() => void>();
type Result = {
outcome: WorkflowOperation["outcome"];
@@ -152,6 +157,19 @@ export function createWorkflows({
}
const owner = {
clear,
+ interrupt() {
+ controller?.abort();
+ controller = undefined;
+ pending = undefined;
+ if (snapshot.status === "idle" || snapshot.status === "unavailable")
+ return;
+ snapshot = Object.freeze({
+ status: "error",
+ data: snapshot.data,
+ error:
+ "Connection interrupted. Refresh to check current workflow data; your draft is retained.",
+ });
+ },
emit,
dispose() {
disposed = true;
@@ -251,6 +269,8 @@ export function createWorkflows({
revision?: string,
) {
assertAccess(workflow);
+ if (workflow.owner !== viewer)
+ throw new Error("Only the workflow owner can modify or run it");
assertOperation(kind);
const tags = [
["h", workflow.channelId],
@@ -434,6 +454,11 @@ export function createWorkflows({
results.set(event.id, result);
rebuild();
},
+ interrupt() {
+ // Socket recovery retires reads, not editor intent or HTTP receipt interest.
+ for (const owned of views) owned.interrupt();
+ for (const owned of views) owned.emit();
+ },
clear() {
results.clear();
receiptInterest.clear();
diff --git a/src/features/workflows/session.test.ts b/src/features/workflows/session.test.ts
index d6a56eb4..88a18c5e 100644
--- a/src/features/workflows/session.test.ts
+++ b/src/features/workflows/session.test.ts
@@ -1,7 +1,14 @@
import { afterEach, expect, it, vi } from "vitest";
import { createRelaySession } from "../relay/session";
import type { RelayEvent } from "../relay/events";
-import { keypair, roster, signed, scriptedTransport } from "../relay/testing";
+import type { LiveCallbacks } from "../relay/live";
+import {
+ flush,
+ keypair,
+ roster,
+ signed,
+ scriptedTransport,
+} from "../relay/testing";
const channelId = "11111111-1111-4111-8111-111111111111";
const id = "22222222-2222-4222-8222-222222222222";
const relay = keypair(),
@@ -23,6 +30,7 @@ afterEach(() => {
function setup() {
const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
let incoming!: (events: readonly RelayEvent[]) => void;
+ let state!: LiveCallbacks["state"];
let resolveRuns!: (value: unknown) => void;
const runs = vi.fn(
(_id: string, _cursor: unknown, _signal: AbortSignal) =>
@@ -35,6 +43,7 @@ function setup() {
workflows: { runs },
subscribe(callbacks) {
incoming = callbacks.receive;
+ state = callbacks.state;
return { update() {}, retry() {}, dispose() {} };
},
});
@@ -43,6 +52,7 @@ function setup() {
...wire,
...owner,
runs,
+ state: (status: "connected" | "retrying") => state({ status, routes: [] }),
emit: (events: readonly RelayEvent[]) => incoming(events),
resolveRuns: (value: unknown) => resolveRuns(value),
};
@@ -294,3 +304,92 @@ it("session fresh definition read resolves a lost save, while ordinary echo does
expect(sign).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledTimes(1);
});
+
+it("transient reconnect cancels reads without purging authorized snapshots", async () => {
+ const h = setup();
+ h.emit([roster(relay, channelId, [viewer.pubkey], 1)]);
+ h.state("connected");
+ const definitions = h.session.workflows.definitions(channelId);
+ const loading = definitions.refresh();
+ await vi.waitFor(() => expect(h.pending).toHaveLength(1));
+ h.next().respond([definition]);
+ await loading;
+ const history = h.session.workflows.runs(reference);
+ const pending = history.refresh();
+ await vi.waitFor(() => expect(h.runs).toHaveBeenCalledTimes(1));
+ h.state("retrying");
+ expect(definitions.snapshot()).toMatchObject({
+ status: "error",
+ data: { items: [{ revision: definition.id }] },
+ });
+ expect(history.snapshot().status).toBe("error");
+ expect(h.runs.mock.calls[0]?.[2].aborted).toBe(true);
+ h.resolveRuns({ runs: [], next: null });
+ await pending;
+ expect(history.snapshot().status).toBe("error");
+ h.state("connected");
+ const refresh = definitions.refresh();
+ await vi.waitFor(() => expect(h.pending).toHaveLength(1));
+ h.next().respond([definition]);
+ await refresh;
+ expect(definitions.snapshot().status).toBe("ready");
+});
+
+it.each(["reconnect", "revoke", "dispose"])(
+ "in-flight run receipt across %s follows session authority",
+ async (transition) => {
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let traffic!: LiveCallbacks;
+ let settle!: (message: string) => void;
+ const publish = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ const owner = createRelaySession(
+ {
+ ...wire.transport,
+ writer: {
+ kinds: [46020],
+ sign: async (template) => signed(viewer, template),
+ publish,
+ },
+ workflows: { runs: async () => ({ runs: [], next: null }) },
+ subscribe(callbacks) {
+ traffic = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ },
+ { outboxStorage: { load: () => [], save() {} } },
+ );
+ owners.push(owner);
+ traffic.receive([roster(relay, channelId, [viewer.pubkey], 1)]);
+ traffic.state({ status: "connected", routes: [] });
+ const eventId = owner.session.workflows.trigger({
+ ...reference,
+ yaml: definition.content,
+ revision: definition.id,
+ createdAt: 10,
+ });
+ await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1));
+ if (transition === "reconnect")
+ traffic.state({ status: "retrying", routes: [] });
+ else if (transition === "revoke")
+ traffic.receive([roster(relay, channelId, [], 2)]);
+ else owner.dispose();
+ settle('response:{"run_id":"33333333-3333-4333-8333-333333333333"}');
+ if (transition === "reconnect") {
+ await vi.waitFor(() =>
+ expect(owner.session.workflows.operations.snapshot()[0]).toMatchObject({
+ eventId,
+ outcome: "succeeded",
+ runId: "33333333-3333-4333-8333-333333333333",
+ }),
+ );
+ } else {
+ await flush();
+ expect(owner.session.workflows.operations.snapshot()).toEqual([]);
+ }
+ },
+);