@desplega.ai/agent-swarm 1.83.1 → 1.83.2
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 +139 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
package/src/http/poll.ts
CHANGED
|
@@ -42,11 +42,13 @@ import { json, jsonError } from "./utils";
|
|
|
42
42
|
* + workflow bus emit. See `src/be/budget-refusal-notify.ts`.
|
|
43
43
|
*/
|
|
44
44
|
function buildBudgetRefusedTrigger(refusal: {
|
|
45
|
-
cause: "agent" | "global";
|
|
45
|
+
cause: "agent" | "global" | "user";
|
|
46
46
|
agentSpend?: number;
|
|
47
47
|
agentBudget?: number;
|
|
48
48
|
globalSpend?: number;
|
|
49
49
|
globalBudget?: number;
|
|
50
|
+
userSpend?: number;
|
|
51
|
+
userBudget?: number;
|
|
50
52
|
resetAt: string;
|
|
51
53
|
}): { type: "budget_refused"; [key: string]: unknown } {
|
|
52
54
|
const trigger: { type: "budget_refused"; [key: string]: unknown } = {
|
|
@@ -58,6 +60,8 @@ function buildBudgetRefusedTrigger(refusal: {
|
|
|
58
60
|
if (refusal.agentBudget !== undefined) trigger.agentBudget = refusal.agentBudget;
|
|
59
61
|
if (refusal.globalSpend !== undefined) trigger.globalSpend = refusal.globalSpend;
|
|
60
62
|
if (refusal.globalBudget !== undefined) trigger.globalBudget = refusal.globalBudget;
|
|
63
|
+
if (refusal.userSpend !== undefined) trigger.userSpend = refusal.userSpend;
|
|
64
|
+
if (refusal.userBudget !== undefined) trigger.userBudget = refusal.userBudget;
|
|
61
65
|
return trigger;
|
|
62
66
|
}
|
|
63
67
|
|
|
@@ -180,7 +184,7 @@ export async function handlePoll(
|
|
|
180
184
|
// the capacity check so capacity AND budget gates share atomicity.
|
|
181
185
|
// Phase 5 also records the dedup row + captures the side-effect
|
|
182
186
|
// context here so the after-commit step can notify the lead.
|
|
183
|
-
const admission = canClaim(myAgentId, new Date());
|
|
187
|
+
const admission = canClaim(myAgentId, new Date(), pendingTask.requestedByUserId);
|
|
184
188
|
if (!admission.allowed) {
|
|
185
189
|
const utcDate = new Date().toISOString().slice(0, 10);
|
|
186
190
|
const dedup = recordBudgetRefusalNotification({
|
|
@@ -192,6 +196,8 @@ export async function handlePoll(
|
|
|
192
196
|
agentBudgetUsd: admission.agentBudget,
|
|
193
197
|
globalSpendUsd: admission.globalSpend,
|
|
194
198
|
globalBudgetUsd: admission.globalBudget,
|
|
199
|
+
userSpendUsd: admission.userSpend,
|
|
200
|
+
userBudgetUsd: admission.userBudget,
|
|
195
201
|
});
|
|
196
202
|
return {
|
|
197
203
|
trigger: buildBudgetRefusedTrigger(admission),
|
|
@@ -200,6 +206,7 @@ export async function handlePoll(
|
|
|
200
206
|
task: {
|
|
201
207
|
id: pendingTask.id,
|
|
202
208
|
task: pendingTask.task,
|
|
209
|
+
requestedByUserId: pendingTask.requestedByUserId,
|
|
203
210
|
slackChannelId: pendingTask.slackChannelId,
|
|
204
211
|
slackThreadTs: pendingTask.slackThreadTs,
|
|
205
212
|
slackUserId: pendingTask.slackUserId,
|
|
@@ -211,6 +218,8 @@ export async function handlePoll(
|
|
|
211
218
|
agentBudgetUsd: admission.agentBudget,
|
|
212
219
|
globalSpendUsd: admission.globalSpend,
|
|
213
220
|
globalBudgetUsd: admission.globalBudget,
|
|
221
|
+
userSpendUsd: admission.userSpend,
|
|
222
|
+
userBudgetUsd: admission.userBudget,
|
|
214
223
|
resetAt: admission.resetAt,
|
|
215
224
|
},
|
|
216
225
|
inserted: dedup.inserted,
|
|
@@ -303,10 +312,10 @@ export async function handlePoll(
|
|
|
303
312
|
// refusal, and the dedup is per-(task,date) so subsequent same-day
|
|
304
313
|
// refusals on the same lead-candidate are suppressed.
|
|
305
314
|
if (unassignedIds.length > 0) {
|
|
306
|
-
const
|
|
315
|
+
const candidateId = unassignedIds[0]!;
|
|
316
|
+
const candidateTask = getTaskById(candidateId);
|
|
317
|
+
const admission = canClaim(myAgentId, new Date(), candidateTask?.requestedByUserId);
|
|
307
318
|
if (!admission.allowed) {
|
|
308
|
-
const candidateId = unassignedIds[0]!;
|
|
309
|
-
const candidateTask = getTaskById(candidateId);
|
|
310
319
|
const utcDate = new Date().toISOString().slice(0, 10);
|
|
311
320
|
const dedup = recordBudgetRefusalNotification({
|
|
312
321
|
taskId: candidateId,
|
|
@@ -317,6 +326,8 @@ export async function handlePoll(
|
|
|
317
326
|
agentBudgetUsd: admission.agentBudget,
|
|
318
327
|
globalSpendUsd: admission.globalSpend,
|
|
319
328
|
globalBudgetUsd: admission.globalBudget,
|
|
329
|
+
userSpendUsd: admission.userSpend,
|
|
330
|
+
userBudgetUsd: admission.userBudget,
|
|
320
331
|
});
|
|
321
332
|
return {
|
|
322
333
|
trigger: buildBudgetRefusedTrigger(admission),
|
|
@@ -326,6 +337,7 @@ export async function handlePoll(
|
|
|
326
337
|
task: {
|
|
327
338
|
id: candidateTask.id,
|
|
328
339
|
task: candidateTask.task,
|
|
340
|
+
requestedByUserId: candidateTask.requestedByUserId,
|
|
329
341
|
slackChannelId: candidateTask.slackChannelId,
|
|
330
342
|
slackThreadTs: candidateTask.slackThreadTs,
|
|
331
343
|
slackUserId: candidateTask.slackUserId,
|
|
@@ -337,6 +349,8 @@ export async function handlePoll(
|
|
|
337
349
|
agentBudgetUsd: admission.agentBudget,
|
|
338
350
|
globalSpendUsd: admission.globalSpend,
|
|
339
351
|
globalBudgetUsd: admission.globalBudget,
|
|
352
|
+
userSpendUsd: admission.userSpend,
|
|
353
|
+
userBudgetUsd: admission.userBudget,
|
|
340
354
|
resetAt: admission.resetAt,
|
|
341
355
|
},
|
|
342
356
|
inserted: dedup.inserted,
|
package/src/http/schedules.ts
CHANGED
|
@@ -466,11 +466,11 @@ export async function handleSchedules(
|
|
|
466
466
|
const mergedTimezone =
|
|
467
467
|
parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
|
|
468
468
|
if (timing.mergedCron || timing.mergedInterval) {
|
|
469
|
-
// biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
|
|
470
469
|
body.nextRunAt = calculateNextRun({
|
|
471
470
|
cronExpression: timing.mergedCron,
|
|
472
471
|
intervalMs: timing.mergedInterval,
|
|
473
472
|
timezone: mergedTimezone,
|
|
473
|
+
// biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
|
|
474
474
|
} as any);
|
|
475
475
|
}
|
|
476
476
|
}
|
package/src/http/users.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
|
|
8
8
|
* every identity mutation lands an event row tagged `op:<sha256-16>`.
|
|
9
9
|
*
|
|
10
|
-
* Endpoint set
|
|
11
|
-
* are deferred to the MCP plan):
|
|
10
|
+
* Endpoint set:
|
|
12
11
|
*
|
|
13
12
|
* GET /api/users
|
|
14
13
|
* POST /api/users
|
|
@@ -16,6 +15,8 @@
|
|
|
16
15
|
* POST /api/users/unmapped/:kind/:externalId/resolve
|
|
17
16
|
* GET /api/users/:id
|
|
18
17
|
* PATCH /api/users/:id
|
|
18
|
+
* POST /api/users/:id/mcp-tokens
|
|
19
|
+
* DELETE /api/users/:id/mcp-tokens/:tokenId
|
|
19
20
|
* POST /api/users/:id/merge
|
|
20
21
|
* GET /api/users/:id/events
|
|
21
22
|
* POST /api/users/:id/identities
|
|
@@ -26,12 +27,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
26
27
|
import { z } from "zod";
|
|
27
28
|
import {
|
|
28
29
|
createUser,
|
|
30
|
+
deleteBudget,
|
|
29
31
|
deleteKv,
|
|
30
32
|
deleteUser,
|
|
31
33
|
getAllUsers,
|
|
32
34
|
getUserById,
|
|
33
35
|
listKv,
|
|
34
36
|
updateUser,
|
|
37
|
+
upsertBudget,
|
|
35
38
|
} from "../be/db";
|
|
36
39
|
import {
|
|
37
40
|
getUserIdentities,
|
|
@@ -39,7 +42,9 @@ import {
|
|
|
39
42
|
linkIdentity,
|
|
40
43
|
listUserEvents,
|
|
41
44
|
listUserTokens,
|
|
45
|
+
mintToken,
|
|
42
46
|
recordIdentityEvent,
|
|
47
|
+
revokeToken,
|
|
43
48
|
unlinkIdentity,
|
|
44
49
|
} from "../be/users";
|
|
45
50
|
import { getOperatorActor } from "./operator-actor";
|
|
@@ -64,6 +69,15 @@ function composeUser(userId: string, recentEventLimit = 5) {
|
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
function syncUserBudgetMirror(userId: string, dailyBudgetUsd: number | null | undefined): void {
|
|
73
|
+
if (dailyBudgetUsd === undefined) return;
|
|
74
|
+
if (dailyBudgetUsd === null) {
|
|
75
|
+
deleteBudget("user", userId);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
upsertBudget("user", userId, dailyBudgetUsd);
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
68
82
|
|
|
69
83
|
const listUsers = route({
|
|
@@ -203,6 +217,41 @@ const updateUserRoute = route({
|
|
|
203
217
|
auth: { apiKey: true },
|
|
204
218
|
});
|
|
205
219
|
|
|
220
|
+
const mintUserMcpTokenRoute = route({
|
|
221
|
+
method: "post",
|
|
222
|
+
path: "/api/users/{id}/mcp-tokens",
|
|
223
|
+
pattern: ["api", "users", null, "mcp-tokens"],
|
|
224
|
+
summary: "Mint a one-time plaintext MCP token for a user",
|
|
225
|
+
description:
|
|
226
|
+
"Returns the plaintext token exactly once. Subsequent reads only expose token summaries.",
|
|
227
|
+
tags: ["Users"],
|
|
228
|
+
params: z.object({ id: z.string() }),
|
|
229
|
+
body: z.object({
|
|
230
|
+
label: z.string().nullable().optional(),
|
|
231
|
+
}),
|
|
232
|
+
responses: {
|
|
233
|
+
200: { description: "Minted token plaintext, token summary and composed user" },
|
|
234
|
+
401: { description: "Unauthorized" },
|
|
235
|
+
404: { description: "User not found" },
|
|
236
|
+
},
|
|
237
|
+
auth: { apiKey: true },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const revokeUserMcpTokenRoute = route({
|
|
241
|
+
method: "delete",
|
|
242
|
+
path: "/api/users/{id}/mcp-tokens/{tokenId}",
|
|
243
|
+
pattern: ["api", "users", null, "mcp-tokens", null],
|
|
244
|
+
summary: "Revoke a user's MCP token",
|
|
245
|
+
tags: ["Users"],
|
|
246
|
+
params: z.object({ id: z.string(), tokenId: z.string() }),
|
|
247
|
+
responses: {
|
|
248
|
+
200: { description: "Composed user after token revocation" },
|
|
249
|
+
401: { description: "Unauthorized" },
|
|
250
|
+
404: { description: "User or token not found" },
|
|
251
|
+
},
|
|
252
|
+
auth: { apiKey: true },
|
|
253
|
+
});
|
|
254
|
+
|
|
206
255
|
const mergeUsersRoute = route({
|
|
207
256
|
method: "post",
|
|
208
257
|
path: "/api/users/{id}/merge",
|
|
@@ -370,6 +419,7 @@ export async function handleUsers(
|
|
|
370
419
|
try {
|
|
371
420
|
const { identities, ...userFields } = parsed.body;
|
|
372
421
|
const user = createUser(userFields);
|
|
422
|
+
syncUserBudgetMirror(user.id, userFields.dailyBudgetUsd);
|
|
373
423
|
for (const ident of identities ?? []) {
|
|
374
424
|
linkIdentity(user.id, ident.kind, ident.externalId, actor);
|
|
375
425
|
}
|
|
@@ -459,6 +509,60 @@ export async function handleUsers(
|
|
|
459
509
|
return true;
|
|
460
510
|
}
|
|
461
511
|
|
|
512
|
+
// ─── POST /api/users/:id/mcp-tokens ───────────────────────────────────────
|
|
513
|
+
if (mintUserMcpTokenRoute.match(req.method, pathSegments)) {
|
|
514
|
+
const parsed = await mintUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
|
|
515
|
+
if (!parsed) return true;
|
|
516
|
+
const actor = getOperatorActor(req, res);
|
|
517
|
+
if (!actor) return true;
|
|
518
|
+
if (!getUserById(parsed.params.id)) {
|
|
519
|
+
jsonError(res, "User not found", 404);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const { tokenId, plaintext } = mintToken(parsed.params.id, parsed.body.label ?? null, actor);
|
|
525
|
+
const token = listUserTokens(parsed.params.id).find((t) => t.id === tokenId);
|
|
526
|
+
json(res, { plaintext, token, user: composeUser(parsed.params.id) });
|
|
527
|
+
} catch (err) {
|
|
528
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to mint token", 500);
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ─── DELETE /api/users/:id/mcp-tokens/:tokenId ────────────────────────────
|
|
534
|
+
if (revokeUserMcpTokenRoute.match(req.method, pathSegments)) {
|
|
535
|
+
const parsed = await revokeUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
|
|
536
|
+
if (!parsed) return true;
|
|
537
|
+
const actor = getOperatorActor(req, res);
|
|
538
|
+
if (!actor) return true;
|
|
539
|
+
if (!getUserById(parsed.params.id)) {
|
|
540
|
+
jsonError(res, "User not found", 404);
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const tokenBelongsToUser = listUserTokens(parsed.params.id).some(
|
|
545
|
+
(token) => token.id === parsed.params.tokenId,
|
|
546
|
+
);
|
|
547
|
+
if (!tokenBelongsToUser) {
|
|
548
|
+
jsonError(res, "Token not found", 404);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
revokeToken(parsed.params.tokenId, actor);
|
|
554
|
+
json(res, { user: composeUser(parsed.params.id) });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const message = err instanceof Error ? err.message : "Failed to revoke token";
|
|
557
|
+
if (message.includes("Token not found")) {
|
|
558
|
+
jsonError(res, "Token not found", 404);
|
|
559
|
+
} else {
|
|
560
|
+
jsonError(res, message, 500);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
462
566
|
// ─── POST /api/users/:id/identities ────────────────────────────────────────
|
|
463
567
|
if (addIdentityRoute.match(req.method, pathSegments)) {
|
|
464
568
|
const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
|
|
@@ -625,6 +729,7 @@ export async function handleUsers(
|
|
|
625
729
|
jsonError(res, "User not found", 404);
|
|
626
730
|
return true;
|
|
627
731
|
}
|
|
732
|
+
syncUserBudgetMirror(parsed.params.id, parsed.body.dailyBudgetUsd);
|
|
628
733
|
|
|
629
734
|
// Budget event
|
|
630
735
|
if (
|
package/src/jira/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getOAuthTokens } from "../be/db-queries/oauth";
|
|
2
|
-
import { ensureToken } from "../oauth/ensure-token";
|
|
2
|
+
import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
|
|
3
3
|
import { getJiraMetadata } from "./metadata";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -36,8 +36,7 @@ export function getJiraCloudId(): string {
|
|
|
36
36
|
* - Prepends `https://api.atlassian.com/ex/jira/{cloudId}` to `path`.
|
|
37
37
|
* - Sets `Authorization: Bearer <token>` and `Accept: application/json`.
|
|
38
38
|
* - Sets `Content-Type: application/json` when a body is provided.
|
|
39
|
-
* - On 401:
|
|
40
|
-
* retries once.
|
|
39
|
+
* - On 401: forces a token refresh and retries once.
|
|
41
40
|
* - On 429: respects `Retry-After` (in seconds) with a single retry.
|
|
42
41
|
*
|
|
43
42
|
* Returns the raw `Response` — callers handle `response.json()`/`response.text()`
|
|
@@ -64,8 +63,7 @@ export async function jiraFetch(path: string, init?: RequestInit): Promise<Respo
|
|
|
64
63
|
let response = await send(token);
|
|
65
64
|
|
|
66
65
|
if (response.status === 401) {
|
|
67
|
-
|
|
68
|
-
await ensureToken("jira", 0);
|
|
66
|
+
await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
|
|
69
67
|
token = await getJiraAccessToken();
|
|
70
68
|
response = await send(token);
|
|
71
69
|
}
|
package/src/jira/oauth.ts
CHANGED
package/src/jira/sync.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
getTrackerSyncByExternalId,
|
|
22
22
|
updateTrackerSyncSwarmId,
|
|
23
23
|
} from "../be/db-queries/tracker";
|
|
24
|
-
import { ensureToken } from "../oauth/ensure-token";
|
|
24
|
+
import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
|
|
25
25
|
import { resolveTemplate } from "../prompts/resolver";
|
|
26
26
|
import { buildJiraContextKey } from "../tasks/context-key";
|
|
27
27
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
@@ -91,7 +91,7 @@ export async function resolveBotAccountId(): Promise<string | null> {
|
|
|
91
91
|
// Mirror jiraFetch's 401-retry pattern: a token may go stale between the
|
|
92
92
|
// proactive ensureToken call and the request reaching Atlassian.
|
|
93
93
|
if (res.status === 401) {
|
|
94
|
-
await
|
|
94
|
+
await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
|
|
95
95
|
tokens = getOAuthTokens("jira");
|
|
96
96
|
if (!tokens?.accessToken) {
|
|
97
97
|
console.warn("[Jira Sync] /me returned 401 and refresh produced no token");
|
|
@@ -18,6 +18,7 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
18
18
|
redirectUri: app.redirectUri,
|
|
19
19
|
scopes: app.scopes.split(","),
|
|
20
20
|
extraParams: metadata.extraParams ?? (metadata.actor ? { actor: metadata.actor } : undefined),
|
|
21
|
+
requiresRefreshTokenRotation: provider === "jira",
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
|
package/src/oauth/wrapper.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as oauth from "oauth4webapi";
|
|
2
|
-
import { storeOAuthTokens } from "../be/db-queries/oauth";
|
|
2
|
+
import { storeOAuthTokens, updateOAuthTokensAfterRefresh } from "../be/db-queries/oauth";
|
|
3
3
|
|
|
4
4
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -13,6 +13,12 @@ export interface OAuthProviderConfig {
|
|
|
13
13
|
scopes: string[];
|
|
14
14
|
/** Extra query params appended to the authorization URL (e.g. { actor: "app" } for Linear) */
|
|
15
15
|
extraParams?: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Provider rotates refresh tokens on every refresh. When true, a refresh
|
|
18
|
+
* response without a new refresh token is unusable because the old one may
|
|
19
|
+
* already be invalidated server-side.
|
|
20
|
+
*/
|
|
21
|
+
requiresRefreshTokenRotation?: boolean;
|
|
16
22
|
/**
|
|
17
23
|
* How to join `scopes` in the authorization URL.
|
|
18
24
|
*
|
|
@@ -160,7 +166,7 @@ export async function exchangeCode(
|
|
|
160
166
|
export async function refreshAccessToken(
|
|
161
167
|
config: OAuthProviderConfig,
|
|
162
168
|
refreshToken: string,
|
|
163
|
-
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> {
|
|
169
|
+
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
|
|
164
170
|
const body = new URLSearchParams({
|
|
165
171
|
grant_type: "refresh_token",
|
|
166
172
|
client_id: config.clientId,
|
|
@@ -183,23 +189,48 @@ export async function refreshAccessToken(
|
|
|
183
189
|
access_token: string;
|
|
184
190
|
token_type: string;
|
|
185
191
|
expires_in?: number;
|
|
192
|
+
scope?: string;
|
|
186
193
|
refresh_token?: string;
|
|
187
194
|
};
|
|
188
195
|
|
|
196
|
+
if (typeof data.access_token !== "string" || data.access_token.length === 0) {
|
|
197
|
+
throw new Error(`Token refresh failed: ${config.provider} response missing access_token`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
config.requiresRefreshTokenRotation &&
|
|
202
|
+
(typeof data.refresh_token !== "string" || data.refresh_token.length === 0)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Token refresh failed: ${config.provider} response did not include a rotated refresh_token`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
const expiresAt = data.expires_in
|
|
190
210
|
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
|
191
211
|
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
192
212
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
const nextRefreshToken = data.refresh_token ?? refreshToken;
|
|
214
|
+
try {
|
|
215
|
+
updateOAuthTokensAfterRefresh(config.provider, refreshToken, {
|
|
216
|
+
accessToken: data.access_token,
|
|
217
|
+
refreshToken: nextRefreshToken,
|
|
218
|
+
expiresAt,
|
|
219
|
+
scope: data.scope ?? null,
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
console.warn(
|
|
224
|
+
`[OAuth] Refusing to use refreshed ${config.provider} access token because persistence failed: ${message}`,
|
|
225
|
+
);
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
198
228
|
|
|
199
229
|
return {
|
|
200
230
|
accessToken: data.access_token,
|
|
201
231
|
refreshToken: data.refresh_token,
|
|
202
232
|
expiresIn: data.expires_in,
|
|
233
|
+
scope: data.scope,
|
|
203
234
|
};
|
|
204
235
|
}
|
|
205
236
|
|
|
@@ -395,6 +395,10 @@ class ClaudeSession implements ProviderSession {
|
|
|
395
395
|
this.config.prompt,
|
|
396
396
|
];
|
|
397
397
|
|
|
398
|
+
if (this.config.resumeSessionId) {
|
|
399
|
+
cmd.push("--resume", this.config.resumeSessionId);
|
|
400
|
+
}
|
|
401
|
+
|
|
398
402
|
if (this.config.additionalArgs?.length) {
|
|
399
403
|
cmd.push(...this.config.additionalArgs);
|
|
400
404
|
}
|
|
@@ -688,10 +692,11 @@ class ClaudeSession implements ProviderSession {
|
|
|
688
692
|
// Stale session retry: if process failed because session not found and we used --resume,
|
|
689
693
|
// strip --resume and retry with a fresh session
|
|
690
694
|
if (result.exitCode !== 0 && this.errorTracker.isSessionNotFound()) {
|
|
691
|
-
const hasResume =
|
|
695
|
+
const hasResume =
|
|
696
|
+
!!this.config.resumeSessionId || (this.config.additionalArgs || []).includes("--resume");
|
|
692
697
|
if (hasResume) {
|
|
693
698
|
console.log(
|
|
694
|
-
`\x1b[33m[${this.config.role}] Session
|
|
699
|
+
`\x1b[33m[${this.config.role}] Session resume failed for task ${this.config.taskId.slice(0, 8)} — retrying without --resume\x1b[0m`,
|
|
695
700
|
);
|
|
696
701
|
|
|
697
702
|
const freshArgs = (this.config.additionalArgs || []).filter((arg, idx, arr) => {
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
clampContextPercent,
|
|
72
72
|
computeContextUsedUnified,
|
|
73
73
|
} from "../utils/context-window";
|
|
74
|
+
import { SessionErrorTracker } from "../utils/error-tracker";
|
|
74
75
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
|
75
76
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
76
77
|
import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
|
|
@@ -413,6 +414,7 @@ class CodexSession implements ProviderSession {
|
|
|
413
414
|
private lastUsage: Usage | null = null;
|
|
414
415
|
private aborted = false;
|
|
415
416
|
private settled = false;
|
|
417
|
+
private readonly errorTracker = new SessionErrorTracker();
|
|
416
418
|
/**
|
|
417
419
|
* Result captured by `settle` but held back from `resolveCompletion` until
|
|
418
420
|
* `runSession`'s `finally` block has fully cleaned up (log writer flush,
|
|
@@ -951,9 +953,11 @@ class CodexSession implements ProviderSession {
|
|
|
951
953
|
}
|
|
952
954
|
if (event.type === "turn.failed" && !terminalError) {
|
|
953
955
|
terminalError = this.formatTerminalError(event.error.message);
|
|
956
|
+
this.errorTracker.processCodexUsageLimitMessage(event.error.message);
|
|
954
957
|
}
|
|
955
958
|
if (event.type === "error" && !terminalError) {
|
|
956
959
|
terminalError = this.formatTerminalError(event.message);
|
|
960
|
+
this.errorTracker.processCodexUsageLimitMessage(event.message);
|
|
957
961
|
}
|
|
958
962
|
}
|
|
959
963
|
} catch (err) {
|
|
@@ -970,6 +974,30 @@ class CodexSession implements ProviderSession {
|
|
|
970
974
|
});
|
|
971
975
|
return;
|
|
972
976
|
}
|
|
977
|
+
// The Codex CLI exits with code 1 after emitting a UsageLimitReached or
|
|
978
|
+
// other terminal error event. The SDK then throws "Codex Exec exited with
|
|
979
|
+
// code 1: Reading prompt from stdin" AFTER the event loop ends, which
|
|
980
|
+
// would overwrite the structured terminalError we already captured above.
|
|
981
|
+
// Preserve the structured error so the [usage-limit] prefix survives to
|
|
982
|
+
// the runner's rate-limit resolver.
|
|
983
|
+
if (terminalError) {
|
|
984
|
+
const cost = this.buildCostData(this.lastUsage, true);
|
|
985
|
+
this.emit({
|
|
986
|
+
type: "result",
|
|
987
|
+
cost,
|
|
988
|
+
isError: true,
|
|
989
|
+
errorCategory: terminalError.category ?? "turn_failed",
|
|
990
|
+
});
|
|
991
|
+
this.settle({
|
|
992
|
+
exitCode: 1,
|
|
993
|
+
sessionId: this._sessionId,
|
|
994
|
+
cost,
|
|
995
|
+
isError: true,
|
|
996
|
+
failureReason: terminalError.message,
|
|
997
|
+
rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
|
|
998
|
+
});
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
973
1001
|
throw err;
|
|
974
1002
|
}
|
|
975
1003
|
|
|
@@ -987,6 +1015,7 @@ class CodexSession implements ProviderSession {
|
|
|
987
1015
|
cost,
|
|
988
1016
|
isError,
|
|
989
1017
|
failureReason: terminalError?.message,
|
|
1018
|
+
rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
|
|
990
1019
|
});
|
|
991
1020
|
} catch (err) {
|
|
992
1021
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1000,6 +1029,7 @@ class CodexSession implements ProviderSession {
|
|
|
1000
1029
|
cost,
|
|
1001
1030
|
isError: true,
|
|
1002
1031
|
failureReason: message,
|
|
1032
|
+
rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
|
|
1003
1033
|
});
|
|
1004
1034
|
} finally {
|
|
1005
1035
|
// Session-end summarization. Pure addition for codex — no behavior to
|