@desplega.ai/agent-swarm 2.5.1 → 2.5.3
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-gr2fcyyj.js} +3 -3
- package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
- package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
- package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
- package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
- package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
- package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
- package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
- package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
- package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
- package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +19 -18
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
- package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
- package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
- package/dist/rbac-roles-gfn30fb8.js +26 -0
- package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3163 -2114
- package/package.json +7 -6
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +158 -13
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +47 -7
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +27 -5
- 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/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -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/swarm-config-guard.ts +23 -0
- package/src/be/users.ts +28 -4
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +254 -43
- package/src/http/config.ts +22 -37
- 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 +118 -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 +42 -6
- 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/providers/dsh-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-config-question.test.ts +702 -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 +183 -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/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -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 +10 -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 +410 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +23 -0
- package/src/workflows/approval-notifications.ts +221 -0
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +36 -173
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/src/be/db.ts
CHANGED
|
@@ -208,6 +208,7 @@ import {
|
|
|
208
208
|
sessionPageFields,
|
|
209
209
|
sessionSummaryQuery,
|
|
210
210
|
} from "./db/session-tree";
|
|
211
|
+
import { searchSkillRowsRanked } from "./db/skill-search";
|
|
211
212
|
import { classifyTaskHumanFree, reclassifyTaskHumanFree } from "./db/tasks/human-free";
|
|
212
213
|
import {
|
|
213
214
|
type AgentTaskRow,
|
|
@@ -7842,6 +7843,8 @@ type WorkflowRunRow = {
|
|
|
7842
7843
|
finishedAt: string | null;
|
|
7843
7844
|
/** The `sub-workflow` step that started this run (migration 203). */
|
|
7844
7845
|
parentStepId?: string | null;
|
|
7846
|
+
/** That step's attempt (`retryCount`) when it started this run (migration 240). */
|
|
7847
|
+
parentStepAttempt?: number | null;
|
|
7845
7848
|
};
|
|
7846
7849
|
|
|
7847
7850
|
/**
|
|
@@ -7862,6 +7865,7 @@ function rowToWorkflowRun(row: WorkflowRunRow, view: WorkflowPayloadView = "repl
|
|
|
7862
7865
|
lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
|
|
7863
7866
|
finishedAt: normalizeDate(row.finishedAt) ?? undefined,
|
|
7864
7867
|
parentStepId: row.parentStepId ?? undefined,
|
|
7868
|
+
parentStepAttempt: row.parentStepId ? (row.parentStepAttempt ?? 0) : undefined,
|
|
7865
7869
|
};
|
|
7866
7870
|
return defineWorkflowPayload(run, "context", row.context, row.context_replay, view, {
|
|
7867
7871
|
runId: row.id,
|
|
@@ -7876,10 +7880,12 @@ export async function createWorkflowRun(data: {
|
|
|
7876
7880
|
triggerData?: unknown;
|
|
7877
7881
|
createdBy?: string;
|
|
7878
7882
|
parentStepId?: string;
|
|
7883
|
+
/** The parent step's `retryCount` when it started this run (migration 240). */
|
|
7884
|
+
parentStepAttempt?: number;
|
|
7879
7885
|
}): Promise<WorkflowRun> {
|
|
7880
7886
|
const now = new Date().toISOString();
|
|
7881
7887
|
const row = await getDbClient().get<WorkflowRunRow>(
|
|
7882
|
-
`INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
7888
|
+
`INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId, parentStepAttempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
|
7883
7889
|
[
|
|
7884
7890
|
data.id,
|
|
7885
7891
|
data.workflowId,
|
|
@@ -7888,6 +7894,7 @@ export async function createWorkflowRun(data: {
|
|
|
7888
7894
|
data.triggerData ? scrubJsonValue(data.triggerData) : null,
|
|
7889
7895
|
data.createdBy ?? null,
|
|
7890
7896
|
data.parentStepId ?? null,
|
|
7897
|
+
data.parentStepAttempt ?? 0,
|
|
7891
7898
|
],
|
|
7892
7899
|
);
|
|
7893
7900
|
if (!row) throw new Error("Failed to create workflow run");
|
|
@@ -7992,9 +7999,10 @@ export type WorkflowRunListOptions = {
|
|
|
7992
7999
|
};
|
|
7993
8000
|
|
|
7994
8001
|
// Everything but `context`. `triggerData` stays: it is small, and stored
|
|
7995
|
-
// scripts read it off list rows (dedupe by trigger, failure reports).
|
|
8002
|
+
// scripts read it off list rows (dedupe by trigger, failure reports). The
|
|
8003
|
+
// parent link and attempt stay so a list row identifies a child's invocation.
|
|
7996
8004
|
const WORKFLOW_RUN_SUMMARY_COLUMNS =
|
|
7997
|
-
"id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt";
|
|
8005
|
+
"id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt, parentStepId, parentStepAttempt";
|
|
7998
8006
|
|
|
7999
8007
|
export type WorkflowRunPage = {
|
|
8000
8008
|
runs: WorkflowRun[];
|
|
@@ -10989,11 +10997,23 @@ export async function listSkills(filters?: SkillFilters): Promise<Skill[]> {
|
|
|
10989
10997
|
return rows.map(rowToSkill);
|
|
10990
10998
|
}
|
|
10991
10999
|
|
|
11000
|
+
/**
|
|
11001
|
+
* Ranked skill search (see searchSkillRowsRanked). Falls back to the unranked
|
|
11002
|
+
* substring match when the ranked query finds nothing, e.g. a partial word or
|
|
11003
|
+
* a query of only stopwords. The fallback also ranks an exact name first.
|
|
11004
|
+
*/
|
|
10992
11005
|
export async function searchSkills(
|
|
10993
11006
|
query: string,
|
|
10994
11007
|
limit = 20,
|
|
10995
11008
|
includeContent = true,
|
|
10996
11009
|
): Promise<Skill[]> {
|
|
11010
|
+
const ranked = await searchSkillRowsRanked<SkillRow>(
|
|
11011
|
+
query,
|
|
11012
|
+
limit,
|
|
11013
|
+
includeContent === false ? SKILL_SLIM_COLUMNS : "skills.*",
|
|
11014
|
+
);
|
|
11015
|
+
if (ranked.length > 0) return ranked.map(rowToSkill);
|
|
11016
|
+
|
|
10997
11017
|
const tokens = query.split(/\s+/).filter(Boolean);
|
|
10998
11018
|
const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
|
|
10999
11019
|
const searchClauses = tokens.map(() => "(name LIKE ? OR description LIKE ? OR content LIKE ?)");
|
|
@@ -11002,8 +11022,8 @@ export async function searchSkills(
|
|
|
11002
11022
|
return [term, term, term];
|
|
11003
11023
|
});
|
|
11004
11024
|
const rows = await getDbClient().query<SkillRow>(
|
|
11005
|
-
`SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY name ASC LIMIT ?`,
|
|
11006
|
-
[...params, limit],
|
|
11025
|
+
`SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY lower(name) = lower(?) DESC, name ASC LIMIT ?`,
|
|
11026
|
+
[...params, query.trim(), limit],
|
|
11007
11027
|
);
|
|
11008
11028
|
return rows.map(rowToSkill);
|
|
11009
11029
|
}
|
|
@@ -12629,6 +12649,8 @@ export async function upsertInboxState(opts: {
|
|
|
12629
12649
|
|
|
12630
12650
|
// SQLite upsert via UNIQUE(userId, itemType, itemId). readAt is sticky: the
|
|
12631
12651
|
// first non-null value written wins on every subsequent conflict.
|
|
12652
|
+
// `triageSessionList` in src/be/db/session-tree.ts copies these conflict
|
|
12653
|
+
// rules for whole-list triage. Keep the two in step.
|
|
12632
12654
|
const row = await getDbClient().get<InboxItemStateRow>(
|
|
12633
12655
|
`INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, readAt, lastReadAt, createdAt, lastUpdatedAt)
|
|
12634
12656
|
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;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- Full-text index for ranked skill search (searchSkills / skill-search tool).
|
|
2
|
+
-- Same shape as memory_fts: an id column FTS5 does not index, the searchable
|
|
3
|
+
-- text columns, and the porter tokenizer. Column order matters: searchSkills
|
|
4
|
+
-- passes bm25() weights positionally (skill_id, name, description, content).
|
|
5
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
6
|
+
skill_id UNINDEXED,
|
|
7
|
+
name,
|
|
8
|
+
description,
|
|
9
|
+
content,
|
|
10
|
+
tokenize='porter unicode61'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
INSERT INTO skills_fts(skill_id, name, description, content)
|
|
14
|
+
SELECT id, name, description, content FROM skills
|
|
15
|
+
WHERE id NOT IN (SELECT skill_id FROM skills_fts WHERE skill_id IS NOT NULL);
|
|
16
|
+
|
|
17
|
+
-- Triggers keep the index in sync on every write path, including FK cascade
|
|
18
|
+
-- deletes. A later migration that rebuilds `skills` (CREATE new + DROP +
|
|
19
|
+
-- RENAME) drops these triggers with the old table and must recreate them;
|
|
20
|
+
-- src/tests/skill-search.test.ts asserts they exist on the migrated schema.
|
|
21
|
+
CREATE TRIGGER IF NOT EXISTS skills_fts_insert
|
|
22
|
+
AFTER INSERT ON skills
|
|
23
|
+
BEGIN
|
|
24
|
+
INSERT INTO skills_fts(skill_id, name, description, content)
|
|
25
|
+
VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
|
|
26
|
+
END;
|
|
27
|
+
|
|
28
|
+
CREATE TRIGGER IF NOT EXISTS skills_fts_update
|
|
29
|
+
AFTER UPDATE OF id, name, description, content ON skills
|
|
30
|
+
BEGIN
|
|
31
|
+
DELETE FROM skills_fts WHERE skill_id = OLD.id;
|
|
32
|
+
INSERT INTO skills_fts(skill_id, name, description, content)
|
|
33
|
+
VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
CREATE TRIGGER IF NOT EXISTS skills_fts_delete
|
|
37
|
+
AFTER DELETE ON skills
|
|
38
|
+
BEGIN
|
|
39
|
+
DELETE FROM skills_fts WHERE skill_id = OLD.id;
|
|
40
|
+
END;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Key a child workflow run by its parent step AND that step's attempt.
|
|
2
|
+
--
|
|
3
|
+
-- The retry poller re-executes a failed step under the same step id, with
|
|
4
|
+
-- `retryCount` bumped. Keyed by step id alone, the retried `sub-workflow`
|
|
5
|
+
-- step reconnected to the failed child of the previous attempt and failed
|
|
6
|
+
-- again without starting a new one.
|
|
7
|
+
|
|
8
|
+
ALTER TABLE workflow_runs ADD COLUMN parentStepAttempt INTEGER NOT NULL DEFAULT 0;
|
|
9
|
+
|
|
10
|
+
-- Before this migration a step had at most one child, created on whichever
|
|
11
|
+
-- attempt first got that far (a retry after a synchronous failure, such as a
|
|
12
|
+
-- disabled child workflow, creates it on attempt 1 or later), and every later
|
|
13
|
+
-- attempt reconnected to it. Hand that child to the attempt that settles or
|
|
14
|
+
-- re-executes it now, or recovery would look up the step's current attempt,
|
|
15
|
+
-- find no child and fail the parent as "deleted":
|
|
16
|
+
-- - a step queued for a retry (`failed` with `nextRetryAt`) starts its next
|
|
17
|
+
-- attempt at `retryCount`, which must start a new child, so the existing
|
|
18
|
+
-- child belongs to the previous attempt;
|
|
19
|
+
-- - any other step (running, waiting, terminal) owns it at `retryCount`.
|
|
20
|
+
-- A child whose step is gone keeps attempt 0.
|
|
21
|
+
UPDATE workflow_runs
|
|
22
|
+
SET parentStepAttempt = (
|
|
23
|
+
SELECT CASE
|
|
24
|
+
WHEN s.status = 'failed' AND s.nextRetryAt IS NOT NULL
|
|
25
|
+
THEN MAX(s.retryCount - 1, 0)
|
|
26
|
+
ELSE s.retryCount
|
|
27
|
+
END
|
|
28
|
+
FROM workflow_run_steps s
|
|
29
|
+
WHERE s.id = workflow_runs.parentStepId
|
|
30
|
+
)
|
|
31
|
+
WHERE parentStepId IS NOT NULL
|
|
32
|
+
AND EXISTS (SELECT 1 FROM workflow_run_steps s WHERE s.id = workflow_runs.parentStepId);
|
|
33
|
+
|
|
34
|
+
DROP INDEX IF EXISTS idx_workflow_runs_parentStepId;
|
|
35
|
+
|
|
36
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_parentStep_attempt
|
|
37
|
+
ON workflow_runs(parentStepId, parentStepAttempt) WHERE parentStepId IS NOT NULL;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- Name + description index for ranked skill search. skills_fts (239) scores
|
|
2
|
+
-- the whole row, and FTS5 bm25 length-normalizes over every column, so a
|
|
3
|
+
-- long SKILL.md body buries a name or description hit. This index holds only
|
|
4
|
+
-- the short fields; searchSkillRowsRanked adds its score to skills_fts's.
|
|
5
|
+
-- Column order matters: bm25() weights are positional (skill_id, name,
|
|
6
|
+
-- description).
|
|
7
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_meta_fts USING fts5(
|
|
8
|
+
skill_id UNINDEXED,
|
|
9
|
+
name,
|
|
10
|
+
description,
|
|
11
|
+
tokenize='porter unicode61'
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
INSERT INTO skills_meta_fts(skill_id, name, description)
|
|
15
|
+
SELECT id, name, description FROM skills
|
|
16
|
+
WHERE id NOT IN (SELECT skill_id FROM skills_meta_fts WHERE skill_id IS NOT NULL);
|
|
17
|
+
|
|
18
|
+
-- Same sync rules as skills_fts: a later migration that rebuilds `skills`
|
|
19
|
+
-- must recreate these triggers; src/tests/skill-search.test.ts asserts them.
|
|
20
|
+
CREATE TRIGGER IF NOT EXISTS skills_meta_fts_insert
|
|
21
|
+
AFTER INSERT ON skills
|
|
22
|
+
BEGIN
|
|
23
|
+
INSERT INTO skills_meta_fts(skill_id, name, description)
|
|
24
|
+
VALUES (NEW.id, NEW.name, NEW.description);
|
|
25
|
+
END;
|
|
26
|
+
|
|
27
|
+
CREATE TRIGGER IF NOT EXISTS skills_meta_fts_update
|
|
28
|
+
AFTER UPDATE OF id, name, description ON skills
|
|
29
|
+
BEGIN
|
|
30
|
+
DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
|
|
31
|
+
INSERT INTO skills_meta_fts(skill_id, name, description)
|
|
32
|
+
VALUES (NEW.id, NEW.name, NEW.description);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
CREATE TRIGGER IF NOT EXISTS skills_meta_fts_delete
|
|
36
|
+
AFTER DELETE ON skills
|
|
37
|
+
BEGIN
|
|
38
|
+
DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
|
|
39
|
+
END;
|
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RunScriptOutput } from "../../scripts-runtime/loader";
|
|
2
2
|
import type { ScriptRecord } from "../../types";
|
|
3
3
|
import {
|
|
4
4
|
getScriptApiConnectionDescriptors,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "../script-connections";
|
|
7
7
|
import { buildScriptCredentialBindingsWithFailures } from "../script-credential-broker";
|
|
8
8
|
import { restoreScratchScriptLastUsedIfUnchanged, touchScratchScriptLastUsed } from "./db";
|
|
9
|
+
import { runScriptWithRunCredential } from "./run-credential";
|
|
9
10
|
|
|
10
11
|
export function getSavedScriptOwnerAgentId(script: ScriptRecord): string | null {
|
|
11
12
|
return script.scopeId ?? script.createdByAgentId;
|
|
@@ -45,7 +46,7 @@ export async function runSavedScriptAsAgent(
|
|
|
45
46
|
exitCode: 1,
|
|
46
47
|
error: "killed",
|
|
47
48
|
}
|
|
48
|
-
: await
|
|
49
|
+
: await runScriptWithRunCredential({
|
|
49
50
|
source: args.script.source,
|
|
50
51
|
args: args.input,
|
|
51
52
|
fsMode: args.script.fsMode,
|
|
@@ -628,6 +628,29 @@ export function validateConfigValue(key: string, value: unknown): string | null
|
|
|
628
628
|
return validator ? validator(value) : null;
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Every check `PUT /api/config` runs before a write: scope and scopeId shape,
|
|
633
|
+
* reserved and internal keys, the per-key value validator and the env-export
|
|
634
|
+
* rule. Shared with HITL `config` questions so the two writers cannot drift.
|
|
635
|
+
*/
|
|
636
|
+
export function validateConfigEntry(entry: {
|
|
637
|
+
scope: "global" | "agent" | "repo";
|
|
638
|
+
scopeId?: string | null;
|
|
639
|
+
key: string;
|
|
640
|
+
value: unknown;
|
|
641
|
+
exportToEnv?: boolean;
|
|
642
|
+
}): string | null {
|
|
643
|
+
if (entry.scope === "global" && entry.scopeId) return "Global scope must not have scopeId";
|
|
644
|
+
if ((entry.scope === "agent" || entry.scope === "repo") && !entry.scopeId) {
|
|
645
|
+
return "Agent/repo scope requires scopeId";
|
|
646
|
+
}
|
|
647
|
+
if (isReservedConfigKey(entry.key)) return reservedKeyError(entry.key).message;
|
|
648
|
+
if (isInternalConfigKey(entry.key)) return internalConfigKeyError(entry.key).message;
|
|
649
|
+
return (
|
|
650
|
+
validateConfigValue(entry.key, entry.value) ?? validateExportToEnv(entry.key, entry.exportToEnv)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
631
654
|
/**
|
|
632
655
|
* Rejects `exportToEnv: true` for keys only the API server reads. Omitting the
|
|
633
656
|
* flag is fine: the write path stores false for those keys.
|