@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.
- package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
- package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
- package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
- package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
- package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
- package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
- package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
- package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
- package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
- package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
- package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
- package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
- package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
- package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
- package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
- package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
- package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
- package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
- package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
- package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
- package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
- package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
- package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
- package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
- package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
- package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
- package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
- package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
- package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
- package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
- package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
- package/dist/cli.js +16 -15
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
- package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
- package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
- package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
- package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
- package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
- package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
- package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
- package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
- package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
- package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
- package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
- package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
- package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
- package/dist/rbac-roles-452665y0.js +26 -0
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
- package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
- package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
- package/openapi.json +463 -3
- package/package.json +4 -3
- package/src/be/db/session-tree.ts +89 -2
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/tasks/read.ts +18 -5
- package/src/be/db.ts +2 -0
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- package/src/be/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/users.ts +28 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +46 -6
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +93 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +40 -5
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +163 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +242 -0
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +16 -0
- package/src/workflows/approval-notifications.ts +214 -0
- package/src/workflows/executors/human-in-the-loop.ts +11 -172
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- 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 {
|
|
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
|
+
}
|
package/src/be/db/tasks/read.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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;
|
package/src/be/rbac-roles.ts
CHANGED
|
@@ -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
|
+
}
|