@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createSkill,
|
|
7
|
+
deleteSkill,
|
|
8
|
+
getDb,
|
|
9
|
+
initDb,
|
|
10
|
+
searchSkills,
|
|
11
|
+
updateSkill,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
|
|
14
|
+
// Natural-language probes that missed on the live catalog: the obvious skill
|
|
15
|
+
// has a long SKILL.md body, and a shorter skill repeats the query words in
|
|
16
|
+
// its content. bm25 over the whole row buried the name/description hit.
|
|
17
|
+
const TEST_DB_PATH = `./test-skill-search-long-content-${process.pid}.sqlite`;
|
|
18
|
+
|
|
19
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
20
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
21
|
+
await unlink(path + suffix).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function topNames(query: string): Promise<string[]> {
|
|
26
|
+
return (await searchSkills(query, 10, false)).map((skill) => skill.name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A long body of words that no probe uses, like a full SKILL.md.
|
|
30
|
+
const longBody = (topic: string) =>
|
|
31
|
+
`${topic}\n${"Each step lists its inputs, outputs and the checks to run afterwards. ".repeat(60)}`;
|
|
32
|
+
|
|
33
|
+
describe("searchSkills on long skill bodies", () => {
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
36
|
+
initDb(TEST_DB_PATH);
|
|
37
|
+
|
|
38
|
+
const fixture = [
|
|
39
|
+
{
|
|
40
|
+
name: "pages",
|
|
41
|
+
description: "Publish hosted HTML pages with share links for outside readers.",
|
|
42
|
+
content: longBody("Hosted artifacts."),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "newsletter-sources",
|
|
46
|
+
description: "Weekly AI newsletter source list and reading picks.",
|
|
47
|
+
content: "Publish a short note for a page you read, then publish the page list. ".repeat(6),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "browser-interaction",
|
|
51
|
+
description: "Drive a headless browser.",
|
|
52
|
+
content: "Open a page, wait for a page to load, then read a page. ".repeat(6),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "scheduling",
|
|
56
|
+
description: "Recurring jobs with cron expressions: create, pause or resume a schedule.",
|
|
57
|
+
content: longBody("Cron jobs."),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "social-content",
|
|
61
|
+
description: "Draft social posts for the brand accounts.",
|
|
62
|
+
content: "How do I create a post? Create a draft, then schedule a post for a slot. ".repeat(
|
|
63
|
+
6,
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "code-quality",
|
|
68
|
+
description: "Checks to run before you push or open a pull request, and how to review one.",
|
|
69
|
+
content: longBody("Pre-push gate."),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "customer-ops",
|
|
73
|
+
description: "Operations runbook for one customer deployment.",
|
|
74
|
+
content:
|
|
75
|
+
"Open a pull request, ask a reviewer, review a pull request, request a fix. ".repeat(6),
|
|
76
|
+
},
|
|
77
|
+
// "get" is a stopword, and "aaa-get-helper" sorts first by name.
|
|
78
|
+
{ name: "get", description: "Fetch API resources.", content: "fetch resources" },
|
|
79
|
+
{ name: "aaa-get-helper", description: "Get helper instructions", content: "get content" },
|
|
80
|
+
{ name: "what is it", description: "Release notes digest.", content: "notes" },
|
|
81
|
+
{ name: "aaa-what-is-it", description: "What is it for, in short.", content: "notes" },
|
|
82
|
+
];
|
|
83
|
+
for (const skill of fixture) await createSkill({ ...skill, scope: "swarm" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
closeDb();
|
|
88
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test.each([
|
|
92
|
+
["publish a page", "pages"],
|
|
93
|
+
["how do I create a schedule", "scheduling"],
|
|
94
|
+
["how do I review a pull request", "code-quality"],
|
|
95
|
+
])("%p ranks %p first over content that repeats the query words", async (query, expected) => {
|
|
96
|
+
expect((await topNames(query))[0]).toBe(expected);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("stopwords do not match every skill", async () => {
|
|
100
|
+
// Every fixture body contains "a"; only skills about pages may match.
|
|
101
|
+
expect(await topNames("publish a page")).not.toContain("code-quality");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a query of only stopwords falls back to the substring match", async () => {
|
|
105
|
+
const names = await topNames("how do I");
|
|
106
|
+
expect(names).toContain("social-content");
|
|
107
|
+
expect(names).not.toContain("pages");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test.each([
|
|
111
|
+
"get",
|
|
112
|
+
"GET",
|
|
113
|
+
" Get ",
|
|
114
|
+
])("an exact-name query of only stopwords (%p) ranks that skill first", async (query) => {
|
|
115
|
+
expect((await searchSkills(query, 1, false)).map((skill) => skill.name)).toEqual(["get"]);
|
|
116
|
+
expect((await topNames(query)).slice(0, 2)).toEqual(["get", "aaa-get-helper"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a multi-word exact name of only stopwords ranks first", async () => {
|
|
120
|
+
expect((await searchSkills("What Is It", 1, false)).map((skill) => skill.name)).toEqual([
|
|
121
|
+
"what is it",
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("keeps the name/description index in sync on update and delete", async () => {
|
|
126
|
+
const skill = await createSkill({
|
|
127
|
+
name: "meta-sync-probe",
|
|
128
|
+
description: "Starts with gammaword.",
|
|
129
|
+
content: "",
|
|
130
|
+
scope: "swarm",
|
|
131
|
+
});
|
|
132
|
+
const metaRows = (term: string) =>
|
|
133
|
+
getDb()
|
|
134
|
+
.query<{ skill_id: string }, [string]>(
|
|
135
|
+
"SELECT skill_id FROM skills_meta_fts WHERE skills_meta_fts MATCH ?",
|
|
136
|
+
)
|
|
137
|
+
.all(`"${term}"`)
|
|
138
|
+
.map((row) => row.skill_id);
|
|
139
|
+
expect(metaRows("gammaword")).toEqual([skill.id]);
|
|
140
|
+
|
|
141
|
+
await updateSkill(skill.id, { description: "Now about deltaword." });
|
|
142
|
+
expect(metaRows("gammaword")).toEqual([]);
|
|
143
|
+
expect(metaRows("deltaword")).toEqual([skill.id]);
|
|
144
|
+
|
|
145
|
+
await deleteSkill(skill.id);
|
|
146
|
+
expect(metaRows("deltaword")).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("migrated schema carries the name/description sync triggers", () => {
|
|
150
|
+
const triggers = getDb()
|
|
151
|
+
.query<{ name: string }, []>(
|
|
152
|
+
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'skills' AND name LIKE 'skills_meta_fts_%' ORDER BY name",
|
|
153
|
+
)
|
|
154
|
+
.all()
|
|
155
|
+
.map((row) => row.name);
|
|
156
|
+
expect(triggers).toEqual([
|
|
157
|
+
"skills_meta_fts_delete",
|
|
158
|
+
"skills_meta_fts_insert",
|
|
159
|
+
"skills_meta_fts_update",
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("migration 241 backfills skills that existed before it ran", async () => {
|
|
165
|
+
const db = new Database(":memory:");
|
|
166
|
+
try {
|
|
167
|
+
db.exec(await Bun.file("src/be/migrations/019_skills.sql").text());
|
|
168
|
+
db.run(
|
|
169
|
+
"INSERT INTO skills (id, name, description, content, type, scope, createdAt, lastUpdatedAt) VALUES ('s1', 'legacy-skill', 'Old description', 'old body', 'remote', 'swarm', '2026-01-01', '2026-01-01')",
|
|
170
|
+
);
|
|
171
|
+
const migration = await Bun.file("src/be/migrations/241_skills_meta_fts.sql").text();
|
|
172
|
+
db.exec(migration);
|
|
173
|
+
|
|
174
|
+
const match = (term: string) =>
|
|
175
|
+
db
|
|
176
|
+
.query<{ skill_id: string }, [string]>(
|
|
177
|
+
"SELECT skill_id FROM skills_meta_fts WHERE skills_meta_fts MATCH ?",
|
|
178
|
+
)
|
|
179
|
+
.all(term)
|
|
180
|
+
.map((row) => row.skill_id);
|
|
181
|
+
expect(match('"description"')).toEqual(["s1"]);
|
|
182
|
+
// Content is not indexed here.
|
|
183
|
+
expect(match('"body"')).toEqual([]);
|
|
184
|
+
|
|
185
|
+
// Re-running the migration does not duplicate backfilled rows.
|
|
186
|
+
db.exec(migration);
|
|
187
|
+
expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM skills_meta_fts").get()?.n).toBe(
|
|
188
|
+
1,
|
|
189
|
+
);
|
|
190
|
+
} finally {
|
|
191
|
+
db.close();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
1
2
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
3
|
import { unlink } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createSkill,
|
|
7
|
+
deleteSkill,
|
|
8
|
+
getDb,
|
|
9
|
+
initDb,
|
|
10
|
+
searchSkills,
|
|
11
|
+
updateSkill,
|
|
12
|
+
} from "../be/db";
|
|
4
13
|
|
|
5
14
|
const TEST_DB_PATH = `./test-skill-search-${process.pid}.sqlite`;
|
|
6
15
|
|
|
@@ -10,22 +19,69 @@ async function removeDbFiles(path: string): Promise<void> {
|
|
|
10
19
|
}
|
|
11
20
|
}
|
|
12
21
|
|
|
22
|
+
async function topNames(query: string, limit?: number): Promise<string[]> {
|
|
23
|
+
return (await searchSkills(query, limit)).map((skill) => skill.name);
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
describe("searchSkills", () => {
|
|
14
27
|
beforeAll(async () => {
|
|
15
28
|
await removeDbFiles(TEST_DB_PATH);
|
|
16
29
|
initDb(TEST_DB_PATH);
|
|
17
30
|
|
|
31
|
+
const fixture = [
|
|
32
|
+
{
|
|
33
|
+
name: "pr-review-fix-loop",
|
|
34
|
+
description: "Run review fix rounds on a pull request until CI is green.",
|
|
35
|
+
content: "Reply to each review thread, push the fix, wait for checks.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "code-quality",
|
|
39
|
+
description: "Pre-push checklist for any code change.",
|
|
40
|
+
content: "Run lint, typecheck and tests, then read the diff as a reviewer would.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "swarm-scripts",
|
|
44
|
+
description: "Author and run reusable TypeScript in the swarm.",
|
|
45
|
+
content: "script-upsert, script-run, credential bindings and secrets stay server-side.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "agent-fs",
|
|
49
|
+
description: "Shared drive for documents between agents and people.",
|
|
50
|
+
content: "Upload markdown and create share links for outside readers.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "scheduling",
|
|
54
|
+
description: "Create recurring jobs with cron expressions.",
|
|
55
|
+
content: "Pick a timezone, set the next run, pause or resume a schedule.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "schedule-audit",
|
|
59
|
+
description: "Audit scheduling drift and missed schedules.",
|
|
60
|
+
content: "Compare each schedule's last run with its cron.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "memory",
|
|
64
|
+
description: "Store, search and rate learnings from past sessions.",
|
|
65
|
+
content: "Use memory-store for a fix that will come back.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "call-time-trap-placement",
|
|
69
|
+
description: "Decide where operational rules belong.",
|
|
70
|
+
content: "Use toolsMd and claudeMd to document the delivery surface.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "content-search-example",
|
|
74
|
+
description: "A skill with a content-only term.",
|
|
75
|
+
content: "This body contains the needle-token.",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
for (const skill of fixture) await createSkill({ ...skill, scope: "swarm" });
|
|
18
79
|
await createSkill({
|
|
19
|
-
name: "
|
|
20
|
-
description: "
|
|
21
|
-
content: "
|
|
22
|
-
scope: "swarm",
|
|
23
|
-
});
|
|
24
|
-
await createSkill({
|
|
25
|
-
name: "content-search-example",
|
|
26
|
-
description: "A skill with a content-only term.",
|
|
27
|
-
content: "This body contains the needle-token.",
|
|
80
|
+
name: "disabled-cron-helper",
|
|
81
|
+
description: "Recurring cron jobs, the old way.",
|
|
82
|
+
content: "cron cron cron schedule",
|
|
28
83
|
scope: "swarm",
|
|
84
|
+
isEnabled: false,
|
|
29
85
|
});
|
|
30
86
|
});
|
|
31
87
|
|
|
@@ -34,27 +90,122 @@ describe("searchSkills", () => {
|
|
|
34
90
|
await removeDbFiles(TEST_DB_PATH);
|
|
35
91
|
});
|
|
36
92
|
|
|
37
|
-
test("
|
|
38
|
-
|
|
93
|
+
test("ranks a name match first", async () => {
|
|
94
|
+
expect((await topNames("scheduling"))[0]).toBe("scheduling");
|
|
95
|
+
expect((await topNames("code quality"))[0]).toBe("code-quality");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("ranks a description-only match first", async () => {
|
|
99
|
+
expect((await topNames("recurring cron jobs"))[0]).toBe("scheduling");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("ranks a content-only match first", async () => {
|
|
103
|
+
expect((await topNames("credential bindings"))[0]).toBe("swarm-scripts");
|
|
104
|
+
expect(await topNames("needle-token")).toEqual(["content-search-example"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("ranks natural-language queries by relevance instead of requiring every word", async () => {
|
|
108
|
+
expect((await topNames("how do I review a pull request"))[0]).toBe("pr-review-fix-loop");
|
|
109
|
+
expect((await topNames("where to share a document with people"))[0]).toBe("agent-fs");
|
|
110
|
+
});
|
|
39
111
|
|
|
40
|
-
|
|
112
|
+
test("a query equal to a skill name ranks that skill first", async () => {
|
|
113
|
+
// On bm25 alone schedule-audit outscores scheduling for this query: its
|
|
114
|
+
// name and description both stem to "schedul". The exact-name boost wins.
|
|
115
|
+
expect((await topNames("Scheduling")).slice(0, 2)).toEqual(["scheduling", "schedule-audit"]);
|
|
116
|
+
expect((await topNames("MEMORY"))[0]).toBe("memory");
|
|
117
|
+
expect((await topNames("call-time-trap-placement"))[0]).toBe("call-time-trap-placement");
|
|
41
118
|
});
|
|
42
119
|
|
|
43
|
-
test("
|
|
44
|
-
const
|
|
120
|
+
test("quotes FTS5 syntax in user text instead of executing it", async () => {
|
|
121
|
+
for (const query of [
|
|
122
|
+
'"',
|
|
123
|
+
"*",
|
|
124
|
+
"NEAR",
|
|
125
|
+
"-",
|
|
126
|
+
'cron" OR "x',
|
|
127
|
+
"cron NEAR/2 jobs",
|
|
128
|
+
"-cron",
|
|
129
|
+
"name:cron",
|
|
130
|
+
"cron*",
|
|
131
|
+
]) {
|
|
132
|
+
await expect(searchSkills(query)).resolves.toBeArray();
|
|
133
|
+
}
|
|
134
|
+
expect((await topNames('timezone" OR "x'))[0]).toBe("scheduling");
|
|
135
|
+
expect((await topNames("-timezone"))[0]).toBe("scheduling");
|
|
136
|
+
// As a column filter, name:timezone would match nothing: "timezone" is
|
|
137
|
+
// only in the scheduling skill's content.
|
|
138
|
+
expect((await topNames("name:timezone"))[0]).toBe("scheduling");
|
|
139
|
+
});
|
|
45
140
|
|
|
46
|
-
|
|
141
|
+
test("excludes disabled skills", async () => {
|
|
142
|
+
expect(await topNames("cron")).not.toContain("disabled-cron-helper");
|
|
143
|
+
expect(await topNames("disabled-cron-helper")).not.toContain("disabled-cron-helper");
|
|
47
144
|
});
|
|
48
145
|
|
|
49
|
-
test("
|
|
50
|
-
|
|
146
|
+
test("respects the limit and the includeContent flag", async () => {
|
|
147
|
+
expect(await topNames("the", 2)).toHaveLength(2);
|
|
148
|
+
const slim = await searchSkills("scheduling", 5, false);
|
|
149
|
+
expect(slim[0]?.name).toBe("scheduling");
|
|
150
|
+
expect(slim[0]?.content).toBe("");
|
|
151
|
+
});
|
|
51
152
|
|
|
52
|
-
|
|
153
|
+
test("keeps the substring fallback for partial words", async () => {
|
|
154
|
+
expect(await topNames("needle-tok")).toEqual(["content-search-example"]);
|
|
53
155
|
});
|
|
54
156
|
|
|
55
|
-
test("keeps
|
|
56
|
-
const
|
|
157
|
+
test("keeps the index in sync on update and delete", async () => {
|
|
158
|
+
const skill = await createSkill({
|
|
159
|
+
name: "sync-probe",
|
|
160
|
+
description: "Starts with alphaword.",
|
|
161
|
+
content: "",
|
|
162
|
+
scope: "swarm",
|
|
163
|
+
});
|
|
164
|
+
expect(await topNames("alphaword")).toEqual(["sync-probe"]);
|
|
165
|
+
|
|
166
|
+
await updateSkill(skill.id, { description: "Now about betaword." });
|
|
167
|
+
expect(await topNames("betaword")).toEqual(["sync-probe"]);
|
|
168
|
+
expect(await topNames("alphaword")).toEqual([]);
|
|
169
|
+
|
|
170
|
+
await deleteSkill(skill.id);
|
|
171
|
+
expect(await topNames("betaword")).toEqual([]);
|
|
172
|
+
const ftsRows = getDb()
|
|
173
|
+
.query<{ n: number }, [string]>("SELECT count(*) AS n FROM skills_fts WHERE skill_id = ?")
|
|
174
|
+
.get(skill.id);
|
|
175
|
+
expect(ftsRows?.n).toBe(0);
|
|
176
|
+
});
|
|
57
177
|
|
|
58
|
-
|
|
178
|
+
test("migrated schema carries the sync triggers", () => {
|
|
179
|
+
const triggers = getDb()
|
|
180
|
+
.query<{ name: string }, []>(
|
|
181
|
+
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'skills' AND name LIKE 'skills_fts_%' ORDER BY name",
|
|
182
|
+
)
|
|
183
|
+
.all()
|
|
184
|
+
.map((row) => row.name);
|
|
185
|
+
expect(triggers).toEqual(["skills_fts_delete", "skills_fts_insert", "skills_fts_update"]);
|
|
59
186
|
});
|
|
60
187
|
});
|
|
188
|
+
|
|
189
|
+
test("migration 239 backfills skills that existed before it ran", async () => {
|
|
190
|
+
const db = new Database(":memory:");
|
|
191
|
+
try {
|
|
192
|
+
db.exec(await Bun.file("src/be/migrations/019_skills.sql").text());
|
|
193
|
+
db.run(
|
|
194
|
+
"INSERT INTO skills (id, name, description, content, type, scope, createdAt, lastUpdatedAt) VALUES ('s1', 'legacy-skill', 'Old description', 'old body', 'remote', 'swarm', '2026-01-01', '2026-01-01')",
|
|
195
|
+
);
|
|
196
|
+
db.exec(await Bun.file("src/be/migrations/239_skills_fts.sql").text());
|
|
197
|
+
|
|
198
|
+
const rows = db
|
|
199
|
+
.query<{ skill_id: string }, [string]>(
|
|
200
|
+
"SELECT skill_id FROM skills_fts WHERE skills_fts MATCH ?",
|
|
201
|
+
)
|
|
202
|
+
.all('"body"');
|
|
203
|
+
expect(rows.map((row) => row.skill_id)).toEqual(["s1"]);
|
|
204
|
+
|
|
205
|
+
// Re-running the migration does not duplicate backfilled rows.
|
|
206
|
+
db.exec(await Bun.file("src/be/migrations/239_skills_fts.sql").text());
|
|
207
|
+
expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM skills_fts").get()?.n).toBe(1);
|
|
208
|
+
} finally {
|
|
209
|
+
db.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
3
|
+
import { closeDb, createAgent, createUser, getDbClient, initDb } from "../be/db";
|
|
4
|
+
import { ensureRbacSeedsSynced, listUserRoles, setUserRoles } from "../be/rbac-roles";
|
|
5
|
+
import { type IdentityActor, listUserEvents, mintToken } from "../be/users";
|
|
6
|
+
import { handleApprovalRequests } from "../http/approval-requests";
|
|
7
|
+
import { handleCore } from "../http/core";
|
|
8
|
+
import { handleTasks } from "../http/tasks";
|
|
9
|
+
import { handleUserRoles } from "../http/user-roles";
|
|
10
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
11
|
+
import { listenOnFreePort } from "./test-net";
|
|
12
|
+
|
|
13
|
+
const key = `user-roles-test-${crypto.randomUUID()}`;
|
|
14
|
+
const ACTOR: IdentityActor = { kind: "operator", id: "user-roles-test" };
|
|
15
|
+
let server: Server;
|
|
16
|
+
let baseUrl: string;
|
|
17
|
+
let leadId: string;
|
|
18
|
+
|
|
19
|
+
async function request(
|
|
20
|
+
path: string,
|
|
21
|
+
bearer: string,
|
|
22
|
+
options: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
|
23
|
+
) {
|
|
24
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
25
|
+
method: options.method ?? (options.body === undefined ? "GET" : "POST"),
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${bearer}`,
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
...options.headers,
|
|
30
|
+
},
|
|
31
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
let body: any = text;
|
|
35
|
+
try {
|
|
36
|
+
body = JSON.parse(text);
|
|
37
|
+
} catch {}
|
|
38
|
+
return { status: res.status, text, body };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const putRoles = (userId: string, bearer: string, roles: string[], headers = {}) =>
|
|
42
|
+
request(`/api/users/${userId}/roles`, bearer, { method: "PUT", body: { roles }, headers });
|
|
43
|
+
|
|
44
|
+
async function newUser(name: string, roles?: string[]) {
|
|
45
|
+
const user = await createUser({ name });
|
|
46
|
+
if (roles) await setUserRoles(user.id, roles, null);
|
|
47
|
+
const token = (await mintToken(user.id, name, ACTOR)).plaintext;
|
|
48
|
+
return { id: user.id, token };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse) {
|
|
52
|
+
const id = req.headers["x-agent-id"] as string | undefined;
|
|
53
|
+
if (await handleCore(req, res, id, key)) return;
|
|
54
|
+
const segments = getPathSegments(req.url ?? "");
|
|
55
|
+
const query = parseQueryParams(req.url ?? "");
|
|
56
|
+
if (await handleUserRoles(req, res, segments, query)) return;
|
|
57
|
+
if (await handleApprovalRequests(req, res, segments, query)) return;
|
|
58
|
+
if (await handleTasks(req, res, segments, query, id)) return;
|
|
59
|
+
res.writeHead(404).end();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
initDb(":memory:");
|
|
64
|
+
ensureRbacSeedsSynced({ quiet: true });
|
|
65
|
+
leadId = (await createAgent({ name: "roles-lead", isLead: true, status: "idle" })).id;
|
|
66
|
+
server = createServer((req, res) => {
|
|
67
|
+
void dispatch(req, res).catch(() => res.writeHead(500).end());
|
|
68
|
+
});
|
|
69
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
74
|
+
closeDb();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("PUT /api/users/{id}/roles", () => {
|
|
78
|
+
test("a role change changes what the user may do", async () => {
|
|
79
|
+
await newUser("keeper-admin");
|
|
80
|
+
const alice = await newUser("alice");
|
|
81
|
+
const task = { task: "do a thing" };
|
|
82
|
+
expect((await request("/api/tasks", alice.token, { body: task })).status).not.toBe(403);
|
|
83
|
+
|
|
84
|
+
const set = await putRoles(alice.id, key, ["viewer"]);
|
|
85
|
+
expect(set.status).toBe(200);
|
|
86
|
+
expect(set.body.roles.map((r: { name: string }) => r.name)).toEqual(["viewer"]);
|
|
87
|
+
expect((await request("/api/tasks", alice.token, { body: task })).status).toBe(403);
|
|
88
|
+
expect((await request("/api/tasks", alice.token)).status).not.toBe(403);
|
|
89
|
+
|
|
90
|
+
expect((await putRoles(alice.id, key, ["admin"])).status).toBe(200);
|
|
91
|
+
expect((await request("/api/tasks", alice.token, { body: task })).status).not.toBe(403);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a demoted user can no longer answer approvals", async () => {
|
|
95
|
+
const bob = await newUser("bob", ["requester"]);
|
|
96
|
+
const res = await request(`/api/approval-requests/${crypto.randomUUID()}/respond`, bob.token, {
|
|
97
|
+
body: { responses: {} },
|
|
98
|
+
});
|
|
99
|
+
expect(res.status).toBe(403);
|
|
100
|
+
expect(res.text).toContain("approval.respond");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("only an operator or a grant-all user may change roles", async () => {
|
|
104
|
+
const target = await newUser("target");
|
|
105
|
+
const requester = await newUser("requester-user", ["requester"]);
|
|
106
|
+
const viewer = await newUser("viewer-user", ["viewer"]);
|
|
107
|
+
const admin = await newUser("admin-user");
|
|
108
|
+
expect((await putRoles(target.id, requester.token, ["viewer"])).status).toBe(403);
|
|
109
|
+
expect((await putRoles(target.id, viewer.token, ["viewer"])).status).toBe(403);
|
|
110
|
+
// The shared key acting as an agent is not the operator.
|
|
111
|
+
expect((await putRoles(target.id, key, ["viewer"], { "X-Agent-ID": leadId })).status).toBe(403);
|
|
112
|
+
expect((await putRoles(target.id, admin.token, ["requester"])).status).toBe(200);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a custom role holding user.role.manage cannot grant admin", async () => {
|
|
116
|
+
const target = await newUser("grant-target");
|
|
117
|
+
const manager = await newUser("manager", ["viewer"]);
|
|
118
|
+
const client = getDbClient();
|
|
119
|
+
await client.run(
|
|
120
|
+
"INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES ('role-rm', 'role-manager', 'x', 0, 0)",
|
|
121
|
+
);
|
|
122
|
+
await client.run(
|
|
123
|
+
"INSERT INTO role_permissions (roleId, verb) VALUES ('role-rm', 'user.role.manage')",
|
|
124
|
+
);
|
|
125
|
+
await client.run(
|
|
126
|
+
"INSERT INTO principal_roles (principalType, principalId, roleId) VALUES ('user', ?, 'role-rm')",
|
|
127
|
+
[manager.id],
|
|
128
|
+
);
|
|
129
|
+
expect((await putRoles(target.id, manager.token, ["admin"])).status).toBe(403);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("last-admin guard", async () => {
|
|
133
|
+
const client = getDbClient();
|
|
134
|
+
await client.run("UPDATE users SET status = 'suspended'");
|
|
135
|
+
const sole = await newUser("sole-admin");
|
|
136
|
+
expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(409);
|
|
137
|
+
expect((await listUserRoles(sole.id)).map((r) => r.name)).toEqual(["admin"]);
|
|
138
|
+
|
|
139
|
+
const second = await newUser("second-admin");
|
|
140
|
+
expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(200);
|
|
141
|
+
// second is now the sole active admin; a suspended admin may still be demoted.
|
|
142
|
+
await client.run("UPDATE users SET status = 'suspended' WHERE id = ?", [second.id]);
|
|
143
|
+
const third = await newUser("third");
|
|
144
|
+
await client.run("UPDATE users SET status = 'suspended' WHERE id = ?", [third.id]);
|
|
145
|
+
expect((await putRoles(third.id, key, ["requester"])).status).toBe(200);
|
|
146
|
+
await client.run("UPDATE users SET status = 'active'");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("validation", async () => {
|
|
150
|
+
const user = await newUser("validated");
|
|
151
|
+
for (const roles of [[], ["nope"], ["admin", "viewer"]]) {
|
|
152
|
+
expect((await putRoles(user.id, key, roles)).status).toBe(400);
|
|
153
|
+
}
|
|
154
|
+
expect((await putRoles("missing-user", key, ["viewer"])).status).toBe(404);
|
|
155
|
+
expect((await listUserRoles(user.id)).map((r) => r.name)).toEqual(["admin"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("agent rows in principal_roles are neither listed nor counted as admins", async () => {
|
|
159
|
+
const client = getDbClient();
|
|
160
|
+
await client.run("UPDATE users SET status = 'suspended'");
|
|
161
|
+
const sole = await newUser("sole-with-agent-twin");
|
|
162
|
+
await client.run(
|
|
163
|
+
`INSERT INTO principal_roles (principalType, principalId, roleId)
|
|
164
|
+
VALUES ('agent', 'agent-twin', 'rbac-role-admin')`,
|
|
165
|
+
);
|
|
166
|
+
const twin = await client.get<{ id: string }>("SELECT id FROM users WHERE id = ?", [sole.id]);
|
|
167
|
+
await client.run("UPDATE principal_roles SET principalId = ? WHERE principalType = 'agent'", [
|
|
168
|
+
twin!.id,
|
|
169
|
+
]);
|
|
170
|
+
expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(409);
|
|
171
|
+
const roles = await request(`/api/users/${sole.id}/roles`, key);
|
|
172
|
+
expect(roles.body.roles.map((r: { name: string }) => r.name)).toEqual(["admin"]);
|
|
173
|
+
await client.run("DELETE FROM principal_roles WHERE principalType = 'agent'");
|
|
174
|
+
await client.run("UPDATE users SET status = 'active'");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("audit event and role catalog", async () => {
|
|
178
|
+
const user = await newUser("audited");
|
|
179
|
+
await putRoles(user.id, key, ["requester"]);
|
|
180
|
+
const events = await listUserEvents(user.id);
|
|
181
|
+
const change = events.find((e) => e.eventType === "profile_changed");
|
|
182
|
+
expect(JSON.stringify(change)).toContain("requester");
|
|
183
|
+
expect(JSON.stringify(change)).toContain("admin");
|
|
184
|
+
|
|
185
|
+
const catalog = await request("/api/rbac/roles", key);
|
|
186
|
+
const primary = catalog.body.roles.filter((r: { primary: boolean }) => r.primary);
|
|
187
|
+
expect(primary.map((r: { name: string }) => r.name).sort()).toEqual([
|
|
188
|
+
"admin",
|
|
189
|
+
"requester",
|
|
190
|
+
"viewer",
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("persists with RBAC disabled", async () => {
|
|
195
|
+
process.env.RBAC_ENABLED = "false";
|
|
196
|
+
try {
|
|
197
|
+
const user = await newUser("rbac-off");
|
|
198
|
+
expect((await putRoles(user.id, key, ["viewer"])).status).toBe(200);
|
|
199
|
+
expect((await listUserRoles(user.id)).map((r) => r.name)).toEqual(["viewer"]);
|
|
200
|
+
} finally {
|
|
201
|
+
delete process.env.RBAC_ENABLED;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
initDb,
|
|
12
12
|
} from "../be/db";
|
|
13
13
|
import type { WorkflowDefinition } from "../types";
|
|
14
|
+
import { buildSlackQuestionsSummary } from "../workflows/approval-notifications";
|
|
14
15
|
import { validateDefinition } from "../workflows/definition";
|
|
15
16
|
import { interpolateNodeConfig, startWorkflowExecution } from "../workflows/engine";
|
|
16
17
|
import { InProcessEventBus } from "../workflows/event-bus";
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
type ExecutorResult,
|
|
21
22
|
} from "../workflows/executors/base";
|
|
22
23
|
import {
|
|
23
|
-
buildSlackQuestionsSummary,
|
|
24
24
|
HumanInTheLoopExecutor,
|
|
25
25
|
MAX_HITL_QUESTIONS,
|
|
26
26
|
resolveHitlQuestions,
|