@desplega.ai/agent-swarm 1.63.0 → 1.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ authJsonToCredentialSelection,
4
+ authJsonToCredentials,
5
+ credentialsToAuthJson,
6
+ } from "../providers/codex-oauth/auth-json.js";
7
+ import {
8
+ AUTHORIZE_URL,
9
+ CLIENT_ID,
10
+ createAuthorizationFlow,
11
+ createState,
12
+ decodeJwt,
13
+ exchangeAuthorizationCode,
14
+ getAccountId,
15
+ JWT_CLAIM_PATH,
16
+ parseAuthorizationInput,
17
+ REDIRECT_URI,
18
+ refreshAccessToken,
19
+ resetFetchForTesting,
20
+ SCOPE,
21
+ setFetchForTesting,
22
+ TOKEN_URL,
23
+ } from "../providers/codex-oauth/flow.js";
24
+ import { generatePKCE } from "../providers/codex-oauth/pkce.js";
25
+ import type { CodexOAuthCredentials } from "../providers/codex-oauth/types.js";
26
+
27
+ describe("generatePKCE", () => {
28
+ it("produces distinct verifier/challenge pairs", async () => {
29
+ const a = await generatePKCE();
30
+ const b = await generatePKCE();
31
+ expect(a.verifier).not.toEqual(b.verifier);
32
+ expect(a.challenge).not.toEqual(b.challenge);
33
+ });
34
+
35
+ it("verifier is base64url (43 chars, URL-safe)", async () => {
36
+ const { verifier } = await generatePKCE();
37
+ expect(verifier.length).toBe(43);
38
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
39
+ });
40
+
41
+ it("challenge is base64url (43 chars, URL-safe)", async () => {
42
+ const { challenge } = await generatePKCE();
43
+ expect(challenge.length).toBe(43);
44
+ expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
45
+ });
46
+ });
47
+
48
+ describe("OAuth constants", () => {
49
+ it("has the correct public client ID", () => {
50
+ expect(CLIENT_ID).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
51
+ });
52
+
53
+ it("has the correct OAuth URLs", () => {
54
+ expect(AUTHORIZE_URL).toBe("https://auth.openai.com/oauth/authorize");
55
+ expect(TOKEN_URL).toBe("https://auth.openai.com/oauth/token");
56
+ expect(REDIRECT_URI).toBe("http://localhost:1455/auth/callback");
57
+ });
58
+
59
+ it("has the correct scope", () => {
60
+ expect(SCOPE).toBe("openid profile email offline_access");
61
+ });
62
+
63
+ it("has the correct JWT claim path", () => {
64
+ expect(JWT_CLAIM_PATH).toBe("https://api.openai.com/auth");
65
+ });
66
+ });
67
+
68
+ describe("createState", () => {
69
+ it("produces a 32-char hex string", () => {
70
+ const state = createState();
71
+ expect(state.length).toBe(32);
72
+ expect(state).toMatch(/^[0-9a-f]+$/);
73
+ });
74
+
75
+ it("produces different values each call", () => {
76
+ expect(createState()).not.toEqual(createState());
77
+ });
78
+ });
79
+
80
+ describe("parseAuthorizationInput", () => {
81
+ it("parses bare code", () => {
82
+ expect(parseAuthorizationInput("abc123")).toEqual({ code: "abc123" });
83
+ });
84
+
85
+ it("parses code=X&state=Y", () => {
86
+ expect(parseAuthorizationInput("code=abc&state=def")).toEqual({
87
+ code: "abc",
88
+ state: "def",
89
+ });
90
+ });
91
+
92
+ it("parses full redirect URL", () => {
93
+ expect(
94
+ parseAuthorizationInput("http://localhost:1455/auth/callback?code=abc&state=def"),
95
+ ).toEqual({ code: "abc", state: "def" });
96
+ });
97
+
98
+ it("parses code#state format", () => {
99
+ expect(parseAuthorizationInput("abc123#def456")).toEqual({
100
+ code: "abc123",
101
+ state: "def456",
102
+ });
103
+ });
104
+
105
+ it("returns empty for empty string", () => {
106
+ expect(parseAuthorizationInput("")).toEqual({});
107
+ });
108
+
109
+ it("returns empty for whitespace", () => {
110
+ expect(parseAuthorizationInput(" ")).toEqual({});
111
+ });
112
+ });
113
+
114
+ describe("decodeJwt", () => {
115
+ it("extracts chatgpt_account_id from a JWT", () => {
116
+ const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "acc-123" } };
117
+ const encoded = btoa(JSON.stringify(payload));
118
+ const token = `header.${encoded}.signature`;
119
+ const decoded = decodeJwt(token);
120
+ expect(decoded).not.toBeNull();
121
+ expect(decoded?.["https://api.openai.com/auth"]?.chatgpt_account_id).toBe("acc-123");
122
+ });
123
+
124
+ it("returns null for invalid JWT", () => {
125
+ expect(decodeJwt("not-a-jwt")).toBeNull();
126
+ expect(decodeJwt("a.b")).toBeNull();
127
+ });
128
+ });
129
+
130
+ describe("getAccountId", () => {
131
+ it("extracts account ID from access token", () => {
132
+ const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "c724a178-abc" } };
133
+ const encoded = btoa(JSON.stringify(payload));
134
+ const token = `header.${encoded}.signature`;
135
+ expect(getAccountId(token)).toBe("c724a178-abc");
136
+ });
137
+
138
+ it("returns null for JWT without claim", () => {
139
+ const payload = { sub: "user123" };
140
+ const encoded = btoa(JSON.stringify(payload));
141
+ const token = `header.${encoded}.signature`;
142
+ expect(getAccountId(token)).toBeNull();
143
+ });
144
+
145
+ it("returns null for empty string claim", () => {
146
+ const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "" } };
147
+ const encoded = btoa(JSON.stringify(payload));
148
+ const token = `header.${encoded}.signature`;
149
+ expect(getAccountId(token)).toBeNull();
150
+ });
151
+ });
152
+
153
+ describe("exchangeAuthorizationCode", () => {
154
+ afterEach(() => {
155
+ resetFetchForTesting();
156
+ });
157
+
158
+ it("constructs expected POST body", async () => {
159
+ let capturedBody: URLSearchParams | null = null;
160
+ setFetchForTesting(async (_input: RequestInfo | URL, init?: RequestInit) => {
161
+ capturedBody = init?.body as URLSearchParams;
162
+ return new Response(
163
+ JSON.stringify({
164
+ access_token: "at_123",
165
+ refresh_token: "rt_456",
166
+ expires_in: 3600,
167
+ }),
168
+ { headers: { "Content-Type": "application/json" } },
169
+ );
170
+ });
171
+
172
+ const result = await exchangeAuthorizationCode("code-abc", "verifier-xyz");
173
+ expect(result.type).toBe("success");
174
+ expect(capturedBody).not.toBeNull();
175
+ expect(capturedBody!.get("grant_type")).toBe("authorization_code");
176
+ expect(capturedBody!.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
177
+ expect(capturedBody!.get("code")).toBe("code-abc");
178
+ expect(capturedBody!.get("code_verifier")).toBe("verifier-xyz");
179
+ });
180
+
181
+ it("returns failed on HTTP error", async () => {
182
+ setFetchForTesting(() => new Response("Bad Request", { status: 400 }));
183
+ const result = await exchangeAuthorizationCode("code-abc", "verifier-xyz");
184
+ expect(result.type).toBe("failed");
185
+ });
186
+
187
+ it("returns failed on missing fields", async () => {
188
+ setFetchForTesting(
189
+ () =>
190
+ new Response(JSON.stringify({ access_token: "at" }), {
191
+ headers: { "Content-Type": "application/json" },
192
+ }),
193
+ );
194
+ const result = await exchangeAuthorizationCode("code-abc", "verifier-xyz");
195
+ expect(result.type).toBe("failed");
196
+ });
197
+ });
198
+
199
+ describe("refreshAccessToken", () => {
200
+ afterEach(() => {
201
+ resetFetchForTesting();
202
+ });
203
+
204
+ it("calls token endpoint with grant_type=refresh_token", async () => {
205
+ let capturedBody: URLSearchParams | null = null;
206
+ setFetchForTesting(async (_input: RequestInfo | URL, init?: RequestInit) => {
207
+ capturedBody = init?.body as URLSearchParams;
208
+ return new Response(
209
+ JSON.stringify({
210
+ access_token: "at_new",
211
+ refresh_token: "rt_new",
212
+ expires_in: 3600,
213
+ }),
214
+ { headers: { "Content-Type": "application/json" } },
215
+ );
216
+ });
217
+
218
+ const result = await refreshAccessToken("rt_old");
219
+ expect(result.type).toBe("success");
220
+ expect(capturedBody).not.toBeNull();
221
+ expect(capturedBody!.get("grant_type")).toBe("refresh_token");
222
+ expect(capturedBody!.get("refresh_token")).toBe("rt_old");
223
+ expect(capturedBody!.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
224
+ });
225
+
226
+ it("returns failed on HTTP error", async () => {
227
+ setFetchForTesting(() => new Response("Unauthorized", { status: 401 }));
228
+ const result = await refreshAccessToken("rt_old");
229
+ expect(result.type).toBe("failed");
230
+ });
231
+ });
232
+
233
+ describe("createAuthorizationFlow", () => {
234
+ it("includes required query parameters", async () => {
235
+ const { verifier, state, url } = await createAuthorizationFlow("agent-swarm");
236
+ expect(verifier).toBeTruthy();
237
+ expect(state).toBeTruthy();
238
+ expect(url).toContain(AUTHORIZE_URL);
239
+ expect(url).toContain("response_type=code");
240
+ expect(url).toContain("client_id=app_EMoamEEZ73f0CkXaXp7hrann");
241
+ expect(url).toContain("code_challenge_method=S256");
242
+ expect(url).toContain("id_token_add_organizations=true");
243
+ expect(url).toContain("codex_cli_simplified_flow=true");
244
+ expect(url).toContain("originator=agent-swarm");
245
+ });
246
+ });
247
+
248
+ describe("credentialsToAuthJson", () => {
249
+ it("produces exact format matching observed ~/.codex/auth.json", () => {
250
+ const creds: CodexOAuthCredentials = {
251
+ access: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature",
252
+ refresh: "rt_abc123",
253
+ expires: 1712678400000,
254
+ accountId: "c724a178-abc",
255
+ };
256
+
257
+ const authJson = credentialsToAuthJson(creds);
258
+ expect(authJson.auth_mode).toBe("chatgpt");
259
+ expect(authJson.OPENAI_API_KEY).toBeNull();
260
+ expect(authJson.tokens.access_token).toBe(creds.access);
261
+ expect(authJson.tokens.refresh_token).toBe(creds.refresh);
262
+ expect(authJson.tokens.account_id).toBe(creds.accountId);
263
+ expect(authJson.tokens.id_token).toBe(creds.access);
264
+ expect(authJson.last_refresh).toBe(new Date(creds.expires).toISOString());
265
+ });
266
+ });
267
+
268
+ describe("authJsonToCredentials", () => {
269
+ it("round-trips correctly", () => {
270
+ const creds: CodexOAuthCredentials = {
271
+ access: "at_123",
272
+ refresh: "rt_456",
273
+ expires: Date.now() + 3600000,
274
+ accountId: "acc-789",
275
+ };
276
+ const authJson = credentialsToAuthJson(creds);
277
+ const restored = authJsonToCredentials(authJson);
278
+ expect(restored.access).toBe(creds.access);
279
+ expect(restored.refresh).toBe(creds.refresh);
280
+ expect(restored.accountId).toBe(creds.accountId);
281
+ expect(Math.abs(restored.expires - creds.expires)).toBeLessThan(1000);
282
+ });
283
+ });
284
+
285
+ describe("authJsonToCredentialSelection", () => {
286
+ it("maps chatgpt auth.json to CODEX_OAUTH tracking info", () => {
287
+ const creds: CodexOAuthCredentials = {
288
+ access: "at_123",
289
+ refresh: "rt_456",
290
+ expires: Date.now() + 3600000,
291
+ accountId: "c724a178-3621-41bb-bdb5-7b6ca848c965",
292
+ };
293
+
294
+ const selection = authJsonToCredentialSelection(credentialsToAuthJson(creds));
295
+ expect(selection.keyType).toBe("CODEX_OAUTH");
296
+ expect(selection.index).toBe(0);
297
+ expect(selection.total).toBe(1);
298
+ expect(selection.keySuffix).toBe("8c965");
299
+ expect(selection.selected).toBe(creds.accountId);
300
+ });
301
+ });
302
+
303
+ describe("no secrets in source", () => {
304
+ it("CLIENT_ID is the public OpenAI client id", () => {
305
+ expect(CLIENT_ID).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
306
+ });
307
+ });
@@ -295,6 +295,24 @@ describe("parseStderrForErrors", () => {
295
295
  expect(tracker.hasErrors()).toBe(true);
296
296
  });
297
297
 
298
+ test("detects 'hit your limit' as rate limit error", () => {
299
+ const tracker = new SessionErrorTracker();
300
+ parseStderrForErrors("You've hit your limit for the day", tracker);
301
+
302
+ expect(tracker.hasErrors()).toBe(true);
303
+ expect(tracker.getErrors()).toHaveLength(1);
304
+ expect(tracker.getErrors()[0]!.type).toBe("stderr_error");
305
+ expect(tracker.getErrors()[0]!.message).toBe("You've hit your limit for the day");
306
+ });
307
+
308
+ test("detects 'hit your limit' case-insensitively", () => {
309
+ const tracker = new SessionErrorTracker();
310
+ parseStderrForErrors("Hit Your Limit · resets 3pm (UTC)", tracker);
311
+
312
+ expect(tracker.hasErrors()).toBe(true);
313
+ expect(tracker.getErrors()[0]!.message).toBe("Hit Your Limit · resets 3pm (UTC)");
314
+ });
315
+
298
316
  test("detects authentication errors", () => {
299
317
  const tracker = new SessionErrorTracker();
300
318
  parseStderrForErrors("Authentication failed: invalid key", tracker);
@@ -368,6 +386,37 @@ describe("parseStderrForErrors", () => {
368
386
  });
369
387
  });
370
388
 
389
+ describe("rate limit detection regex (runner)", () => {
390
+ // This regex is used in runner.ts to detect rate-limited failures from credential errors
391
+ const rateLimitRegex = /rate.?limit|hit your limit/i;
392
+
393
+ test("matches 'rate limit' with space", () => {
394
+ expect(rateLimitRegex.test("Rate limit hit: Too many requests")).toBe(true);
395
+ });
396
+
397
+ test("matches 'rate_limit' with underscore", () => {
398
+ expect(rateLimitRegex.test("rate_limit exceeded")).toBe(true);
399
+ });
400
+
401
+ test("matches 'ratelimit' without separator", () => {
402
+ expect(rateLimitRegex.test("ratelimit error")).toBe(true);
403
+ });
404
+
405
+ test("matches 'hit your limit' message", () => {
406
+ expect(rateLimitRegex.test("You've hit your limit · resets 3pm (UTC)")).toBe(true);
407
+ });
408
+
409
+ test("matches 'Hit Your Limit' case-insensitively", () => {
410
+ expect(rateLimitRegex.test("Hit Your Limit")).toBe(true);
411
+ });
412
+
413
+ test("does not match unrelated errors", () => {
414
+ expect(rateLimitRegex.test("Authentication failed")).toBe(false);
415
+ expect(rateLimitRegex.test("Server error 500")).toBe(false);
416
+ expect(rateLimitRegex.test("Connection timeout")).toBe(false);
417
+ });
418
+ });
419
+
371
420
  describe("parseRateLimitResetTime", () => {
372
421
  test("parses 'resets 3pm (UTC)' format", () => {
373
422
  const result = parseRateLimitResetTime(
@@ -432,7 +432,7 @@ describe("Workflow Engine v2 (Phase 3)", () => {
432
432
  expect(steps).toHaveLength(2);
433
433
  });
434
434
 
435
- test("validation halt (mustPass) fails the run", async () => {
435
+ test("validation halt (mustPass) fails the run when all branches fail", async () => {
436
436
  const registry = createTestRegistry();
437
437
  const def: WorkflowDefinition = {
438
438
  nodes: [
@@ -456,13 +456,109 @@ describe("Workflow Engine v2 (Phase 3)", () => {
456
456
 
457
457
  const run = getWorkflowRun(runId);
458
458
  expect(run!.status).toBe("failed");
459
- expect(run!.error).toContain("Validation failed");
459
+ expect(run!.error).toContain("Failed nodes: step1");
460
460
 
461
461
  const steps = getWorkflowRunStepsByRunId(runId);
462
462
  const nodeIds = steps.map((s) => s.nodeId);
463
463
  expect(nodeIds).not.toContain("step2");
464
464
  });
465
465
 
466
+ test("linear workflow: mustPass failure on non-entry node marks run as failed", async () => {
467
+ // Regression: when the failing mustPass node is NOT the entry node, the
468
+ // entry node's "completed" status must not count toward hasCompletedSteps,
469
+ // otherwise the run is incorrectly marked as partial-failure instead of failed.
470
+ const registry = createTestRegistry();
471
+ const def: WorkflowDefinition = {
472
+ nodes: [
473
+ {
474
+ id: "trigger",
475
+ type: "echo",
476
+ config: { message: "entry node completes" },
477
+ next: "validator",
478
+ },
479
+ {
480
+ id: "validator",
481
+ type: "echo",
482
+ config: { message: "will fail validation" },
483
+ validation: {
484
+ executor: "validate",
485
+ config: { shouldFail: true },
486
+ mustPass: true,
487
+ },
488
+ next: "action",
489
+ },
490
+ { id: "action", type: "echo", config: { message: "never reached" } },
491
+ ],
492
+ };
493
+
494
+ const workflow = makeWorkflow(def);
495
+ const runId = await startWorkflowExecution(workflow, {}, registry);
496
+
497
+ const run = getWorkflowRun(runId);
498
+ // Run should be failed — the only non-entry completed step is none
499
+ expect(run!.status).toBe("failed");
500
+ expect(run!.error).toContain("Failed nodes: validator");
501
+
502
+ const steps = getWorkflowRunStepsByRunId(runId);
503
+ const nodeIds = steps.map((s) => s.nodeId);
504
+ expect(nodeIds).toContain("trigger");
505
+ expect(nodeIds).toContain("validator");
506
+ expect(nodeIds).not.toContain("action");
507
+ });
508
+
509
+ test("mustPass failure cancels only the failed branch, not parallel branches", async () => {
510
+ const registry = createTestRegistry();
511
+ const def: WorkflowDefinition = {
512
+ nodes: [
513
+ {
514
+ id: "start",
515
+ type: "echo",
516
+ config: { message: "begin" },
517
+ next: ["branchA", "branchB"],
518
+ },
519
+ {
520
+ id: "branchA",
521
+ type: "echo",
522
+ config: { message: "branch A will fail validation" },
523
+ validation: {
524
+ executor: "validate",
525
+ config: { shouldFail: true },
526
+ mustPass: true,
527
+ },
528
+ next: "afterA",
529
+ },
530
+ { id: "afterA", type: "echo", config: { message: "after A — should NOT execute" } },
531
+ {
532
+ id: "branchB",
533
+ type: "echo",
534
+ config: { message: "branch B succeeds" },
535
+ next: "afterB",
536
+ },
537
+ { id: "afterB", type: "echo", config: { message: "after B — should execute" } },
538
+ ],
539
+ };
540
+
541
+ const workflow = makeWorkflow(def);
542
+ const runId = await startWorkflowExecution(workflow, {}, registry);
543
+
544
+ const run = getWorkflowRun(runId);
545
+ // Run should complete (not fail) because branchB succeeded
546
+ expect(run!.status).toBe("completed");
547
+ // Should note partial failure
548
+ expect(run!.error).toContain("Partial failure");
549
+ expect(run!.error).toContain("branchA");
550
+
551
+ const steps = getWorkflowRunStepsByRunId(runId);
552
+ const nodeIds = steps.map((s) => s.nodeId);
553
+ // branchA's successor should NOT have executed
554
+ expect(nodeIds).not.toContain("afterA");
555
+ // branchB's successor SHOULD have executed
556
+ expect(nodeIds).toContain("afterB");
557
+ // branchA step should be marked as failed
558
+ const branchAStep = steps.find((s) => s.nodeId === "branchA");
559
+ expect(branchAStep!.status).toBe("failed");
560
+ });
561
+
466
562
  test("validation failure without mustPass is advisory (allows completion)", async () => {
467
563
  const registry = createTestRegistry();
468
564
  const def: WorkflowDefinition = {
@@ -4,6 +4,7 @@ export const CREDENTIAL_POOL_VARS = [
4
4
  "ANTHROPIC_API_KEY",
5
5
  "OPENROUTER_API_KEY",
6
6
  "OPENAI_API_KEY",
7
+ "CODEX_OAUTH",
7
8
  ] as const;
8
9
 
9
10
  /**
@@ -21,7 +22,7 @@ export const PROVIDER_CREDENTIAL_VARS: Record<string, readonly string[]> = {
21
22
  claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
22
23
  // pi-mono accepts either router or anthropic keys
23
24
  pi: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"],
24
- codex: ["OPENAI_API_KEY"],
25
+ codex: ["OPENAI_API_KEY", "CODEX_OAUTH"],
25
26
  };
26
27
 
27
28
  /**
@@ -40,6 +41,7 @@ export function deriveProviderFromKeyType(keyType: string): string {
40
41
  case "OPENROUTER_API_KEY":
41
42
  return "pi";
42
43
  case "OPENAI_API_KEY":
44
+ case "CODEX_OAUTH":
43
45
  return "codex";
44
46
  default:
45
47
  return "claude";
@@ -226,7 +226,12 @@ export function parseStderrForErrors(stderr: string, tracker: SessionErrorTracke
226
226
  const lower = stderr.toLowerCase();
227
227
  const firstLine = stderr.trim().split("\n")[0] ?? stderr.trim();
228
228
 
229
- if (lower.includes("rate limit") || lower.includes("rate_limit") || lower.includes("429")) {
229
+ if (
230
+ lower.includes("rate limit") ||
231
+ lower.includes("rate_limit") ||
232
+ lower.includes("429") ||
233
+ lower.includes("hit your limit")
234
+ ) {
230
235
  tracker.addStderrError(firstLine);
231
236
  } else if (
232
237
  lower.includes("authentication") ||
@@ -37,6 +37,7 @@ export function checkpointStepFailure(
37
37
  error: string,
38
38
  retryCount: number,
39
39
  retryPolicy?: RetryPolicy,
40
+ options?: { markRunFailed?: boolean },
40
41
  ): { shouldRetry: boolean } {
41
42
  const now = new Date().toISOString();
42
43
 
@@ -55,7 +56,7 @@ export function checkpointStepFailure(
55
56
  return { shouldRetry: true };
56
57
  }
57
58
 
58
- // No retries left — mark step and run failed
59
+ // No retries left — mark step failed, and optionally the run too
59
60
  // Clear nextRetryAt so the poller stops picking this step up
60
61
  updateWorkflowRunStep(stepId, {
61
62
  status: "failed",
@@ -64,11 +65,14 @@ export function checkpointStepFailure(
64
65
  nextRetryAt: null,
65
66
  });
66
67
 
67
- updateWorkflowRun(runId, {
68
- status: "failed",
69
- error: `Step failed: ${error}`,
70
- finishedAt: now,
71
- });
68
+ const markRunFailed = options?.markRunFailed ?? true;
69
+ if (markRunFailed) {
70
+ updateWorkflowRun(runId, {
71
+ status: "failed",
72
+ error: `Step failed: ${error}`,
73
+ finishedAt: now,
74
+ });
75
+ }
72
76
 
73
77
  return { shouldRetry: false };
74
78
  }
@@ -244,13 +244,16 @@ export async function walkGraph(
244
244
  // Collect successors and check for errors/pauses
245
245
  const nextBatch = new Map<string, WorkflowNode>();
246
246
  let hasWaiting = false;
247
- let hasFailed = false;
248
247
 
249
248
  for (let i = 0; i < results.length; i++) {
250
249
  const result = results[i]!;
251
250
  if (result.outcome === "failed") {
252
- hasFailed = true;
253
- break;
251
+ // Check if the run was already marked failed in DB (e.g., executor error).
252
+ // If so, stop immediately. If not (mustPass validation), skip this
253
+ // node's successors but continue processing other branches.
254
+ const currentRun = getWorkflowRun(runId);
255
+ if (currentRun?.status === "failed") return;
256
+ continue;
254
257
  }
255
258
  if (result.outcome === "waiting") {
256
259
  hasWaiting = true;
@@ -267,7 +270,6 @@ export async function walkGraph(
267
270
  }
268
271
  }
269
272
 
270
- if (hasFailed) return; // Run already marked failed in executeStep
271
273
  if (hasWaiting) return; // Run paused, will be resumed by event
272
274
 
273
275
  // Convergence check — only wait for predecessors with active edges to
@@ -302,16 +304,46 @@ export async function walkGraph(
302
304
  const hasPendingRetries = finalSteps.some(
303
305
  (s) => s.status === "failed" && s.nextRetryAt != null,
304
306
  );
307
+ const failedSteps = finalSteps.filter((s) => s.status === "failed" && s.nextRetryAt == null);
308
+ // Exclude entry/trigger nodes when checking for completed steps — a trigger
309
+ // completing doesn't mean a meaningful branch succeeded. Without this filter,
310
+ // a linear workflow (trigger → mustPass validator → action) would be marked
311
+ // as partial-failure instead of failed when the validator fails.
312
+ const entryNodeIds = new Set(findEntryNodes(def).map((n) => n.id));
313
+ const hasCompletedSteps = finalSteps.some(
314
+ (s) => s.status === "completed" && !entryNodeIds.has(s.nodeId),
315
+ );
305
316
 
306
317
  if (hasWaitingSteps) {
307
318
  // Async tasks still in progress — set back to waiting for next event
308
319
  updateWorkflowRun(runId, { status: "waiting" });
309
320
  } else if (!hasPendingRetries) {
310
- updateWorkflowRun(runId, {
311
- status: "completed",
312
- context: ctx,
313
- finishedAt: new Date().toISOString(),
314
- });
321
+ if (failedSteps.length > 0 && !hasCompletedSteps) {
322
+ // All branches failed — mark run as failed
323
+ const failedNodeIds = failedSteps.map((s) => s.nodeId).join(", ");
324
+ updateWorkflowRun(runId, {
325
+ status: "failed",
326
+ error: `All branches failed. Failed nodes: ${failedNodeIds}`,
327
+ context: ctx,
328
+ finishedAt: new Date().toISOString(),
329
+ });
330
+ } else if (failedSteps.length > 0) {
331
+ // Partial failure — some branches succeeded, some failed.
332
+ // Mark as completed with error noting partial failure.
333
+ const failedNodeIds = failedSteps.map((s) => s.nodeId).join(", ");
334
+ updateWorkflowRun(runId, {
335
+ status: "completed",
336
+ error: `Partial failure: nodes [${failedNodeIds}] failed (mustPass validation), but other branches completed successfully`,
337
+ context: ctx,
338
+ finishedAt: new Date().toISOString(),
339
+ });
340
+ } else {
341
+ updateWorkflowRun(runId, {
342
+ status: "completed",
343
+ context: ctx,
344
+ finishedAt: new Date().toISOString(),
345
+ });
346
+ }
315
347
  }
316
348
  }
317
349
  }
@@ -532,8 +564,8 @@ async function executeStep(
532
564
 
533
565
  if (validationResult.outcome === "halt") {
534
566
  const errorMsg = "Validation failed (mustPass)";
535
- checkpointStepFailure(runId, stepId, errorMsg, 0);
536
- throw new Error(errorMsg);
567
+ checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, { markRunFailed: false });
568
+ return { outcome: "failed", successors: [] };
537
569
  }
538
570
 
539
571
  if (validationResult.outcome === "retry") {