@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,120 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer, type Server } from "node:http";
|
|
4
|
+
import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
|
|
5
|
+
import { mintScriptRunToken, revokeSessionToken } from "../be/users";
|
|
6
|
+
import { handleConfig } from "../http/config";
|
|
7
|
+
import { handleCore } from "../http/core";
|
|
8
|
+
import { handleSessions } from "../http/sessions";
|
|
9
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
10
|
+
import { listenOnFreePort } from "./test-net";
|
|
11
|
+
|
|
12
|
+
// A script run presents a token minted by `mintScriptRunToken` (the api does
|
|
13
|
+
// this per run instead of handing the script the operator key). These tests
|
|
14
|
+
// pin that the token is scoped to its executing agent at the config layer, so
|
|
15
|
+
// a worker's inline script cannot read another agent's secret or delete its
|
|
16
|
+
// config row — the escalation that running as the operator allowed.
|
|
17
|
+
const API_KEY = `script-run-config-operator-${randomBytes(8).toString("hex")}`;
|
|
18
|
+
const WORKER_SECRET = `worker-secret-${randomBytes(8).toString("hex")}`;
|
|
19
|
+
const OTHER_SECRET = `other-secret-${randomBytes(8).toString("hex")}`;
|
|
20
|
+
|
|
21
|
+
let server: Server;
|
|
22
|
+
let baseUrl: string;
|
|
23
|
+
let workerId: string;
|
|
24
|
+
let otherId: string;
|
|
25
|
+
let otherConfigId: string;
|
|
26
|
+
|
|
27
|
+
function headers(bearer: string, agentId?: string): Record<string, string> {
|
|
28
|
+
return {
|
|
29
|
+
Authorization: `Bearer ${bearer}`,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
...(agentId ? { "X-Agent-ID": agentId } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
initDb(":memory:");
|
|
37
|
+
workerId = (await createAgent({ name: "script-run-worker", isLead: false, status: "idle" })).id;
|
|
38
|
+
otherId = (await createAgent({ name: "script-run-other", isLead: false, status: "idle" })).id;
|
|
39
|
+
await upsertSwarmConfig({
|
|
40
|
+
scope: "agent",
|
|
41
|
+
scopeId: workerId,
|
|
42
|
+
key: "OWN_SECRET",
|
|
43
|
+
value: WORKER_SECRET,
|
|
44
|
+
isSecret: true,
|
|
45
|
+
});
|
|
46
|
+
otherConfigId = (
|
|
47
|
+
await upsertSwarmConfig({
|
|
48
|
+
scope: "agent",
|
|
49
|
+
scopeId: otherId,
|
|
50
|
+
key: "OTHER_SECRET",
|
|
51
|
+
value: OTHER_SECRET,
|
|
52
|
+
isSecret: true,
|
|
53
|
+
})
|
|
54
|
+
).id;
|
|
55
|
+
server = createServer(async (req, res) => {
|
|
56
|
+
if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, API_KEY))
|
|
57
|
+
return;
|
|
58
|
+
const segments = getPathSegments(req.url || "");
|
|
59
|
+
const query = parseQueryParams(req.url || "");
|
|
60
|
+
if (await handleSessions(req, res, segments, query)) return;
|
|
61
|
+
if (await handleConfig(req, res, segments, query)) return;
|
|
62
|
+
res.writeHead(404).end();
|
|
63
|
+
});
|
|
64
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
69
|
+
closeDb();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("script run token is scoped to its executing agent", () => {
|
|
73
|
+
test("reads its own agent's secret", async () => {
|
|
74
|
+
const run = await mintScriptRunToken(workerId, 60_000);
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`${baseUrl}/api/config/resolved?includeSecrets=true`, {
|
|
77
|
+
headers: headers(run.plaintext),
|
|
78
|
+
});
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(await res.text()).toContain(WORKER_SECRET);
|
|
81
|
+
} finally {
|
|
82
|
+
await revokeSessionToken(run.tokenId);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("cannot read another agent's secret", async () => {
|
|
87
|
+
const run = await mintScriptRunToken(workerId, 60_000);
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(
|
|
90
|
+
`${baseUrl}/api/config/resolved?includeSecrets=true&agentId=${otherId}`,
|
|
91
|
+
{ headers: headers(run.plaintext) },
|
|
92
|
+
);
|
|
93
|
+
// X-Agent-ID is forced to the token's agent, so this reads the worker's
|
|
94
|
+
// own scope; either way the other agent's secret never comes back.
|
|
95
|
+
const body = await res.text();
|
|
96
|
+
expect(body).not.toContain(OTHER_SECRET);
|
|
97
|
+
} finally {
|
|
98
|
+
await revokeSessionToken(run.tokenId);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("cannot delete another agent's config row", async () => {
|
|
103
|
+
const run = await mintScriptRunToken(workerId, 60_000);
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${baseUrl}/api/config/${otherConfigId}`, {
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
headers: headers(run.plaintext),
|
|
108
|
+
});
|
|
109
|
+
await res.text();
|
|
110
|
+
expect(res.status).toBe(403);
|
|
111
|
+
// The row is still there, read back as operator.
|
|
112
|
+
const check = await fetch(`${baseUrl}/api/config/${otherConfigId}`, {
|
|
113
|
+
headers: headers(API_KEY),
|
|
114
|
+
});
|
|
115
|
+
expect(check.status).toBe(200);
|
|
116
|
+
} finally {
|
|
117
|
+
await revokeSessionToken(run.tokenId);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { closeDb, createAgent, initDb } from "../be/db";
|
|
4
|
+
import {
|
|
5
|
+
AgentTokenExistsError,
|
|
6
|
+
mintAgentToken,
|
|
7
|
+
mintScriptRunToken,
|
|
8
|
+
resolveSessionToken,
|
|
9
|
+
revokeAgentTokens,
|
|
10
|
+
revokeSessionToken,
|
|
11
|
+
} from "../be/users";
|
|
12
|
+
|
|
13
|
+
const synthetic = (label: string) => `script-run-token-${label}-${randomBytes(8).toString("hex")}`;
|
|
14
|
+
|
|
15
|
+
let agentId: string;
|
|
16
|
+
let otherId: string;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
initDb(":memory:");
|
|
20
|
+
agentId = (await createAgent({ name: synthetic("agent"), isLead: false, status: "idle" })).id;
|
|
21
|
+
otherId = (await createAgent({ name: synthetic("other"), isLead: false, status: "idle" })).id;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
closeDb();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("mintScriptRunToken", () => {
|
|
29
|
+
test("resolves to the executing agent as an agent principal", async () => {
|
|
30
|
+
const token = await mintScriptRunToken(agentId, 60_000);
|
|
31
|
+
expect(token.plaintext.startsWith("aseph_")).toBe(true);
|
|
32
|
+
|
|
33
|
+
const resolved = await resolveSessionToken(token.plaintext);
|
|
34
|
+
expect(resolved).not.toBeNull();
|
|
35
|
+
if (resolved?.kind !== "agent") throw new Error("expected an agent principal");
|
|
36
|
+
expect(resolved.agentId).toBe(agentId);
|
|
37
|
+
expect(resolved.tokenKind).toBe("agent");
|
|
38
|
+
expect(resolved.taskId).toBeNull();
|
|
39
|
+
expect(resolved.runtimeId).toBeNull();
|
|
40
|
+
|
|
41
|
+
await revokeSessionToken(token.tokenId);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("does not count as the agent's worker token", async () => {
|
|
45
|
+
// A script run must be able to mint its token while the agent already
|
|
46
|
+
// holds a worker token, and vice versa: the two lifecycles are separate.
|
|
47
|
+
const worker = await mintAgentToken(agentId);
|
|
48
|
+
const run = await mintScriptRunToken(agentId, 60_000);
|
|
49
|
+
|
|
50
|
+
expect(run.tokenId).not.toBe(worker.tokenId);
|
|
51
|
+
// The worker token is unique per agent; minting a second would throw. The
|
|
52
|
+
// script run token sharing the agent did not trip that uniqueness.
|
|
53
|
+
await expect(mintAgentToken(agentId)).rejects.toBeInstanceOf(AgentTokenExistsError);
|
|
54
|
+
|
|
55
|
+
await revokeSessionToken(run.tokenId);
|
|
56
|
+
await revokeAgentTokens(agentId);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("survives a revoke of the agent's worker tokens", async () => {
|
|
60
|
+
const worker = await mintAgentToken(otherId);
|
|
61
|
+
const run = await mintScriptRunToken(otherId, 60_000);
|
|
62
|
+
|
|
63
|
+
// Revoking the agent's worker tokens (worker-token revoke / rotate) must
|
|
64
|
+
// not revoke a script run's in-flight token.
|
|
65
|
+
await revokeAgentTokens(otherId);
|
|
66
|
+
|
|
67
|
+
expect(await resolveSessionToken(worker.plaintext)).toBeNull();
|
|
68
|
+
const stillValid = await resolveSessionToken(run.plaintext);
|
|
69
|
+
expect(stillValid).not.toBeNull();
|
|
70
|
+
expect(stillValid?.agentId).toBe(otherId);
|
|
71
|
+
|
|
72
|
+
await revokeSessionToken(run.tokenId);
|
|
73
|
+
expect(await resolveSessionToken(run.plaintext)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -574,6 +574,25 @@ describe("/api/scripts HTTP", () => {
|
|
|
574
574
|
).toBe(beforeFailed);
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
+
skip("a run mints a per-run token for its agent and revokes it when the run ends", async () => {
|
|
578
|
+
const tokens = () =>
|
|
579
|
+
getDbClient().query<{ revokedAt: string | null }>(
|
|
580
|
+
"SELECT revokedAt FROM session_tokens WHERE agentId = ? AND scriptRun = 1",
|
|
581
|
+
[workerId],
|
|
582
|
+
);
|
|
583
|
+
const before = (await tokens()).length;
|
|
584
|
+
const res = await dispatch("/api/scripts/run", {
|
|
585
|
+
method: "POST",
|
|
586
|
+
agentId: workerId,
|
|
587
|
+
body: JSON.stringify({ source: validSource(2), args: { value: 2 }, intent: "token" }),
|
|
588
|
+
});
|
|
589
|
+
expect(res.status).toBe(200);
|
|
590
|
+
expect((await res.json()).result).toEqual({ result: 4 });
|
|
591
|
+
const minted = (await tokens()).slice(before);
|
|
592
|
+
expect(minted).toHaveLength(1);
|
|
593
|
+
expect(minted[0]?.revokedAt).not.toBeNull();
|
|
594
|
+
});
|
|
595
|
+
|
|
577
596
|
test("workspace-rw named scripts return 501", async () => {
|
|
578
597
|
await upsert({ name: "workspace", source: validSource(2), fsMode: "workspace-rw" });
|
|
579
598
|
const res = await dispatch("/api/scripts/run", {
|
|
@@ -4,6 +4,7 @@ import { keyPrefixRange, sessionGroupKey } from "../assets/key";
|
|
|
4
4
|
import {
|
|
5
5
|
closeDb,
|
|
6
6
|
countSessions,
|
|
7
|
+
createSteeringMessage,
|
|
7
8
|
createTaskExtended,
|
|
8
9
|
createUser,
|
|
9
10
|
getDbClient,
|
|
@@ -58,6 +59,7 @@ beforeAll(async () => {
|
|
|
58
59
|
});
|
|
59
60
|
beforeEach(async () => {
|
|
60
61
|
await getDbClient().run("DELETE FROM approval_requests");
|
|
62
|
+
await getDbClient().run("DELETE FROM task_steering_messages");
|
|
61
63
|
await getDbClient().run("DELETE FROM inbox_item_state");
|
|
62
64
|
await getDbClient().run("DELETE FROM session_shares");
|
|
63
65
|
await getDbClient().run("DELETE FROM agent_tasks");
|
|
@@ -195,7 +197,8 @@ describe("chain-wide read state", () => {
|
|
|
195
197
|
{ activity: "2026-10-14T12:00:00.000Z", expected: "read" },
|
|
196
198
|
{ status: "dismissed", updated: OLD, expected: "done" },
|
|
197
199
|
{ status: "done", updated: NOW, expected: "done" },
|
|
198
|
-
|
|
200
|
+
// Agent activity after the Done mark keeps it Done.
|
|
201
|
+
{ status: "done", updated: OLD, expected: "done" },
|
|
199
202
|
{ status: "snoozed", snoozeUntil: "2026-10-22T12:00:00.000Z", expected: "later" },
|
|
200
203
|
{ status: "snoozed", snoozeUntil: NOW, expected: "unread" },
|
|
201
204
|
{ status: "snoozed", expected: "unread" },
|
|
@@ -259,6 +262,145 @@ describe("chain-wide read state", () => {
|
|
|
259
262
|
});
|
|
260
263
|
});
|
|
261
264
|
|
|
265
|
+
describe("Done holds until the session needs the user", () => {
|
|
266
|
+
const DONE_AT = "2026-10-21T11:00:00.000Z";
|
|
267
|
+
const BEFORE = "2026-10-21T10:30:00.000Z";
|
|
268
|
+
const AFTER = "2026-10-21T11:30:00.000Z";
|
|
269
|
+
const readState = async (id: string) =>
|
|
270
|
+
(await listRecentSessions(filters({ limit: 100 }))).find((s) => s.root.id === id)?.readState;
|
|
271
|
+
const doneSession = async () => {
|
|
272
|
+
const root = await task("shared/", { source: "ui" }, BEFORE);
|
|
273
|
+
await getDbClient().run(
|
|
274
|
+
"UPDATE agent_tasks SET createdAt = ?, status = 'in_progress' WHERE id = ?",
|
|
275
|
+
[BEFORE, root.id],
|
|
276
|
+
);
|
|
277
|
+
await state(root.id, "done", DONE_AT);
|
|
278
|
+
expect(await readState(root.id)).toBe("done");
|
|
279
|
+
return root;
|
|
280
|
+
};
|
|
281
|
+
const child = async (
|
|
282
|
+
parentTaskId: string,
|
|
283
|
+
extra: Parameters<typeof createTaskExtended>[1],
|
|
284
|
+
createdAt = AFTER,
|
|
285
|
+
) => {
|
|
286
|
+
const row = await createTaskExtended("Child", { parentTaskId, key: "shared/", ...extra });
|
|
287
|
+
await getDbClient().run(
|
|
288
|
+
"UPDATE agent_tasks SET createdAt = ?, lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
|
|
289
|
+
[createdAt, createdAt, row.id],
|
|
290
|
+
);
|
|
291
|
+
return row;
|
|
292
|
+
};
|
|
293
|
+
const approval = async (taskId: string, createdAt: string, status = "pending") =>
|
|
294
|
+
getDbClient().run(
|
|
295
|
+
`INSERT INTO approval_requests (id, sourceTaskId, title, questions, approvers, status, createdAt)
|
|
296
|
+
VALUES (?, ?, 'Approve deploy', '[]', '{}', ?, ?)`,
|
|
297
|
+
[crypto.randomUUID(), taskId, status, createdAt],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
test("agent progress and a status change keep it Done", async () => {
|
|
301
|
+
const root = await doneSession();
|
|
302
|
+
await getDbClient().run(
|
|
303
|
+
"UPDATE agent_tasks SET lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
|
|
304
|
+
[AFTER, root.id],
|
|
305
|
+
);
|
|
306
|
+
expect(await readState(root.id)).toBe("done");
|
|
307
|
+
const tree = await getSessionTree(actor(), NOW);
|
|
308
|
+
expect(tree.done.total).toBe(1);
|
|
309
|
+
expect(tree.inbox.total).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("agent-created child and delegated tasks keep it Done", async () => {
|
|
313
|
+
const root = await doneSession();
|
|
314
|
+
// Inherits the requester from its parent.
|
|
315
|
+
const inherited = await child(root.id, { source: "mcp" });
|
|
316
|
+
// Lead delegation, a deferred wake-up and a follow-up name the requester explicitly.
|
|
317
|
+
await child(inherited.id, { source: "mcp", requestedByUserId: owner.id });
|
|
318
|
+
await child(root.id, { source: "schedule", requestedByUserId: owner.id });
|
|
319
|
+
await child(root.id, { source: "system", requestedByUserId: owner.id });
|
|
320
|
+
await child(root.id, { source: "workflow", requestedByUserId: owner.id });
|
|
321
|
+
expect(await readState(root.id)).toBe("done");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a pending approval created after Done returns it to the Inbox", async () => {
|
|
325
|
+
const before = await doneSession();
|
|
326
|
+
await approval(before.id, BEFORE);
|
|
327
|
+
const resolved = await doneSession();
|
|
328
|
+
await approval(resolved.id, AFTER, "approved");
|
|
329
|
+
expect(await readState(before.id)).toBe("done");
|
|
330
|
+
expect(await readState(resolved.id)).toBe("done");
|
|
331
|
+
|
|
332
|
+
const root = await doneSession();
|
|
333
|
+
const agent = await child(root.id, { source: "mcp" });
|
|
334
|
+
await approval(agent.id, AFTER);
|
|
335
|
+
expect(await readState(root.id)).toBe("unread");
|
|
336
|
+
expect(await ids({ triage: "inbox" })).toEqual([root.id]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a human follow-up after Done returns it to the Inbox", async () => {
|
|
340
|
+
const earlier = await doneSession();
|
|
341
|
+
await child(earlier.id, { source: "ui", requestedByUserId: owner.id }, BEFORE);
|
|
342
|
+
expect(await readState(earlier.id)).toBe("done");
|
|
343
|
+
|
|
344
|
+
const root = await doneSession();
|
|
345
|
+
await child(root.id, { source: "ui", requestedByUserId: owner.id });
|
|
346
|
+
expect(await readState(root.id)).toBe("unread");
|
|
347
|
+
const slack = await doneSession();
|
|
348
|
+
await child(slack.id, { source: "slack", requestedByUserId: other.id });
|
|
349
|
+
expect(await readState(slack.id)).toBe("unread");
|
|
350
|
+
const tree = await getSessionTree(actor(), NOW);
|
|
351
|
+
expect(tree.inbox.total).toBe(2);
|
|
352
|
+
expect(tree.done.total).toBe(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a person's steer or queued message after Done returns it to the Inbox", async () => {
|
|
356
|
+
// The real writer stamps created_at with the column default, after DONE_AT.
|
|
357
|
+
const steered = await doneSession();
|
|
358
|
+
await createSteeringMessage({
|
|
359
|
+
taskId: steered.id,
|
|
360
|
+
body: "Change course",
|
|
361
|
+
mode: "steer",
|
|
362
|
+
source: "ui",
|
|
363
|
+
createdByKind: "user",
|
|
364
|
+
createdByUserId: owner.id,
|
|
365
|
+
});
|
|
366
|
+
expect(await readState(steered.id)).toBe("unread");
|
|
367
|
+
|
|
368
|
+
const queued = await doneSession();
|
|
369
|
+
const agent = await child(queued.id, { source: "mcp" });
|
|
370
|
+
await createSteeringMessage({
|
|
371
|
+
taskId: agent.id,
|
|
372
|
+
body: "Also check this",
|
|
373
|
+
mode: "queue",
|
|
374
|
+
source: "slack",
|
|
375
|
+
createdByKind: "user",
|
|
376
|
+
createdByUserId: owner.id,
|
|
377
|
+
});
|
|
378
|
+
expect(await readState(queued.id)).toBe("unread");
|
|
379
|
+
expect((await ids({ triage: "inbox" })).sort()).toEqual([steered.id, queued.id].sort());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("agent, system, cancelled and earlier steering keep it Done", async () => {
|
|
383
|
+
const steer = async (
|
|
384
|
+
taskId: string,
|
|
385
|
+
createdByKind: "user" | "agent" | "system",
|
|
386
|
+
createdAt = AFTER,
|
|
387
|
+
status = "pending",
|
|
388
|
+
) =>
|
|
389
|
+
getDbClient().run(
|
|
390
|
+
`INSERT INTO task_steering_messages
|
|
391
|
+
(id, task_id, body, mode, status, source, created_by_kind, created_at)
|
|
392
|
+
VALUES (?, ?, 'Steer', 'queue', ?, 'mcp', ?, ?)`,
|
|
393
|
+
[crypto.randomUUID(), taskId, status, createdByKind, createdAt],
|
|
394
|
+
);
|
|
395
|
+
const root = await doneSession();
|
|
396
|
+
await steer(root.id, "agent");
|
|
397
|
+
await steer(root.id, "system");
|
|
398
|
+
await steer(root.id, "user", AFTER, "cancelled");
|
|
399
|
+
await steer(root.id, "user", BEFORE, "handled");
|
|
400
|
+
expect(await readState(root.id)).toBe("done");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
262
404
|
test("needs attention uses the latest failed status and pending approvals anywhere in the chain", async () => {
|
|
263
405
|
const failed = await task();
|
|
264
406
|
const child = await task("shared/a/", { parentTaskId: failed.id }, NOW);
|
|
@@ -491,3 +633,270 @@ test("limit clamps to 200 and count without chain filters does not recurse", asy
|
|
|
491
633
|
expect(body.sessions).toHaveLength(200);
|
|
492
634
|
expect(body.total).toBe(205);
|
|
493
635
|
});
|
|
636
|
+
|
|
637
|
+
describe("Mine view hides machine-started roots", () => {
|
|
638
|
+
test("a workflow root attributed to the viewer is hidden by default and shown under All", async () => {
|
|
639
|
+
// Positive control: pr-review-demo fan-out carries the trigger user as requester.
|
|
640
|
+
const demo = await task("shared/", { source: "workflow", tags: ["pr-review-demo"] });
|
|
641
|
+
const scheduled = await task("shared/", { source: "schedule" });
|
|
642
|
+
const heartbeat = await task("shared/", { source: "system" });
|
|
643
|
+
// Negative controls: the viewer's own human sessions stay.
|
|
644
|
+
const slack = await task("shared/", { source: "slack" });
|
|
645
|
+
const ui = await task("shared/", { source: "ui" });
|
|
646
|
+
const whatsapp = await task("shared/", { source: "system", taskType: "kapso-inbound" });
|
|
647
|
+
// A workflow root someone else shared with the viewer stays too.
|
|
648
|
+
const sharedRun = await task("shared/", { source: "workflow", requestedByUserId: other.id });
|
|
649
|
+
await share(sharedRun);
|
|
650
|
+
// Ownerless roots stay out of both views, as before.
|
|
651
|
+
await task("shared/", { source: "workflow", requestedByUserId: undefined });
|
|
652
|
+
|
|
653
|
+
const headers = { "x-swarm-user-id": owner.id };
|
|
654
|
+
const list = async (query: string) => {
|
|
655
|
+
const res = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50&${query}`, { headers });
|
|
656
|
+
const body = (await res.json()) as { total: number; sessions: { root: { id: string } }[] };
|
|
657
|
+
expect(body.sessions).toHaveLength(body.total);
|
|
658
|
+
return body.sessions.map((s) => s.root.id).sort();
|
|
659
|
+
};
|
|
660
|
+
const tree = async (query: string) => {
|
|
661
|
+
const res = await fetch(`${baseUrl}/api/session-tree?${query}`, { headers });
|
|
662
|
+
return ((await res.json()) as Awaited<ReturnType<typeof getSessionTree>>).noGroup.total;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const mine = [slack.id, ui.id, whatsapp.id, sharedRun.id].sort();
|
|
666
|
+
const all = [...mine, demo.id, scheduled.id, heartbeat.id].sort();
|
|
667
|
+
expect(await list("")).toEqual(mine);
|
|
668
|
+
expect(await list("show=mine")).toEqual(mine);
|
|
669
|
+
expect(await list("show=all")).toEqual(all);
|
|
670
|
+
expect(await tree("")).toBe(mine.length);
|
|
671
|
+
expect(await tree("show=all")).toBe(all.length);
|
|
672
|
+
|
|
673
|
+
// No picked user: the bypass actor still sees every root, ownerless included.
|
|
674
|
+
const bypass = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50`);
|
|
675
|
+
expect(((await bypass.json()) as { total: number }).total).toBe(all.length + 1);
|
|
676
|
+
// Access is unchanged: the hidden session still opens by direct link.
|
|
677
|
+
const detail = await fetch(`${baseUrl}/api/sessions/${demo.id}`, { headers });
|
|
678
|
+
expect(detail.status).toBe(200);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("source filter and list triage", () => {
|
|
683
|
+
const asOwner = () => ({ "x-swarm-user-id": owner.id, "content-type": "application/json" });
|
|
684
|
+
const treeOf = async (query = "") => {
|
|
685
|
+
const res = await fetch(`${baseUrl}/api/session-tree?${query}`, { headers: asOwner() });
|
|
686
|
+
expect(res.status).toBe(200);
|
|
687
|
+
return (await res.json()) as Awaited<ReturnType<typeof getSessionTree>>;
|
|
688
|
+
};
|
|
689
|
+
const listOf = async (query: string) => {
|
|
690
|
+
const res = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50&${query}`, {
|
|
691
|
+
headers: asOwner(),
|
|
692
|
+
});
|
|
693
|
+
const body = (await res.json()) as {
|
|
694
|
+
total: number;
|
|
695
|
+
sessions: { root: { id: string }; readState?: string }[];
|
|
696
|
+
};
|
|
697
|
+
return body;
|
|
698
|
+
};
|
|
699
|
+
const triage = (body: Record<string, unknown>, withUser = true) =>
|
|
700
|
+
fetch(`${baseUrl}/api/session-triage`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
headers: withUser ? asOwner() : { "content-type": "application/json" },
|
|
703
|
+
body: JSON.stringify(body),
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("hideSource leaves a source out of sections and lists, and sources still count it", async () => {
|
|
707
|
+
const slackA = await task("shared/", { source: "slack" });
|
|
708
|
+
const slackB = await task("shared/a/", { source: "slack" });
|
|
709
|
+
const ui = await task("shared/", { source: "ui" });
|
|
710
|
+
const chat = await task("shared/", { source: "chat" });
|
|
711
|
+
|
|
712
|
+
const full = await treeOf();
|
|
713
|
+
expect(full.inbox.total).toBe(4);
|
|
714
|
+
expect(full.sources).toEqual([
|
|
715
|
+
{ source: "slack", total: 2 },
|
|
716
|
+
{ source: "chat", total: 1 },
|
|
717
|
+
{ source: "ui", total: 1 },
|
|
718
|
+
]);
|
|
719
|
+
|
|
720
|
+
const hidden = await treeOf("hideSource=slack,chat");
|
|
721
|
+
expect(hidden.inbox.total).toBe(1);
|
|
722
|
+
expect(hidden.noGroup.total).toBe(1);
|
|
723
|
+
expect(hidden.groups).toEqual([]);
|
|
724
|
+
// The menu still offers the hidden sources back.
|
|
725
|
+
expect(hidden.sources).toEqual(full.sources);
|
|
726
|
+
|
|
727
|
+
const inbox = await listOf("triage=inbox&hideSource=slack,chat");
|
|
728
|
+
expect(inbox.total).toBe(1);
|
|
729
|
+
expect(inbox.sessions.map((s) => s.root.id)).toEqual([ui.id]);
|
|
730
|
+
const all = await listOf("triage=inbox");
|
|
731
|
+
expect(all.sessions.map((s) => s.root.id).sort()).toEqual(
|
|
732
|
+
[slackA.id, slackB.id, ui.id, chat.id].sort(),
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("showSource keeps only the listed sources, and an empty list keeps none", async () => {
|
|
737
|
+
await task("shared/", { source: "slack" });
|
|
738
|
+
const ui = await task("shared/a/", { source: "ui" });
|
|
739
|
+
await task("shared/", { source: "github" });
|
|
740
|
+
|
|
741
|
+
const full = await treeOf();
|
|
742
|
+
const shown = await treeOf("showSource=ui");
|
|
743
|
+
expect(shown.inbox.total).toBe(1);
|
|
744
|
+
expect(shown.groups.map((g) => g.key)).toEqual(["shared/a/"]);
|
|
745
|
+
expect(shown.sources).toEqual(full.sources);
|
|
746
|
+
const inbox = await listOf("triage=inbox&showSource=ui");
|
|
747
|
+
expect(inbox.total).toBe(1);
|
|
748
|
+
expect(inbox.sessions.map((s) => s.root.id)).toEqual([ui.id]);
|
|
749
|
+
|
|
750
|
+
const none = await treeOf("showSource=");
|
|
751
|
+
expect(none.inbox.total).toBe(0);
|
|
752
|
+
expect(none.sources).toEqual(full.sources);
|
|
753
|
+
expect((await listOf("triage=inbox&showSource=")).total).toBe(0);
|
|
754
|
+
|
|
755
|
+
expect((await triage({ action: "done", triage: "inbox", showSource: ["ui"] })).status).toBe(
|
|
756
|
+
200,
|
|
757
|
+
);
|
|
758
|
+
expect((await listOf("triage=done")).sessions.map((s) => s.root.id)).toEqual([ui.id]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("triage by list writes every matching session, honoring the list and the filters", async () => {
|
|
762
|
+
const a = await task("shared/", { source: "ui" });
|
|
763
|
+
const b = await task("shared/", { source: "ui" }, OLD);
|
|
764
|
+
const slack = await task("shared/", { source: "slack" });
|
|
765
|
+
const grouped = await task("shared/a/", { source: "ui" });
|
|
766
|
+
const workflow = await task("shared/", { source: "workflow" });
|
|
767
|
+
const later = await task("shared/", { source: "ui" });
|
|
768
|
+
await state(later.id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
|
|
769
|
+
|
|
770
|
+
// No group, Slack hidden, Mine view: a and b only.
|
|
771
|
+
let res = await triage({ action: "done", noGroup: true, hideSource: ["slack"] });
|
|
772
|
+
expect(res.status).toBe(200);
|
|
773
|
+
expect(await res.json()).toEqual({ updated: 2 });
|
|
774
|
+
let done = await listOf("triage=done");
|
|
775
|
+
expect(done.sessions.map((s) => s.root.id).sort()).toEqual([a.id, b.id].sort());
|
|
776
|
+
// The rest keep their state; the workflow root (All only) was not touched.
|
|
777
|
+
const inbox = await listOf("triage=inbox&show=all");
|
|
778
|
+
expect(inbox.sessions.map((s) => s.root.id).sort()).toEqual(
|
|
779
|
+
[slack.id, grouped.id, workflow.id].sort(),
|
|
780
|
+
);
|
|
781
|
+
expect((await listOf("triage=later")).sessions.map((s) => s.root.id)).toEqual([later.id]);
|
|
782
|
+
|
|
783
|
+
// Back to the Inbox from the Done list, then read and unread across the Inbox.
|
|
784
|
+
res = await triage({ action: "inbox", triage: "done" });
|
|
785
|
+
expect(await res.json()).toEqual({ updated: 2 });
|
|
786
|
+
expect((await listOf("triage=done")).total).toBe(0);
|
|
787
|
+
res = await triage({ action: "unread", triage: "inbox" });
|
|
788
|
+
expect(await res.json()).toEqual({ updated: 4 });
|
|
789
|
+
expect((await treeOf()).inbox.unread).toBe(4);
|
|
790
|
+
res = await triage({ action: "read", triage: "inbox" });
|
|
791
|
+
expect(await res.json()).toEqual({ updated: 4 });
|
|
792
|
+
const tree = await treeOf();
|
|
793
|
+
expect(tree.inbox.unread).toBe(0);
|
|
794
|
+
expect(tree.inbox.total).toBe(4);
|
|
795
|
+
|
|
796
|
+
// One group only.
|
|
797
|
+
res = await triage({ action: "done", groupKey: "shared/a/" });
|
|
798
|
+
expect(await res.json()).toEqual({ updated: 1 });
|
|
799
|
+
done = await listOf("triage=done");
|
|
800
|
+
expect(done.sessions.map((s) => s.root.id)).toEqual([grouped.id]);
|
|
801
|
+
// Show All reaches the workflow root.
|
|
802
|
+
res = await triage({ action: "done", triage: "inbox", show: "all" });
|
|
803
|
+
expect(await res.json()).toEqual({ updated: 4 });
|
|
804
|
+
expect((await listOf("triage=inbox&show=all")).total).toBe(0);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("triage by list acts on the Later list", async () => {
|
|
808
|
+
const snoozedA = await task("shared/", { source: "ui" });
|
|
809
|
+
const snoozedB = await task("shared/", { source: "ui" });
|
|
810
|
+
const open = await task("shared/", { source: "ui" });
|
|
811
|
+
for (const id of [snoozedA.id, snoozedB.id]) {
|
|
812
|
+
await state(id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
let res = await triage({ action: "done", triage: "later" });
|
|
816
|
+
expect(await res.json()).toEqual({ updated: 2 });
|
|
817
|
+
expect((await listOf("triage=later")).total).toBe(0);
|
|
818
|
+
const done = await listOf("triage=done");
|
|
819
|
+
expect(done.sessions.map((s) => s.root.id).sort()).toEqual([snoozedA.id, snoozedB.id].sort());
|
|
820
|
+
const cleared = await getDbClient().query<{ snoozeUntil: string | null }>(
|
|
821
|
+
"SELECT snoozeUntil FROM inbox_item_state WHERE itemId IN (?, ?)",
|
|
822
|
+
[snoozedA.id, snoozedB.id],
|
|
823
|
+
);
|
|
824
|
+
expect(cleared.map((row) => row.snoozeUntil)).toEqual([null, null]);
|
|
825
|
+
|
|
826
|
+
await state(snoozedA.id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
|
|
827
|
+
res = await triage({ action: "inbox", triage: "later" });
|
|
828
|
+
expect(await res.json()).toEqual({ updated: 1 });
|
|
829
|
+
const inbox = await listOf("triage=inbox");
|
|
830
|
+
expect(inbox.sessions.map((s) => s.root.id).sort()).toEqual([snoozedA.id, open.id].sort());
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("triage by list writes the same row fields as the one-session PATCH", async () => {
|
|
834
|
+
type Row = {
|
|
835
|
+
status: string;
|
|
836
|
+
snoozeUntil: string | null;
|
|
837
|
+
dismissedAt: string | null;
|
|
838
|
+
doneAt: string | null;
|
|
839
|
+
readAt: string | null;
|
|
840
|
+
lastReadAt: string | null;
|
|
841
|
+
};
|
|
842
|
+
const rowOf = async (id: string) =>
|
|
843
|
+
(await getDbClient().get<Row>("SELECT * FROM inbox_item_state WHERE itemId = ?", [
|
|
844
|
+
id,
|
|
845
|
+
])) as Row;
|
|
846
|
+
const unreadCursor = "1970-01-01T00:00:00.000Z";
|
|
847
|
+
for (const action of ["done", "inbox", "read", "unread"] as const) {
|
|
848
|
+
// Two sessions with the same earlier state: read once, then snoozed past
|
|
849
|
+
// the wake time (back in the Inbox). One gets the dashboard's PATCH
|
|
850
|
+
// write, the other the list write.
|
|
851
|
+
const one = await task(`shared/${action}-one/`, { source: "ui" });
|
|
852
|
+
const list = await task(`shared/${action}-list/`, { source: "ui" });
|
|
853
|
+
for (const id of [one.id, list.id]) {
|
|
854
|
+
await state(id, "snoozed", NOW, { snoozeUntil: OLD, lastReadAt: OLD });
|
|
855
|
+
await getDbClient().run("UPDATE inbox_item_state SET readAt = ? WHERE itemId = ?", [
|
|
856
|
+
OLD,
|
|
857
|
+
id,
|
|
858
|
+
]);
|
|
859
|
+
}
|
|
860
|
+
const listed = (await fetch(
|
|
861
|
+
`${baseUrl}/api/sessions?scope=tree&groupKey=shared/${action}-one/`,
|
|
862
|
+
{
|
|
863
|
+
headers: asOwner(),
|
|
864
|
+
},
|
|
865
|
+
).then((res) => res.json())) as { sessions: { lastActivityAt: string }[] };
|
|
866
|
+
const lastActivityAt = listed.sessions[0]?.lastActivityAt;
|
|
867
|
+
expect(lastActivityAt).toBeTruthy();
|
|
868
|
+
// The write of `writeFor` in apps/ui/src/api/hooks/use-session-triage.ts.
|
|
869
|
+
const write =
|
|
870
|
+
action === "done"
|
|
871
|
+
? { status: "done" as const }
|
|
872
|
+
: action === "read"
|
|
873
|
+
? { status: "open" as const, readAt: NOW, lastReadAt: lastActivityAt }
|
|
874
|
+
: action === "unread"
|
|
875
|
+
? { status: "open" as const, lastReadAt: unreadCursor }
|
|
876
|
+
: { status: "open" as const };
|
|
877
|
+
await upsertInboxState({ userId: owner.id, itemType: "to_read", itemId: one.id, ...write });
|
|
878
|
+
const res = await triage({ action, groupKey: `shared/${action}-list/` });
|
|
879
|
+
expect(await res.json()).toEqual({ updated: 1 });
|
|
880
|
+
|
|
881
|
+
const [a, b] = [await rowOf(one.id), await rowOf(list.id)];
|
|
882
|
+
expect({ ...b, doneAt: b.doneAt !== null }).toMatchObject({
|
|
883
|
+
status: a.status,
|
|
884
|
+
snoozeUntil: a.snoozeUntil,
|
|
885
|
+
dismissedAt: a.dismissedAt,
|
|
886
|
+
doneAt: a.doneAt !== null,
|
|
887
|
+
readAt: a.readAt,
|
|
888
|
+
lastReadAt: a.lastReadAt,
|
|
889
|
+
});
|
|
890
|
+
// readAt is sticky: the first view stays.
|
|
891
|
+
expect(b.readAt).toBe(OLD);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("triage by list needs a picked user and exactly one list", async () => {
|
|
896
|
+
await task("shared/", { source: "ui" });
|
|
897
|
+
expect((await triage({ action: "done", triage: "inbox" }, false)).status).toBe(400);
|
|
898
|
+
expect((await triage({ action: "done" })).status).toBe(400);
|
|
899
|
+
expect((await triage({ action: "done", triage: "inbox", noGroup: true })).status).toBe(400);
|
|
900
|
+
expect((await listOf("triage=inbox")).total).toBe(1);
|
|
901
|
+
});
|
|
902
|
+
});
|