@desplega.ai/agent-swarm 2.5.1 → 2.5.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.
Files changed (155) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
  37. package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/http/all-routes.ts +1 -0
  102. package/src/http/approval-requests.ts +46 -6
  103. package/src/http/index.ts +2 -0
  104. package/src/http/schedules.ts +26 -0
  105. package/src/http/scripts.ts +2 -2
  106. package/src/http/sessions.ts +93 -2
  107. package/src/http/user-roles.ts +173 -0
  108. package/src/http/users.ts +9 -0
  109. package/src/http/x.ts +2 -2
  110. package/src/integrations/chat-sdk/commands.ts +14 -1
  111. package/src/integrations/chat-sdk/inbound.ts +40 -5
  112. package/src/integrations/chat-sdk/outbound.ts +35 -10
  113. package/src/integrations/chat-sdk/reactions.ts +107 -0
  114. package/src/integrations/chat-sdk/relay.ts +75 -7
  115. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  116. package/src/integrations/reaction-events.ts +20 -0
  117. package/src/providers/amp-adapter.ts +1 -1
  118. package/src/rbac/legacy-policy.ts +3 -0
  119. package/src/rbac/permissions.ts +8 -0
  120. package/src/scheduler/schedule-task.ts +44 -3
  121. package/src/scheduler/scheduler.ts +4 -4
  122. package/src/scripts-runtime/loader.ts +8 -1
  123. package/src/slack/ack.ts +2 -1
  124. package/src/slack/reaction-shortcode.ts +4 -7
  125. package/src/tasks/worker-follow-up.ts +30 -0
  126. package/src/tests/approval-requests-notifications.test.ts +189 -0
  127. package/src/tests/chat-sdk-commands.test.ts +12 -0
  128. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  129. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  130. package/src/tests/chat-sdk-relay.test.ts +361 -10
  131. package/src/tests/chat-sdk-tools.test.ts +104 -0
  132. package/src/tests/docs-index-footprint.test.js +3 -3
  133. package/src/tests/http-users.test.ts +38 -0
  134. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  135. package/src/tests/rbac-engine.test.ts +2 -0
  136. package/src/tests/rbac-roles.test.ts +10 -0
  137. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  138. package/src/tests/script-api-credential.test.ts +173 -0
  139. package/src/tests/script-connections.test.ts +4 -0
  140. package/src/tests/script-run-config-scope.test.ts +120 -0
  141. package/src/tests/script-run-token.test.ts +75 -0
  142. package/src/tests/scripts-http.test.ts +19 -0
  143. package/src/tests/sessions-tree.test.ts +242 -0
  144. package/src/tests/user-roles-http.test.ts +204 -0
  145. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  146. package/src/tools/chat-reply.ts +2 -0
  147. package/src/tools/schedules/patch-schedule.ts +18 -0
  148. package/src/tools/schedules/update-schedule.ts +18 -0
  149. package/src/types.ts +16 -0
  150. package/src/workflows/approval-notifications.ts +214 -0
  151. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  152. package/src/workflows/executors/swarm-script.ts +2 -2
  153. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  154. package/templates/skills/code-quality/content.md +1 -1
  155. package/dist/rbac-roles-ff1ffmec.js +0 -12
@@ -6,12 +6,18 @@ import {
6
6
  } from "../../assets/key";
7
7
  import { isTerminalTaskStatus, TERMINAL_TASK_STATUSES } from "../../types";
8
8
  import { getDbClient, literalPrefixPattern } from "../db";
9
- import { type SessionViewer, sessionViewerCondition } from "./session-visibility";
9
+ import {
10
+ type SessionViewer,
11
+ sessionMineCondition,
12
+ sessionViewerCondition,
13
+ } from "./session-visibility";
10
14
 
11
15
  export type SessionReadState = "unread" | "read" | "later" | "done";
