diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1eabeb1d5be..2ea8361d4cc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1468,6 +1468,10 @@ const DEFAULT_RELAY_WS_URL = "ws://localhost:3000"; const KIND_REACTION = 7; // NIP-25 reaction const KIND_DELETION = 5; // NIP-09 deletion const KIND_NIP29_DELETION = 9005; +/** Kind 30620 — replaceable workflow definition (`d` + `h`, YAML content). */ +const KIND_WORKFLOW_DEFINITION = 30620; +/** Kind 46020 — workflow trigger (`d` = workflow id). */ +const KIND_WORKFLOW_TRIGGER = 46020; const CHANNEL_WINDOW_AUX_KINDS = new Set([ KIND_REACTION, KIND_DELETION, @@ -3559,6 +3563,65 @@ function parseWorkflowDefinition( return parsed as Record; } +/** + * Soft YAML→object parse matching Rust `parse_definition`: on failure return + * `{}` so a malformed body cannot break the local save wire record after a + * successful relay submit. + */ +function parseWorkflowDefinitionSoft( + yamlDefinition: string, +): Record { + try { + const parsed = yamlParse(yamlDefinition); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Fall through to empty object (mirrors Rust). + } + return {}; +} + +/** Build the WorkflowWire record Rust returns from create/update inputs. */ +function workflowWireRecord(args: { + id: string; + channelId: string | null; + ownerPubkey: string; + yamlDefinition: string; + createdAt: number; + updatedAt: number; +}): MockWorkflow { + const definition = parseWorkflowDefinitionSoft(args.yamlDefinition); + const nameCandidate = + typeof definition.name === "string" ? definition.name.trim() : ""; + return { + id: args.id, + name: nameCandidate.length > 0 ? nameCandidate : args.id, + owner_pubkey: args.ownerPubkey, + channel_id: args.channelId, + definition, + status: "active", + created_at: args.createdAt, + updated_at: args.updatedAt, + }; +} + +/** Parse `response:{...}` / raw JSON from a command OK message (Rust-compatible). */ +function parseCommandResponseMessage(message: string): Record { + const json = message.startsWith("response:") + ? message.slice("response:".length) + : message; + try { + const parsed = JSON.parse(json || "{}") as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Ignore — same as Rust `.ok().and_then(...)` for webhook_secret. + } + return {}; +} + function handleGetChannelWorkflows(args: { channelId: string }) { return mockWorkflows.filter((w) => w.channel_id === args.channelId); } @@ -3576,10 +3639,49 @@ function handleGetWorkflow(args: { workflowId: string }) { return workflow; } -function handleCreateWorkflow(args: { - channelId: string; - yamlDefinition: string; -}) { +/** + * Handle `create_workflow`. Relay mode publishes kind 30620 (`d`+`h`, YAML + * content) via `submitSignedEvent` and does not touch mock workflow stores + * (D-042). Mirrors `commands/workflows.rs` / `events::build_workflow_definition`. + */ +async function handleCreateWorkflow( + args: { + channelId: string; + yamlDefinition: string; + }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (identity) { + const workflowId = crypto.randomUUID(); + const result = await submitSignedEvent(config, { + kind: KIND_WORKFLOW_DEFINITION, + content: args.yamlDefinition, + tags: [ + ["d", workflowId], + ["h", args.channelId], + ], + }); + const response = parseCommandResponseMessage(result.message); + const webhookSecret = + typeof response.webhook_secret === "string" + ? response.webhook_secret + : undefined; + const now = Math.floor(Date.now() / 1000); + const workflow = workflowWireRecord({ + id: workflowId, + channelId: args.channelId, + ownerPubkey: identity.pubkey, + yamlDefinition: args.yamlDefinition, + createdAt: now, + updatedAt: now, + }); + return { + ...workflow, + webhook_secret: webhookSecret, + }; + } + mockWorkflowIdCounter += 1; const now = Math.floor(Date.now() / 1000); const definition = parseWorkflowDefinition(args.yamlDefinition); @@ -3609,10 +3711,56 @@ function handleCreateWorkflow(args: { }; } -function handleUpdateWorkflow(args: { - workflowId: string; - yamlDefinition: string; -}) { +/** + * Handle `update_workflow`. Relay mode looks up the prior kind 30620 for `h` + * + created_at, then replaces via the same (pubkey, d) with a new 30620. + * Does not update mock stores. Mirrors Rust `update_workflow`. + */ +async function handleUpdateWorkflow( + args: { + workflowId: string; + yamlDefinition: string; + }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (identity) { + const prior = await relayQuery(config, [ + { + kinds: [KIND_WORKFLOW_DEFINITION], + "#d": [args.workflowId], + limit: 1, + }, + ]); + const priorEvent = prior[0]; + if (!priorEvent) { + throw new Error("workflow not found"); + } + const channelId = priorEvent.tags.find((tag) => tag[0] === "h")?.[1]; + if (!channelId) { + throw new Error("workflow not found"); + } + await submitSignedEvent(config, { + kind: KIND_WORKFLOW_DEFINITION, + content: args.yamlDefinition, + tags: [ + ["d", args.workflowId], + ["h", channelId], + ], + }); + const updatedAt = Math.floor(Date.now() / 1000); + const workflow = workflowWireRecord({ + id: args.workflowId, + channelId, + ownerPubkey: identity.pubkey, + yamlDefinition: args.yamlDefinition, + createdAt: priorEvent.created_at, + updatedAt, + }); + // Updates never rotate the webhook secret (Rust returns None). + return { ...workflow }; + } + const workflow = mockWorkflows.find((w) => w.id === args.workflowId); if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`); const definition = parseWorkflowDefinition(args.yamlDefinition); @@ -3630,7 +3778,26 @@ function handleUpdateWorkflow(args: { }; } -function handleDeleteWorkflow(args: { workflowId: string }) { +/** + * Handle `delete_workflow`. Relay mode publishes kind 5 with + * `a=30620:owner:id` and skips mock stores. Mirrors + * `events::build_workflow_delete`. + */ +async function handleDeleteWorkflow( + args: { workflowId: string }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (identity) { + const coord = `30620:${identity.pubkey}:${args.workflowId}`; + await submitSignedEvent(config, { + kind: KIND_DELETION, + content: "", + tags: [["a", coord]], + }); + return; + } + const index = mockWorkflows.findIndex((w) => w.id === args.workflowId); if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`); mockWorkflows.splice(index, 1); @@ -3699,7 +3866,25 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun { }; } -function handleTriggerWorkflow(args: { workflowId: string }) { +/** + * Handle `trigger_workflow`. Relay mode publishes kind 46020 with `d` and + * returns `{ event_id }` (Rust `trigger_workflow`). Mock mode keeps the + * existing run-shaped response for smoke. + */ +async function handleTriggerWorkflow( + args: { workflowId: string }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (identity) { + const result = await submitSignedEvent(config, { + kind: KIND_WORKFLOW_TRIGGER, + content: "", + tags: [["d", args.workflowId]], + }); + return { event_id: result.event_id }; + } + const workflow = mockWorkflows.find((w) => w.id === args.workflowId); if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`); const run = buildMockWorkflowRun(workflow); @@ -14009,18 +14194,22 @@ export function maybeInstallE2eTauriMocks() { case "create_workflow": return handleCreateWorkflow( payload as Parameters[0], + activeConfig, ); case "update_workflow": return handleUpdateWorkflow( payload as Parameters[0], + activeConfig, ); case "delete_workflow": return handleDeleteWorkflow( payload as Parameters[0], + activeConfig, ); case "trigger_workflow": return handleTriggerWorkflow( payload as Parameters[0], + activeConfig, ); case "get_workflow_runs": return handleGetWorkflowRuns( diff --git a/desktop/tests/e2e/bridge-relay-mutations.spec.ts b/desktop/tests/e2e/bridge-relay-mutations.spec.ts index e9af9b13e34..07933253bd6 100644 --- a/desktop/tests/e2e/bridge-relay-mutations.spec.ts +++ b/desktop/tests/e2e/bridge-relay-mutations.spec.ts @@ -513,3 +513,176 @@ test("send_channel_user_input_answer publishes a real kind 46041", async ({ ) .toBeTruthy(); }); + +test("workflow create/update/trigger/delete publish real 30620 / 46020 / 5", async ({ + page, +}) => { + // Mirrors desktop/src-tauri/src/commands/workflows.rs + events.rs: + // create/update → kind 30620 (d+h, YAML content); trigger → 46020 (d); + // delete → kind 5 with a=30620:owner:id. Issue title mentioned 30625; + // Rust has no such kind — only 30620. + await installBridge(page, { + mode: "relay", + user: "tyler", + relayHttpUrl: RELAY_HTTP_URL, + relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"), + }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible({ + timeout: 15_000, + }); + + const marker = `bridge-wf-${Date.now()}`; + const createYaml = [ + `name: ${marker}`, + "trigger:", + " on: manual", + "steps:", + " - id: step_1", + " action: noop", + ].join("\n"); + + const created = (await invokeBridgeCommand(page, "create_workflow", { + channelId: GENERAL_CHANNEL_ID, + yamlDefinition: createYaml, + })) as { + id?: string; + name?: string; + channel_id?: string; + owner_pubkey?: string; + }; + + expect(created.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(created.name).toBe(marker); + expect(created.channel_id).toBe(GENERAL_CHANNEL_ID); + expect(created.owner_pubkey?.toLowerCase()).toBe( + TEST_IDENTITIES.tyler.pubkey.toLowerCase(), + ); + + const workflowId = created.id as string; + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [30620], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#d": [workflowId], + "#h": [GENERAL_CHANNEL_ID], + limit: 5, + }, + ]) + ).find( + (event) => + event.kind === 30620 && + event.pubkey === TEST_IDENTITIES.tyler.pubkey && + event.content === createYaml && + event.tags.some((tag) => tag[0] === "d" && tag[1] === workflowId) && + event.tags.some( + (tag) => tag[0] === "h" && tag[1] === GENERAL_CHANNEL_ID, + ), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); + + const updatedName = `${marker}-updated`; + const updateYaml = [ + `name: ${updatedName}`, + "trigger:", + " on: manual", + "steps:", + " - id: step_1", + " action: noop", + ].join("\n"); + + const updated = (await invokeBridgeCommand(page, "update_workflow", { + workflowId, + yamlDefinition: updateYaml, + })) as { id?: string; name?: string }; + + expect(updated.id).toBe(workflowId); + expect(updated.name).toBe(updatedName); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [30620], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#d": [workflowId], + limit: 5, + }, + ]) + ).find( + (event) => + event.kind === 30620 && + event.content === updateYaml && + event.tags.some( + (tag) => tag[0] === "h" && tag[1] === GENERAL_CHANNEL_ID, + ), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); + + const triggerResult = (await invokeBridgeCommand(page, "trigger_workflow", { + workflowId, + })) as { event_id?: string }; + + expect(triggerResult.event_id).toMatch(/^[0-9a-f]{64}$/); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [46020], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#d": [workflowId], + limit: 10, + }, + ]) + ).find( + (event) => + event.id === triggerResult.event_id && + event.kind === 46020 && + event.content === "" && + event.tags.some((tag) => tag[0] === "d" && tag[1] === workflowId), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); + + await invokeBridgeCommand(page, "delete_workflow", { workflowId }); + + const deleteCoord = `30620:${TEST_IDENTITIES.tyler.pubkey}:${workflowId}`; + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [5], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#a": [deleteCoord], + limit: 10, + }, + ]) + ).find( + (event) => + event.kind === 5 && + event.content === "" && + event.tags.some((tag) => tag[0] === "a" && tag[1] === deleteCoord), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); +}); diff --git a/docs/crew/STATE.md b/docs/crew/STATE.md index a4c086176d6..672143ca8a8 100644 --- a/docs/crew/STATE.md +++ b/docs/crew/STATE.md @@ -154,9 +154,10 @@ Out of scope for this slice: - Phase 09 live probe results are recorded in [`verification/0010-evidence-on-thread-log-probes.md`](verification/0010-evidence-on-thread-log-probes.md). - Verification 0011 records the closed headless click-to-real-relay reaction - path (#133) and the #144 remaining-mutation pass (user-input answer, persona - publish, identity archive, managed-agent message) plus Rust confirmation of - workflow (still mock-only on the bridge) vs local-archive (confirmed local). + path (#133), the #144 remaining-mutation pass (user-input answer, persona + publish, identity archive, managed-agent message), and the #172 workflow + relay branches (30620 / 46020 / 5). Local-archive commands stay confirmed + local-only. - The desktop unit suite passes with 5045 tests passing, one skipped, and zero failures. - `buzz-acp` uses the process cwd for ordinary sessions and one validated, diff --git a/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md b/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md index cf5a828f7af..5b04a825328 100644 --- a/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md +++ b/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md @@ -1,8 +1,8 @@ # Verification 0011 — E2E bridge relay mutation audit -- **Date:** 2026-08-12 (updated for #144; original #133 2026-08-11) -- **Issue:** #133; follow-up #144 -- **Branch / commit:** `feat/144-bridge-relay-mutations` (see PR for SHA) +- **Date:** 2026-08-12 (updated for #172 / #144; original #133 2026-08-11) +- **Issue:** #133; follow-ups #144, #172 +- **Branch / commit:** see PRs for #144 and #172 SHAs - **Plan phase:** relay-backed desktop mutation coverage ## Boundary exercised @@ -35,7 +35,9 @@ agent ownership metadata remains a bridge-config profile injection; see Issue **#144** extended the same boundary to the remaining mock-only mutating commands listed under [(a) Already relay-aware](#a-already-relay-aware) and confirmed workflow / local-archive classification against the Rust -commands (see [Rust confirmation (#144)](#rust-confirmation-144)). +commands (see [Rust confirmation (#144 / #172)](#rust-confirmation-144--172)). +Issue **#172** then gave the four workflow commands real relay branches +(they had been misclassified as local-only on the bridge). ## CI coverage @@ -73,9 +75,9 @@ integration job duplicates services by design so it never becomes a hard Gate dependency. Crew-specific relay-mutation specs: - `evidence-reactions-relay.spec.ts` (#133) -- `bridge-relay-mutations.spec.ts` (#144) — archive/unarchive identity, +- `bridge-relay-mutations.spec.ts` (#144 / #172) — archive/unarchive identity, update_persona_and_publish, send_managed_agent_channel_message, - send_channel_user_input_answer + send_channel_user_input_answer, workflow create/update/trigger/delete ### How to run these specs locally @@ -152,6 +154,7 @@ Each test invokes the bridge command path under `mode: "relay"` and polls | persona catalog | `update_persona_and_publish` | 30175, tyler | `d`, content `display_name` | | managed agent msg | `send_managed_agent_channel_message` | 9, agent (alice) | `h`, `client` marker; agent signing + NIP-OA `x-auth-tag` | | user-input answer | `send_channel_user_input_answer` | 46041, tyler | `h`, `e`→request, `p`→requesting agent | +| workflows (#172) | `create_workflow` / `update_workflow` / `trigger_workflow` / `delete_workflow` | 30620 / 46020 / 5, tyler | create+update: `d`+`h` + YAML content; trigger: `d`; delete: `a=30620:owner:id` | ## Verification commands @@ -202,17 +205,15 @@ that command — confirmed where noted). | `update_persona_and_publish` | **Fixed in #144.** `publishMockPersonaHead` posts kind **30175** via `submitSignedEvent` (content/tags mirror `persona_event_content` / NIP-AP); mock catalog bookkeeping skipped on success. Also covers `set_persona_shared` through the same helper. Spec: `bridge-relay-mutations.spec.ts`. | | `archive_identity`, `unarchive_identity` | **Fixed in #144.** Relay posts kind **9035** / **9036** with `-` + `p` (+ optional `reason` / `replaced-by`); owner path attaches live kind:0 NIP-OA `auth` tag when present. Mirrors `identity_archive.rs` / `events::build_*_identity_request`. Spec: `bridge-relay-mutations.spec.ts`. | | `send_managed_agent_channel_message` | **Fixed in #144.** Relay path signs kind **9** as the managed agent (`MockManagedAgentSeed.privateKeyHex` → real nsec), attaches client markers, and sends NIP-OA `x-auth-tag` from the owner identity (mirrors `managed_agent_submission_auth_tag`). Without a real agent key the command errors visibly rather than silently mocking. Spec: `bridge-relay-mutations.spec.ts`. | +| `create_workflow`, `update_workflow`, `delete_workflow`, `trigger_workflow` | **Fixed in #172.** Relay publishes kind **30620** (`d`+`h`, YAML content), kind **5** (`a=30620:owner:id`), kind **46020** (`d`). Skips mock workflow stores. Mirrors `commands/workflows.rs` + `events::build_workflow_*`. Spec: `bridge-relay-mutations.spec.ts`. | ### (b) Mock-only traps -None remaining from the #133/#144 lists. Commands that still stop at the mock -boundary despite a real Rust Nostr mutation are recorded under -[(c)](#c-legitimately-local-only-or-confirmed--misclassified) as -**misclassified / follow-up** (workflows). +None remaining from the #133/#144/#172 lists. ### (c) Legitimately local-only or confirmed / misclassified -| Command(s) | Classification after Rust confirmation (#144) | +| Command(s) | Classification after Rust confirmation (#144 / #172) | |---|---| | `create_persona`, `update_persona`, `delete_persona`, `set_persona_active` | Local persona catalog persistence; `update_persona` queues local pending state. Publishing is the separate `update_persona_and_publish` / `set_persona_shared` path (now relay-aware). | | `create_channel_template` | E2E-only local template fixture state. | @@ -222,11 +223,10 @@ boundary despite a real Rust Nostr mutation are recorded under | `save_custom_harness`, `delete_custom_harness`, `connect_acp_runtime`, `install_acp_runtime` | Local ACP configuration/process installation. | | `plugin:process\|restart`, updater, opener, window/resource/plugin commands | OS/plugin/process shims, not relay mutations. | | Pairing and identity-recovery UI commands | Native pairing flow; relay interaction is outside this mocked command state. | -| `create_workflow`, `update_workflow`, `delete_workflow`, `trigger_workflow` | **Misclassified on the bridge side.** Rust (`commands/workflows.rs`) **does** publish via `submit_event`: kind **30620** definition (`d`+`h`), kind **5** delete targeting `a=30620:owner:id`, kind **46020** trigger (`d`). Bridge handlers remain mock-local. Follow-up candidate (not in #144 DoD command list). | | `create_save_subscription`, `delete_save_subscription`, `merge_save_subscription_kinds`, `remove_save_subscription_kind`, `archive_events` | **Confirmed local-only against Rust.** `archive/mod.rs`: `create_save_subscription` probes access then upserts SQLite; `archive_events` queries the relay then persists to the local archive DB — neither command publishes a mutation event. Bridge mock state matches that boundary. | | Sleep prevention, clipboard/download/save, and media picker/upload shims | OS/filesystem/native media shims; separate message commands publish relay events. | -## Rust confirmation (#144) +## Rust confirmation (#144 / #172) Checked against: @@ -236,7 +236,7 @@ Checked against: | Persona publish | `desktop/src-tauri/src/commands/personas/sharing.rs` | Kind 30175 via `submit_signed_event_at_with_keys`. Bridge now mirrors. | | Identity archive | `desktop/src-tauri/src/commands/identity_archive.rs` + `events.rs` | Kind 9035/9036. Bridge now mirrors. | | Managed agent message | `desktop/src-tauri/src/commands/messages.rs` | Kind 9 signed as agent + optional `x-auth-tag`. Bridge now mirrors when `privateKeyHex` is seeded. | -| Workflows | `desktop/src-tauri/src/commands/workflows.rs` + `events.rs` | **Do publish** 30620 / 5 / 46020. Bridge still mock-only → follow-up. | +| Workflows | `desktop/src-tauri/src/commands/workflows.rs` + `events.rs` | **Fixed in #172.** Kind **30620** definition (`d`+`h`, YAML content), kind **5** delete (`a=30620:owner:id`), kind **46020** trigger (`d`). No kind **30625** in Rust (issue title hypothesis only). Bridge relay branches mirror; return shapes match Rust (`WorkflowSaveWire` / `{ event_id }` for trigger). Spec: `bridge-relay-mutations.spec.ts`. | | Local archive | `desktop/src-tauri/src/archive/mod.rs` | **No mutation publish**; SQLite + query. Bridge local-only confirmed. | ## Limits