From 07e4fd21f61b3438f79edd57f7f347b6afb8e34b Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 08:04:09 -0400
Subject: [PATCH] fix(desktop): distinguish workflow preparation failures from
submission
Signed-off-by: Logan Johnson
---
.../workflows/triggerOperations.test.mjs | 5 +++
.../features/workflows/triggerOperations.ts | 5 ++-
.../workflows/ui/WorkflowTriggerFeedback.tsx | 13 ++++++++
.../ui/workflowTriggerFeedback.test.mjs | 32 +++++++++++++++++--
4 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/desktop/src/features/workflows/triggerOperations.test.mjs b/desktop/src/features/workflows/triggerOperations.test.mjs
index 7fb669afd8..fb2f3e3352 100644
--- a/desktop/src/features/workflows/triggerOperations.test.mjs
+++ b/desktop/src/features/workflows/triggerOperations.test.mjs
@@ -58,6 +58,7 @@ test("production owner retains exact signed payload after committed response los
release();
await assert.rejects(first, /response lost/);
assert.equal(owner.state(key).status, "error");
+ assert.equal(owner.state(key).failurePhase, "submit");
const result = await owner.run("workflow", scope);
assert.equal(result.runId, "run-1");
assert.equal(prepared, 1);
@@ -119,6 +120,10 @@ test("preflight rejection retries preparation without inventing a submitted even
assert.fail("preflight failure must not publish"),
});
await assert.rejects(owner.run("workflow", scope), /stale revision/);
+ assert.equal(
+ owner.state(owner.key("workflow", scope)).failurePhase,
+ "prepare",
+ );
await assert.rejects(owner.run("workflow", scope), /stale revision/);
assert.equal(tries, 2);
});
diff --git a/desktop/src/features/workflows/triggerOperations.ts b/desktop/src/features/workflows/triggerOperations.ts
index 55670288a3..1d2fd1676b 100644
--- a/desktop/src/features/workflows/triggerOperations.ts
+++ b/desktop/src/features/workflows/triggerOperations.ts
@@ -8,6 +8,7 @@ import type { RelayEvent, TriggerWorkflowResponse } from "@/shared/api/types";
export type TriggerState = {
status: "idle" | "pending" | "error" | "success";
error?: string;
+ failurePhase?: "prepare" | "submit";
result?: TriggerWorkflowResponse;
};
const idle: TriggerState = { status: "idle" };
@@ -61,7 +62,8 @@ export class WorkflowTriggerOperations {
let op = this.operations.get(key);
if (op?.inFlight) return op.inFlight;
// A settled success means the next explicit Trigger is a distinct run.
- // An error means Retry of the same event unless the user explicitly opts out.
+ // Submission errors retry the same event unless the user explicitly opts out.
+ // Preparation errors have not published anything and may prepare again.
if (!op || newRun || op.state.status === "success") {
if (!op && this.operations.size >= 256) {
for (const [oldKey, old] of this.operations) {
@@ -99,6 +101,7 @@ export class WorkflowTriggerOperations {
operation.state = {
status: "error",
error: error instanceof Error ? error.message : String(error),
+ failurePhase: operation.event ? "submit" : "prepare",
};
throw error;
} finally {
diff --git a/desktop/src/features/workflows/ui/WorkflowTriggerFeedback.tsx b/desktop/src/features/workflows/ui/WorkflowTriggerFeedback.tsx
index b915299728..0389ac5cc7 100644
--- a/desktop/src/features/workflows/ui/WorkflowTriggerFeedback.tsx
+++ b/desktop/src/features/workflows/ui/WorkflowTriggerFeedback.tsx
@@ -34,6 +34,19 @@ export function WorkflowTriggerFeedback({
Run created: {state.result?.runId}
);
+ if (state.failurePhase === "prepare")
+ return (
+
+
Workflow run was not started. {state.error}
+
Retry prepares a new request before submitting it.
+
+
+ );
return (
dom.window.close());
// Actual card + detail hook + IPC adapters. Unlike the former invocation-count
// assertion, this peer signs and commits an event, loses the response, then
// verifies the exact retry bytes and returns the same persisted run identity.
-test("card/detail share pending, accessible error/retry and confirmed-only success animation", async () => {
+test("card/detail distinguish preparation from ambiguous submission and animate only confirmed success", async () => {
const keys = generateSecretKey();
const pubkey = getPublicKey(keys);
localStorage.setItem(
@@ -92,6 +92,7 @@ test("card/detail share pending, accessible error/retry and confirmed-only succe
if (command === "get_run_approvals") return { approvals: [] };
if (command === "prepare_workflow_trigger") {
prepares++;
+ if (prepares === 1) throw new Error("revision lookup failed");
assert.equal(args.expectedRelayUrl, "wss://a.example");
assert.equal(args.expectedSignerPubkey, pubkey);
return finalizeEvent(
@@ -196,6 +197,26 @@ test("card/detail share pending, accessible error/retry and confirmed-only succe
fireEvent.click(
rendered.getByRole("menuitem", { name: "Trigger", exact: true }),
);
+ await waitFor(() =>
+ assert.match(
+ card.getByRole("alert").textContent,
+ /revision lookup failed/,
+ ),
+ );
+ for (const surface of [card, detail]) {
+ const text = surface.getByRole("alert").textContent;
+ assert.match(text, /Workflow run was not started/);
+ assert.doesNotMatch(text, /may already exist|same signed request/);
+ assert.equal(
+ surface.queryByRole("button", { name: "Start a distinct run…" }),
+ null,
+ );
+ }
+ assert.equal(posts.length, 0, "preparation failure cannot publish");
+ assert.equal(stack.firstChild, originalTile);
+ fireEvent.click(
+ card.getByRole("button", { name: "Retry trigger", exact: true }),
+ );
await waitFor(() => assert.equal(posts.length, 1));
assert.match(card.getByRole("status").textContent, /Triggering/);
assert.equal(
@@ -226,6 +247,13 @@ test("card/detail share pending, accessible error/retry and confirmed-only succe
),
);
assert.match(detail.getByRole("alert").textContent, /same signed request/);
+ for (const surface of [card, detail]) {
+ assert.doesNotMatch(
+ surface.getByRole("alert").textContent,
+ /was not started/,
+ );
+ assert.ok(surface.getByRole("button", { name: "Start a distinct run…" }));
+ }
assert.equal(
stack.firstChild,
originalTile,
@@ -236,7 +264,7 @@ test("card/detail share pending, accessible error/retry and confirmed-only succe
);
await waitFor(() => assert.equal(posts.length, 2));
assert.equal(posts[0], posts[1]);
- assert.equal(prepares, 1);
+ assert.equal(prepares, 2);
assert.equal(runs.size, 1);
await act(async () => {
resolvePost();