12
16
  export interface SessionFilters {
13
17
  /** Restrict roots to these task sources. */
14
18
  source?: string[];
19
+ /** Leave out roots of these task sources (the sessions v2 source filter). */
20
+ hideSource?: string[];
15
21
  /** Match root task text or title with a case-insensitive substring. */
16
22
  q?: string;
17
23
  /** Match the root requester, excluding NULL requester rows. */
@@ -36,6 +42,8 @@ export interface SessionFilters {
36
42
  now?: string;
37
43
  /** Exclude ownerless roots for user viewers to match the tree's visibility. */
38
44
  tree?: boolean;
45
+ /** For user viewers: only human-started roots of the viewer and shares (the "Mine" view). */
46
+ mine?: boolean;
39
47
  /** Match exactly this root (one session's summary, e.g. its read state). */
40
48
  rootId?: string;
41
49
  }
@@ -89,6 +97,9 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
89
97
  const sources = opts.source?.filter(Boolean) ?? [];
90
98
  if (sources.length)
91
99
  add({ sql: `r.source IN (${sources.map(() => "?").join(",")})`, params: sources });
100
+ const hidden = opts.hideSource?.filter(Boolean) ?? [];
101
+ if (hidden.length)
102
+ add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
92
103
  if (opts.q?.trim()) {
93
104
  const like = `%${opts.q.trim().toLowerCase()}%`;
94
105
  add({
@@ -106,6 +117,7 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
106
117
  if (opts.viewer) {
107
118
  add(sessionViewerCondition(opts.viewer));
108
119
  if (opts.tree) conditions.push("r.requestedByUserId IS NOT NULL");
120
+ if (opts.mine) add(sessionMineCondition(opts.viewer.userId));
109
121
  }
110
122
  if (opts.keyPrefix) {
111
123
  const { lo, hi } = keyPrefixRange(opts.keyPrefix);
@@ -322,25 +334,32 @@ export interface SessionTree {
322
334
  later: SessionSectionCounts;
323
335
  done: SessionSectionCounts;
324
336
  noGroup: SessionSectionCounts;
337
+ /** Each root source in this view with its session count, hidden sources included, most first. */
338
+ sources: { source: string; total: number }[];
325
339
  }
326
340
 
327
341
  export async function getSessionTree(
328
342
  actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
329
343
  now?: string,
344
+ opts: { mine?: boolean; hideSource?: string[] } = {},
330
345
  ): Promise<SessionTree> {
346
+ // No source filter in SQL: the source counts must include hidden sources,
347
+ // so the filter menu can offer them back. The loop skips them instead.
331
348
  const query = sessionSummaryQuery({
332
349
  ...(actor.kind === "user" ? { viewer: { userId: actor.userId, scope: "all" } as const } : {}),
333
350
  now,
334
351
  tree: true,
352
+ mine: opts.mine,
335
353
  });
336
354
  const rows = await getDbClient().query<{
337
355
  key: string;
356
+ source: string;
338
357
  lastActivityAt: string;
339
358
  latestStatus: string;
340
359
  readState: SessionReadState | null;
341
360
  needsYou: number;
342
361
  }>(
343
- `SELECT r."key", a.lastActivityAt, a.latestStatus, a.readState, a.needsYou
362
+ `SELECT r."key", r.source, a.lastActivityAt, a.latestStatus, a.readState, a.needsYou
344
363
  FROM (${query.sql}) a CROSS JOIN agent_tasks r ON r.id = a.root_id`,
345
364
  query.params,
346
365
  );
@@ -351,7 +370,10 @@ export async function getSessionTree(
351
370
  later: counts(),
352
371
  done: counts(),
353
372
  noGroup: counts(),
373
+ sources: [],
354
374
  };
375
+ const hidden = new Set(opts.hideSource);
376
+ const sources = new Map<string, number>();
355
377
  const groups = new Map<string, SessionTreeGroup>();
356
378
  const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
357
379
  section.total++;
@@ -360,6 +382,8 @@ export async function getSessionTree(
360
382
  if (row.needsYou) section.needsYou++;
361
383
  };
362
384
  for (const row of rows) {
385
+ sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
386
+ if (hidden.has(row.source)) continue;
363
387
  const key = sessionGroupKey(row.key);
364
388
  const noGroup =
365
389
  key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
@@ -380,5 +404,68 @@ export async function getSessionTree(
380
404
  }
381
405
  }
382
406
  result.groups = [...groups.values()].sort((a, b) => a.key.localeCompare(b.key));
407
+ result.sources = [...sources]
408
+ .map(([source, total]) => ({ source, total }))
409
+ .sort((a, b) => b.total - a.total || a.source.localeCompare(b.source));
383
410
  return result;
384
411
  }
412
+
413
+ /** Triage actions on a whole list: the bulk bar's "Select all N". */
414
+ export type SessionListTriage = "done" | "inbox" | "read" | "unread";
415
+
416
+ /** The read cursor of "unread". The dashboard and the my-swarm app write the same value. */
417
+ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
418
+
419
+ /**
420
+ * Apply one triage action to every session a sidebar list shows `userId`
421
+ * (the same filters as `GET /api/sessions?scope=tree`), in one statement.
422
+ * The rows match what the dashboard writes for one session: Done, back to
423
+ * the Inbox, read up to the newest activity, or unread. Returns the number
424
+ * of sessions written.
425
+ */
426
+ export async function triageSessionList(
427
+ userId: string,
428
+ filters: Pick<SessionFilters, "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "now">,
429
+ action: SessionListTriage,
430
+ ): Promise<number> {
431
+ const now = filters.now ?? new Date().toISOString();
432
+ const query = sessionSummaryQuery({
433
+ ...filters,
434
+ now,
435
+ viewer: { userId, scope: "all" },
436
+ tree: true,
437
+ });
438
+ const status = action === "done" ? "done" : "open";
439
+ // `read` moves the cursor to each session's newest activity, `unread` to the epoch.
440
+ const lastReadAt =
441
+ action === "read" ? "a.lastActivityAt" : action === "unread" ? `'${UNREAD_CURSOR}'` : "NULL";
442
+ const setsLastRead = action === "read" || action === "unread";
443
+ // The conflict rules copy `upsertInboxState` in src/be/db.ts (sticky readAt,
444
+ // lastReadAt only when the action sets it, snooze and dismiss cleared). Keep
445
+ // the two in step. `WHERE true` is required: SQLite cannot parse
446
+ // INSERT ... SELECT ... ON CONFLICT when the SELECT has no WHERE clause.
447
+ const result = await getDbClient().run(
448
+ `INSERT INTO inbox_item_state
449
+ (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, readAt, lastReadAt, createdAt, lastUpdatedAt)
450
+ SELECT ?, 'to_read', a.root_id, ?, NULL, NULL, ?, ?, ${lastReadAt}, ?, ?
451
+ FROM (${query.sql}) a WHERE true
452
+ ON CONFLICT(userId, itemType, itemId) DO UPDATE SET
453
+ status = excluded.status,
454
+ snoozeUntil = NULL,
455
+ dismissedAt = NULL,
456
+ doneAt = excluded.doneAt,
457
+ readAt = COALESCE(inbox_item_state.readAt, excluded.readAt),
458
+ lastReadAt = ${setsLastRead ? "excluded.lastReadAt" : "inbox_item_state.lastReadAt"},
459
+ lastUpdatedAt = excluded.lastUpdatedAt`,
460
+ [
461
+ userId,
462
+ status,
463
+ action === "done" ? now : null,
464
+ action === "read" ? now : null,
465
+ now,
466
+ now,
467
+ ...query.params,
468
+ ],
469
+ );
470
+ return result.changes;
471
+ }
@@ -27,3 +27,34 @@ export function sessionViewerCondition(viewer: SessionViewer): {
27
27
  params: [viewer.userId, viewer.userId],
28
28
  };
29
29
  }
30
+
31
+ /**
32
+ * Root sources that a machine starts: a workflow run, a schedule, or the
33
+ * swarm itself (heartbeat, follow-ups, budget and approval notices). A
34
+ * workflow root carries the user whose trigger fired it, so the requester
35
+ * alone does not tell a person's session from fan-out. `kapso-inbound` is the
36
+ * exception: a WhatsApp message a person sent, written with `system`.
37
+ */
38
+ export const MACHINE_SESSION_SOURCES = ["workflow", "schedule", "system"] as const;
39
+ const HUMAN_SYSTEM_TASK_TYPES = ["kapso-inbound"] as const;
40
+
41
+ /**
42
+ * The sessions v2 "Mine" view (alias `r`): roots this user started from a
43
+ * human channel (every source outside {@link MACHINE_SESSION_SOURCES}), plus
44
+ * sessions shared with this user or everyone. A list filter only: it narrows
45
+ * what a list shows and never changes who can open a session.
46
+ */
47
+ export function sessionMineCondition(userId: string | null): {
48
+ sql: string;
49
+ params: (string | null)[];
50
+ } {
51
+ const machine = MACHINE_SESSION_SOURCES.map(() => "?").join(",");
52
+ const humanSystem = HUMAN_SYSTEM_TASK_TYPES.map(() => "?").join(",");
53
+ return {
54
+ sql: `((r.requestedByUserId = ? AND (r.source NOT IN (${machine})
55
+ OR (r.source = 'system' AND r.taskType IN (${humanSystem}))))
56
+ OR EXISTS (SELECT 1 FROM session_shares s
57
+ WHERE s.rootTaskId = r.id AND s.revokedAt IS NULL AND (s.userId IS NULL OR s.userId = ?)))`,
58
+ params: [userId, ...MACHINE_SESSION_SOURCES, ...HUMAN_SYSTEM_TASK_TYPES, userId],
59
+ };
60
+ }
@@ -1715,6 +1715,18 @@ export async function getLatestLeadTaskInThread(
1715
1715
  return row ? rowToAgentTask(row) : null;
1716
1716
  }
1717
1717
 
1718
+ /**
1719
+ * `source` filter of the contextKey lookups below. `'chat'` also matches a
1720
+ * defer-task wake-up of a chat task: it keeps the chat contextKey with
1721
+ * `source = 'schedule'` (src/scheduler/schedule-task.ts) and continues the
1722
+ * same ask, so `/cancel`, steering and routing treat it as the chat task.
1723
+ * Binds `source` three times.
1724
+ */
1725
+ function contextKeySourceSql(alias = ""): string {
1726
+ return `(? IS NULL OR ${alias}source = ?
1727
+ OR (? = 'chat' AND ${alias}source = 'schedule' AND ${alias}taskType = 'deferred'))`;
1728
+ }
1729
+
1718
1730
  /**
1719
1731
  * ContextKey twins of the Slack thread lookups above, for channels that key a
1720
1732
  * conversation by `contextKey` only (Chat SDK). `source` narrows to the
@@ -1731,7 +1743,7 @@ export async function getAgentWorkingOnContextKey(
1731
1743
  const taskRow = await getDbClient().get<AgentTaskRow>(
1732
1744
  `SELECT agentId FROM agent_tasks
1733
1745
  WHERE contextKey = ?
1734
- AND (? IS NULL OR source = ?)
1746
+ AND ${contextKeySourceSql()}
1735
1747
  AND (? IS NULL
1736
1748
  OR status NOT IN (${TERMINAL_TASK_STATUSES.map(() => "?").join(", ")})
1737
1749
  OR finishedAt >= ?)
@@ -1741,6 +1753,7 @@ export async function getAgentWorkingOnContextKey(
1741
1753
  contextKey,
1742
1754
  source ?? null,
1743
1755
  source ?? null,
1756
+ source ?? null,
1744
1757
  endedSince ?? null,
1745
1758
  ...TERMINAL_TASK_STATUSES,
1746
1759
  endedSince ?? null,
@@ -1760,11 +1773,11 @@ export async function getLatestActiveTaskByContextKey(
1760
1773
  const row = await getDbClient().get<AgentTaskRow>(
1761
1774
  `SELECT * FROM agent_tasks
1762
1775
  WHERE contextKey = ?
1763
- AND (? IS NULL OR source = ?)
1776
+ AND ${contextKeySourceSql()}
1764
1777
  AND status IN ('in_progress', 'pending')
1765
1778
  ORDER BY createdAt DESC, rowid DESC
1766
1779
  LIMIT 1`,
1767
- [contextKey, source ?? null, source ?? null],
1780
+ [contextKey, source ?? null, source ?? null, source ?? null],
1768
1781
  );
1769
1782
 
1770
1783
  return row ? rowToAgentTask(row) : null;
@@ -1780,11 +1793,11 @@ export async function getLatestLeadTaskByContextKey(
1780
1793
  FROM agent_tasks t
1781
1794
  JOIN agents a ON a.id = t.agentId
1782
1795
  WHERE t.contextKey = ?
1783
- AND (? IS NULL OR t.source = ?)
1796
+ AND ${contextKeySourceSql("t.")}
1784
1797
  AND a.isLead = 1
1785
1798
  ORDER BY t.createdAt DESC, t.rowid DESC
1786
1799
  LIMIT 1`,
1787
- [contextKey, source ?? null, source ?? null],
1800
+ [contextKey, source ?? null, source ?? null, source ?? null],
1788
1801
  );
1789
1802
 
1790
1803
  return row ? rowToAgentTask(row) : null;
package/src/be/db.ts CHANGED
@@ -12629,6 +12629,8 @@ export async function upsertInboxState(opts: {
12629
12629
 
12630
12630
  // SQLite upsert via UNIQUE(userId, itemType, itemId). readAt is sticky: the
12631
12631
  // first non-null value written wins on every subsequent conflict.
12632
+ // `triageSessionList` in src/be/db/session-tree.ts copies these conflict
12633
+ // rules for whole-list triage. Keep the two in step.
12632
12634
  const row = await getDbClient().get<InboxItemStateRow>(
12633
12635
  `INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, readAt, lastReadAt, createdAt, lastUpdatedAt)
12634
12636
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -0,0 +1,8 @@
1
+ -- Mark the ephemeral session token a script run uses for its ctx.swarm SDK
2
+ -- calls, so it stands in for the executing agent instead of the operator key.
3
+ -- It shares the all-null binding shape of an agent (worker) token, so this
4
+ -- flag is what keeps the two lifecycles apart: worker-token queries
5
+ -- (AGENT_TOKEN_BINDING) filter on scriptRun = 0, so minting or revoking an
6
+ -- agent's worker token never touches a script run's in-flight token, and a
7
+ -- script run's token never counts as the agent's worker token.
8
+ ALTER TABLE session_tokens ADD COLUMN scriptRun INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,86 @@
1
+ -- Chat relay and defer-task (see 217_chat_sdk_state.sql for the obligation table).
2
+ --
3
+ -- 1. A deferred task posts nothing. `defer-task` completes the task with a
4
+ -- "deferred until ..." placeholder, which the terminal trigger enqueues like
5
+ -- any answer. `completeTask` writes `deferredAt` after the status, in the same
6
+ -- transaction, so this trigger drops the obligation before it commits.
7
+ -- 2. The wake-up task (`source='schedule'`, `taskType='deferred'`) keeps the
8
+ -- chat contextKey of the task it continues (src/scheduler/schedule-task.ts),
9
+ -- and relays like a chat task: it carries the real answer. The scheduler
10
+ -- grants the key only to the deferring agent continuing a task that itself
11
+ -- answered in chat; the triggers also require that the wake-up runs as the
12
+ -- same agent on the same thread as its parent.
13
+
14
+ CREATE TRIGGER drop_chat_relay_on_defer
15
+ AFTER UPDATE OF deferredAt ON agent_tasks
16
+ WHEN NEW.deferredAt IS NOT NULL AND OLD.deferredAt IS NULL
17
+ BEGIN
18
+ DELETE FROM chat_relay_obligations
19
+ WHERE task_id = NEW.id AND delivered_at IS NULL;
20
+ END;
21
+
22
+ -- Placeholders already queued by earlier deferrals.
23
+ DELETE FROM chat_relay_obligations
24
+ WHERE delivered_at IS NULL
25
+ AND given_up_at IS NULL
26
+ AND task_id IN (SELECT id FROM agent_tasks WHERE deferredAt IS NOT NULL);
27
+
28
+ DROP TRIGGER IF EXISTS enqueue_chat_relay_after_terminal_update;
29
+ DROP TRIGGER IF EXISTS enqueue_chat_relay_after_terminal_insert;
30
+
31
+ CREATE TRIGGER enqueue_chat_relay_after_terminal_update
32
+ AFTER UPDATE OF status ON agent_tasks
33
+ WHEN OLD.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
34
+ AND NEW.status IN ('completed', 'failed', 'cancelled')
35
+ AND NEW.contextKey GLOB 'task:chat-sdk:*'
36
+ AND (
37
+ NEW.source = 'chat'
38
+ OR (NEW.source = 'system' AND NEW.taskType = 'follow-up')
39
+ OR (
40
+ NEW.source = 'schedule'
41
+ AND NEW.taskType = 'deferred'
42
+ AND EXISTS (
43
+ SELECT 1 FROM agent_tasks parent
44
+ WHERE parent.id = NEW.parentTaskId
45
+ AND parent.agentId = NEW.agentId
46
+ AND parent.contextKey = NEW.contextKey
47
+ )
48
+ )
49
+ OR (
50
+ NEW.source = 'system'
51
+ AND NEW.taskType = 'resume'
52
+ AND EXISTS (SELECT 1 FROM agents WHERE agents.id = NEW.agentId AND agents.isLead = 1)
53
+ )
54
+ )
55
+ BEGIN
56
+ INSERT OR IGNORE INTO chat_relay_obligations (task_id)
57
+ VALUES (NEW.id);
58
+ END;
59
+
60
+ CREATE TRIGGER enqueue_chat_relay_after_terminal_insert
61
+ AFTER INSERT ON agent_tasks
62
+ WHEN NEW.status IN ('completed', 'failed', 'cancelled')
63
+ AND NEW.contextKey GLOB 'task:chat-sdk:*'
64
+ AND (
65
+ NEW.source = 'chat'
66
+ OR (NEW.source = 'system' AND NEW.taskType = 'follow-up')
67
+ OR (
68
+ NEW.source = 'schedule'
69
+ AND NEW.taskType = 'deferred'
70
+ AND EXISTS (
71
+ SELECT 1 FROM agent_tasks parent
72
+ WHERE parent.id = NEW.parentTaskId
73
+ AND parent.agentId = NEW.agentId
74
+ AND parent.contextKey = NEW.contextKey
75
+ )
76
+ )
77
+ OR (
78
+ NEW.source = 'system'
79
+ AND NEW.taskType = 'resume'
80
+ AND EXISTS (SELECT 1 FROM agents WHERE agents.id = NEW.agentId AND agents.isLead = 1)
81
+ )
82
+ )
83
+ BEGIN
84
+ INSERT OR IGNORE INTO chat_relay_obligations (task_id)
85
+ VALUES (NEW.id);
86
+ END;
@@ -0,0 +1,75 @@
1
+ -- Sessions v2: a one-time Inbox cleanup. Until now a session with no triage
2
+ -- row stayed in the Inbox forever, so long-time users opened an Inbox with
3
+ -- thousands of old sessions.
4
+ --
5
+ -- For every user who sees a session in the sessions v2 tree (the owner, a
6
+ -- direct share, or a share with everyone), the session moves to Done when it
7
+ -- reads as "read" for that user, its chain had no activity in the last 7
8
+ -- days, its latest task is finished (completed, failed, cancelled, or
9
+ -- superseded), and no approval in it waits for an answer. A stuck running
10
+ -- session or a pending approval stays in the Inbox. "Read" is the rule of
11
+ -- `sessionReadStateSql` in src/be/db/session-tree.ts: no triage row (quiet
12
+ -- for 7 days is read), or an open row whose read cursor covers the newest
13
+ -- activity. Snoozed, done,
14
+ -- dismissed, and unread sessions keep their state. New activity brings a
15
+ -- Done session back to the Inbox as unread, as for a session marked Done by
16
+ -- hand. Ownerless roots are not in the tree and get no rows.
17
+
18
+ CREATE TEMP TABLE _quiet_sessions AS
19
+ WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status) AS (
20
+ SELECT id, id, lastUpdatedAt, status FROM agent_tasks
21
+ WHERE parentTaskId IS NULL AND requestedByUserId IS NOT NULL
22
+ UNION ALL
23
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status
24
+ FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
25
+ ), activity AS (
26
+ -- SQLite takes the bare status from the row with MAX(lastUpdatedAt), as
27
+ -- the session list does. Keep MAX the only MIN/MAX aggregate here.
28
+ SELECT root_id, MAX(lastUpdatedAt) AS lastActivityAt, status AS latestStatus,
29
+ SUM(EXISTS (SELECT 1 FROM approval_requests ar
30
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
31
+ FROM chain GROUP BY root_id
32
+ ), quiet AS (
33
+ SELECT root_id, lastActivityAt FROM activity
34
+ WHERE lastActivityAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
35
+ AND latestStatus IN ('completed', 'failed', 'cancelled', 'superseded')
36
+ AND pendingApproval = 0
37
+ ), viewers AS (
38
+ SELECT r.requestedByUserId AS userId, q.root_id AS rootId, q.lastActivityAt
39
+ FROM quiet q JOIN agent_tasks r ON r.id = q.root_id
40
+ UNION
41
+ SELECT s.userId, q.root_id, q.lastActivityAt
42
+ FROM quiet q JOIN session_shares s ON s.rootTaskId = q.root_id
43
+ WHERE s.revokedAt IS NULL AND s.userId IS NOT NULL
44
+ UNION
45
+ SELECT u.id, q.root_id, q.lastActivityAt
46
+ FROM quiet q JOIN session_shares s ON s.rootTaskId = q.root_id CROSS JOIN users u
47
+ WHERE s.revokedAt IS NULL AND s.userId IS NULL
48
+ )
49
+ SELECT v.userId, v.rootId, v.lastActivityAt
50
+ FROM viewers v JOIN users u ON u.id = v.userId;
51
+
52
+ -- Open rows that read as read: their cursor covers the newest activity.
53
+ UPDATE inbox_item_state
54
+ SET status = 'done',
55
+ doneAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
56
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
57
+ WHERE itemType = 'to_read'
58
+ AND status = 'open'
59
+ AND EXISTS (
60
+ SELECT 1 FROM _quiet_sessions q
61
+ WHERE q.userId = inbox_item_state.userId
62
+ AND q.rootId = inbox_item_state.itemId
63
+ AND q.lastActivityAt <= COALESCE(inbox_item_state.lastReadAt, inbox_item_state.lastUpdatedAt)
64
+ );
65
+
66
+ -- No row yet: quiet for 7 days already reads as read.
67
+ INSERT OR IGNORE INTO inbox_item_state
68
+ (userId, itemType, itemId, status, doneAt, createdAt, lastUpdatedAt)
69
+ SELECT q.userId, 'to_read', q.rootId, 'done',
70
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
71
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
72
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
73
+ FROM _quiet_sessions q;
74
+
75
+ DROP TABLE _quiet_sessions;
@@ -1,5 +1,6 @@
1
1
  import type { PermissionVerb } from "../rbac";
2
2
  import { PermissionVerbSchema } from "../rbac";
3
+ import { invalidateTelemetryUserRole } from "../telemetry-identity";
3
4
  import { isEnvFlagEnabled } from "../utils/env-flag";
4
5
  import { getDb, getDbClient } from "./db";
5
6
 
@@ -39,8 +40,19 @@ export const BUILTIN_ROLES = [
39
40
  "session.presence",
40
41
  ],
41
42
  },
43
+ {
44
+ id: "rbac-role-viewer",
45
+ name: "viewer",
46
+ description: "Read-only: no task, approval or script writes.",
47
+ isBuiltin: true,
48
+ grantsAll: false,
49
+ verbs: ["favorite.write.own", "session.presence"],
50
+ },
42
51
  ] satisfies (BuiltinRoleDefinition & { isBuiltin: true })[];
43
52
 
53
+ /** A person holds exactly one of these; other roles are extras on top. */
54
+ export const PRIMARY_ROLE_NAMES: readonly string[] = ["admin", "requester", "viewer"];
55
+
44
56
  export type EffectiveGrant = {
45
57
  grantsAll: boolean;
46
58
  verbs: ReadonlySet<PermissionVerb>;
@@ -80,6 +92,8 @@ type RoleIdentityRow = {
80
92
  name: string;
81
93
  };
82
94
 
95
+ type GrantRoleRow = RoleIdentityRow & { grantsAll: number };
96
+
83
97
  type UserRoleRow = {
84
98
  id: string;
85
99
  name: string;
@@ -233,6 +247,130 @@ export async function listUserRoles(userId: string): Promise<UserRole[]> {
233
247
  return rows.map(roleRowToUserRole);
234
248
  }
235
249
 
250
+ export type RbacRoleCatalogEntry = UserRole & { primary: boolean; verbs: string[] };
251
+
252
+ /** Every role with its verbs, for the role picker. */
253
+ export async function listRbacRoles(): Promise<RbacRoleCatalogEntry[]> {
254
+ const client = getDbClient();
255
+ const roles = await client.query<UserRoleRow>(
256
+ "SELECT id, name, description, isBuiltin, grantsAll, createdAt FROM roles ORDER BY name",
257
+ );
258
+ const perms = await client.query<{ roleId: string; verb: string }>(
259
+ "SELECT roleId, verb FROM role_permissions ORDER BY verb",
260
+ );
261
+ return roles.map((row) => ({
262
+ ...roleRowToUserRole(row),
263
+ primary: PRIMARY_ROLE_NAMES.includes(row.name),
264
+ verbs: perms.filter((p) => p.roleId === row.id).map((p) => p.verb),
265
+ }));
266
+ }
267
+
268
+ export class InvalidRoleSetError extends Error {}
269
+
270
+ export class LastAdminError extends Error {
271
+ constructor() {
272
+ super("last_admin");
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Throw LastAdminError when removing this user would leave the swarm without
278
+ * an active grant-all user. Joins the caller's transaction.
279
+ */
280
+ export async function assertNotLastAdmin(userId: string): Promise<void> {
281
+ const client = getDbClient();
282
+ const self = await client.get<{ n: number }>(
283
+ `SELECT COUNT(*) AS n
284
+ FROM users u
285
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
286
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
287
+ WHERE u.status = 'active' AND u.id = ?`,
288
+ [userId],
289
+ );
290
+ if ((self?.n ?? 0) === 0) return;
291
+ const others = await client.get<{ n: number }>(
292
+ `SELECT COUNT(DISTINCT u.id) AS n
293
+ FROM users u
294
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
295
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
296
+ WHERE u.status = 'active' AND u.id <> ?`,
297
+ [userId],
298
+ );
299
+ if ((others?.n ?? 0) === 0) throw new LastAdminError();
300
+ }
301
+
302
+ /**
303
+ * Replace a user's RBAC roles. The set holds exactly one primary role
304
+ * (admin, requester or viewer) plus any extra roles. Refuses to leave the
305
+ * swarm without an active grant-all user.
306
+ */
307
+ export async function setUserRoles(
308
+ userId: string,
309
+ roleNames: string[],
310
+ actorUserId: string | null,
311
+ ): Promise<{ before: string[]; after: string[] }> {
312
+ const names = [...new Set(roleNames)];
313
+ if (names.filter((name) => PRIMARY_ROLE_NAMES.includes(name)).length !== 1) {
314
+ throw new InvalidRoleSetError(
315
+ `Pick exactly one primary role: ${PRIMARY_ROLE_NAMES.join(", ")}`,
316
+ );
317
+ }
318
+ return getDbClient().transaction(async (tx) => {
319
+ const wanted: GrantRoleRow[] = [];
320
+ for (const name of names) {
321
+ const row = await tx.get<GrantRoleRow>(
322
+ "SELECT id, name, grantsAll FROM roles WHERE name = ?",
323
+ [name],
324
+ );
325
+ if (!row) throw new InvalidRoleSetError(`Unknown role: ${name}`);
326
+ wanted.push(row);
327
+ }
328
+ const current = await tx.query<GrantRoleRow>(
329
+ `SELECT r.id, r.name, r.grantsAll
330
+ FROM principal_roles pr JOIN roles r ON r.id = pr.roleId
331
+ WHERE pr.principalType = 'user' AND pr.principalId = ?
332
+ ORDER BY r.name`,
333
+ [userId],
334
+ );
335
+ const wantsAll = wanted.some((r) => r.grantsAll === 1);
336
+ if (current.some((r) => r.grantsAll === 1) && !wantsAll) {
337
+ const user = await tx.get<{ status: string }>("SELECT status FROM users WHERE id = ?", [
338
+ userId,
339
+ ]);
340
+ if (user?.status === "active") {
341
+ const others = await tx.get<{ n: number }>(
342
+ `SELECT COUNT(DISTINCT u.id) AS n
343
+ FROM users u
344
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
345
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
346
+ WHERE u.status = 'active' AND u.id <> ?`,
347
+ [userId],
348
+ );
349
+ if ((others?.n ?? 0) === 0) throw new LastAdminError();
350
+ }
351
+ }
352
+ const wantedIds = new Set(wanted.map((r) => r.id));
353
+ for (const row of current) {
354
+ if (!wantedIds.has(row.id)) {
355
+ await tx.run(
356
+ `DELETE FROM principal_roles
357
+ WHERE principalType = 'user' AND principalId = ? AND roleId = ?`,
358
+ [userId, row.id],
359
+ );
360
+ }
361
+ }
362
+ for (const row of wanted) {
363
+ await tx.run(
364
+ `INSERT OR IGNORE INTO principal_roles (principalType, principalId, roleId, created_by)
365
+ VALUES ('user', ?, ?, ?)`,
366
+ [userId, row.id, actorUserId],
367
+ );
368
+ }
369
+ getDbClient().afterCommit(() => invalidateTelemetryUserRole(userId));
370
+ return { before: current.map((r) => r.name), after: wanted.map((r) => r.name).sort() };
371
+ });
372
+ }
373
+
236
374
  export function ensureRbacSeedsSynced(opts?: { quiet?: boolean }): RbacSeedSyncStats {
237
375
  const db = getDb();
238
376
  const desiredVerbsByRoleId = validatedBuiltinVerbSets();
@@ -0,0 +1,64 @@
1
+ import { type RunScriptInput, type RunScriptOutput, runScript } from "../../scripts-runtime/loader";
2
+ import { getAgentById } from "../db";
3
+ import { mintScriptRunToken, revokeSessionToken } from "../users";
4
+
5
+ /**
6
+ * Backstop lifetime of a script run's api credential. The run revokes its
7
+ * token the moment it ends (see `runScriptWithRunCredential`); this bounds a
8
+ * token whose run died without releasing. Comfortably above the 5m maximum
9
+ * script wall clock.
10
+ */
11
+ const SCRIPT_RUN_TOKEN_TTL_MS = 15 * 60 * 1000;
12
+
13
+ export type ScriptApiCredential = {
14
+ /**
15
+ * The bearer the script's `ctx.swarm` SDK calls present to the api, or
16
+ * `undefined` to let `runScript` fall back to the swarm API key.
17
+ */
18
+ apiKey: string | undefined;
19
+ /** Revoke the per-run token, if one was minted. Safe to call more than once. */
20
+ release: () => Promise<void>;
21
+ };
22
+
23
+ /**
24
+ * Resolve the api credential a script run acts as. When the run has a real
25
+ * executing agent, mint a short-lived token bound to it so `ctx.swarm` calls
26
+ * are scoped to that agent at the api, instead of carrying the swarm API key
27
+ * (the operator). System runs with no real agent (scheduler and workflow runs
28
+ * pass sentinel ids like "schedule"/"workflow") keep the operator key, their
29
+ * existing behavior. The caller must `release()` when the run ends.
30
+ */
31
+ export async function acquireScriptApiCredential(agentId: string): Promise<ScriptApiCredential> {
32
+ if (!(await getAgentById(agentId))) {
33
+ return { apiKey: undefined, release: async () => {} };
34
+ }
35
+ const token = await mintScriptRunToken(agentId, SCRIPT_RUN_TOKEN_TTL_MS);
36
+ let released = false;
37
+ return {
38
+ apiKey: token.plaintext,
39
+ release: async () => {
40
+ if (released) return;
41
+ released = true;
42
+ await revokeSessionToken(token.tokenId);
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Run a script with a per-run credential bound to its executing agent, and
49
+ * revoke that credential when the run ends: on success, a thrown error, a
50
+ * timeout or an abort. Every api-side script-run caller goes through here.
51
+ * The mint and revoke stay on the api side because `src/scripts-runtime/` is
52
+ * a no-DB zone (`scripts/check-db-boundary.sh`); `runScript` only receives the
53
+ * bearer.
54
+ */
55
+ export async function runScriptWithRunCredential(
56
+ input: Omit<RunScriptInput, "apiKey">,
57
+ ): Promise<RunScriptOutput> {
58
+ const credential = await acquireScriptApiCredential(input.agentId);
59
+ try {
60
+ return await runScript({ ...input, apiKey: credential.apiKey });
61
+ } finally {
62
+ await credential.release();
63
+ }
64
+ }