@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.
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/cli.tsx +20 -0
- package/src/commands/codex-login.ts +263 -0
- package/src/commands/runner.ts +86 -2
- package/src/http/index.ts +12 -1
- package/src/http/poll.ts +12 -0
- package/src/http/tasks.ts +27 -0
- package/src/providers/codex-adapter.ts +42 -0
- package/src/providers/codex-oauth/auth-json.ts +58 -0
- package/src/providers/codex-oauth/flow.ts +368 -0
- package/src/providers/codex-oauth/pkce.ts +26 -0
- package/src/providers/codex-oauth/storage.ts +121 -0
- package/src/providers/codex-oauth/types.ts +37 -0
- package/src/telemetry.ts +109 -0
- package/src/tests/codex-login.test.ts +155 -0
- package/src/tests/codex-oauth-storage.test.ts +306 -0
- package/src/tests/codex-oauth.test.ts +307 -0
- package/src/tests/error-tracker.test.ts +49 -0
- package/src/tests/workflow-engine-v2.test.ts +98 -2
- package/src/utils/credentials.ts +3 -1
- package/src/utils/error-tracker.ts +6 -1
- package/src/workflows/checkpoint.ts +10 -6
- package/src/workflows/engine.ts +43 -11
|
@@ -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("
|
|
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 = {
|
package/src/utils/credentials.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|
package/src/workflows/engine.ts
CHANGED
|
@@ -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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
567
|
+
checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, { markRunFailed: false });
|
|
568
|
+
return { outcome: "failed", successors: [] };
|
|
537
569
|
}
|
|
538
570
|
|
|
539
571
|
if (validationResult.outcome === "retry") {
|