@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- 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/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- 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/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -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/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
|
@@ -16,8 +16,13 @@ export type SessionReadState = "unread" | "read" | "later" | "done";
|
|
|
16
16
|
export interface SessionFilters {
|
|
17
17
|
/** Restrict roots to these task sources. */
|
|
18
18
|
source?: string[];
|
|
19
|
-
/** Leave out roots of these task sources (the sessions v2 source filter). */
|
|
19
|
+
/** Leave out roots of these task sources (the older sessions v2 source filter). */
|
|
20
20
|
hideSource?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Keep only roots of these task sources (the sessions v2 source filter).
|
|
23
|
+
* Unlike `source`, an empty list matches no root.
|
|
24
|
+
*/
|
|
25
|
+
showSource?: string[];
|
|
21
26
|
/** Match root task text or title with a case-insensitive substring. */
|
|
22
27
|
q?: string;
|
|
23
28
|
/** Match the root requester, excluding NULL requester rows. */
|
|
@@ -50,13 +55,17 @@ export interface SessionFilters {
|
|
|
50
55
|
|
|
51
56
|
type SqlFragment = { sql: string; params: (string | number | null)[] };
|
|
52
57
|
|
|
53
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Shared read-state rule with bound ISO timestamps for the list and tree.
|
|
60
|
+
* Done holds through agent activity (progress, status changes, child tasks):
|
|
61
|
+
* only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
|
|
62
|
+
*/
|
|
54
63
|
function sessionReadStateSql(now = new Date().toISOString()): SqlFragment {
|
|
55
64
|
return {
|
|
56
65
|
sql: `CASE
|
|
57
66
|
WHEN s.id IS NULL THEN CASE WHEN a.lastActivityAt > ? THEN 'unread' ELSE 'read' END
|
|
58
67
|
WHEN s.status = 'dismissed' THEN 'done'
|
|
59
|
-
WHEN s.status = 'done' THEN CASE WHEN a.
|
|
68
|
+
WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
|
|
60
69
|
WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
|
|
61
70
|
WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
|
|
62
71
|
ELSE 'read'
|
|
@@ -100,6 +109,14 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
|
|
|
100
109
|
const hidden = opts.hideSource?.filter(Boolean) ?? [];
|
|
101
110
|
if (hidden.length)
|
|
102
111
|
add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
|
|
112
|
+
if (opts.showSource) {
|
|
113
|
+
const shown = opts.showSource.filter(Boolean);
|
|
114
|
+
add(
|
|
115
|
+
shown.length
|
|
116
|
+
? { sql: `r.source IN (${shown.map(() => "?").join(",")})`, params: shown }
|
|
117
|
+
: { sql: "0", params: [] },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
103
120
|
if (opts.q?.trim()) {
|
|
104
121
|
const like = `%${opts.q.trim().toLowerCase()}%`;
|
|
105
122
|
add({
|
|
@@ -152,6 +169,35 @@ function hasSessionChainFilter(opts: SessionFilters): boolean {
|
|
|
152
169
|
|
|
153
170
|
const runningSql = `latestStatus NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(",")})`;
|
|
154
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Sources of tasks that agents and the server create: delegation and scripts
|
|
174
|
+
* (`mcp`), follow-ups (`system`), deferred wake-ups (`schedule`) and workflow
|
|
175
|
+
* steps. Agents also set `requestedByUserId` explicitly on these, so a requester
|
|
176
|
+
* alone does not mark a human message.
|
|
177
|
+
*/
|
|
178
|
+
const AGENT_TASK_SOURCES = ["mcp", "system", "schedule", "workflow"] as const;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Per chain row, over `chain` and the viewer's Done row `d`: whether the row
|
|
182
|
+
* needs the user since they marked the session Done. That is a human message
|
|
183
|
+
* (a task attributed to a person directly, not copied from its parent, from a
|
|
184
|
+
* human channel), a person's steering message on a chain task (steer or
|
|
185
|
+
* queue, not cancelled), or a pending approval, created after the Done mark.
|
|
186
|
+
* Steering `created_at` and the Done mark's `lastUpdatedAt` are both written
|
|
187
|
+
* as `%Y-%m-%dT%H:%M:%fZ`, so they compare as text.
|
|
188
|
+
*/
|
|
189
|
+
const reopensDoneSql = `(d.id IS NOT NULL AND (
|
|
190
|
+
(chain.createdAt > d.lastUpdatedAt
|
|
191
|
+
AND chain.requestedByUserId IS NOT NULL
|
|
192
|
+
AND chain.requestedByUserIdInherited = 0
|
|
193
|
+
AND COALESCE(chain.source, '') NOT IN (${AGENT_TASK_SOURCES.map((s) => `'${s}'`).join(",")}))
|
|
194
|
+
OR EXISTS (SELECT 1 FROM task_steering_messages sm
|
|
195
|
+
WHERE sm.task_id = chain.id AND sm.created_by_kind = 'user'
|
|
196
|
+
AND sm.status != 'cancelled' AND sm.created_at > d.lastUpdatedAt)
|
|
197
|
+
OR EXISTS (SELECT 1 FROM approval_requests ar
|
|
198
|
+
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
|
|
199
|
+
))`;
|
|
200
|
+
|
|
155
201
|
/**
|
|
156
202
|
* A session starts at a parentTaskId IS NULL root. One recursive CTE computes
|
|
157
203
|
* chain-wide latest activity for list ordering. Root filters precede recursion.
|
|
@@ -162,7 +208,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
|
|
|
162
208
|
const read = opts.viewer?.userId ? sessionReadStateSql(opts.now) : { sql: "NULL", params: [] };
|
|
163
209
|
const params = [...root.params];
|
|
164
210
|
if (opts.withUserId) params.push(opts.withUserId);
|
|
165
|
-
params.push(...read.params, opts.viewer?.userId ?? null);
|
|
211
|
+
params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
|
|
166
212
|
const filters: string[] = [];
|
|
167
213
|
if (opts.triage === "unread") filters.push("readState = 'unread'");
|
|
168
214
|
else if (opts.triage === "later" || opts.triage === "done") {
|
|
@@ -182,19 +228,27 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
|
|
|
182
228
|
return {
|
|
183
229
|
// SQLite takes the bare status from the row with MAX(lastUpdatedAt).
|
|
184
230
|
// Keep this as the only MIN/MAX aggregate so status follows activity.
|
|
185
|
-
|
|
186
|
-
|
|
231
|
+
// `d` is the viewer's Done row, so `reopensDone` can compare chain rows
|
|
232
|
+
// against the Done mark (SUM keeps MAX the only MIN/MAX aggregate).
|
|
233
|
+
sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId,
|
|
234
|
+
createdAt, source, requestedByUserIdInherited) AS (
|
|
235
|
+
SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
|
|
236
|
+
r.createdAt, r.source, r.requestedByUserIdInherited
|
|
187
237
|
FROM agent_tasks r WHERE ${root.sql}
|
|
188
238
|
UNION ALL
|
|
189
|
-
SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId
|
|
239
|
+
SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
|
|
240
|
+
t.createdAt, t.source, t.requestedByUserIdInherited
|
|
190
241
|
FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
|
|
191
242
|
), agg AS (
|
|
192
|
-
SELECT root_id, COUNT(*) AS chainCount,
|
|
193
|
-
status AS latestStatus, MAX(lastUpdatedAt) AS lastActivityAt,
|
|
194
|
-
${opts.withUserId ? "SUM(requestedByUserId = ?) AS withUser," : ""}
|
|
243
|
+
SELECT chain.root_id, COUNT(*) AS chainCount,
|
|
244
|
+
chain.status AS latestStatus, MAX(chain.lastUpdatedAt) AS lastActivityAt,
|
|
245
|
+
${opts.withUserId ? "SUM(chain.requestedByUserId = ?) AS withUser," : ""}
|
|
195
246
|
SUM(EXISTS (SELECT 1 FROM approval_requests ar
|
|
196
|
-
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
|
|
197
|
-
|
|
247
|
+
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval,
|
|
248
|
+
SUM(${reopensDoneSql}) AS reopensDone
|
|
249
|
+
FROM chain LEFT JOIN inbox_item_state d
|
|
250
|
+
ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = chain.root_id AND d.status = 'done'
|
|
251
|
+
GROUP BY chain.root_id
|
|
198
252
|
), state AS (
|
|
199
253
|
SELECT a.*, ${read.sql} AS readState, s.snoozeUntil
|
|
200
254
|
FROM agg a LEFT JOIN inbox_item_state s
|
|
@@ -341,7 +395,7 @@ export interface SessionTree {
|
|
|
341
395
|
export async function getSessionTree(
|
|
342
396
|
actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
|
|
343
397
|
now?: string,
|
|
344
|
-
opts: { mine?: boolean; hideSource?: string[] } = {},
|
|
398
|
+
opts: { mine?: boolean; hideSource?: string[]; showSource?: string[] } = {},
|
|
345
399
|
): Promise<SessionTree> {
|
|
346
400
|
// No source filter in SQL: the source counts must include hidden sources,
|
|
347
401
|
// so the filter menu can offer them back. The loop skips them instead.
|
|
@@ -373,6 +427,7 @@ export async function getSessionTree(
|
|
|
373
427
|
sources: [],
|
|
374
428
|
};
|
|
375
429
|
const hidden = new Set(opts.hideSource);
|
|
430
|
+
const shown = opts.showSource ? new Set(opts.showSource) : null;
|
|
376
431
|
const sources = new Map<string, number>();
|
|
377
432
|
const groups = new Map<string, SessionTreeGroup>();
|
|
378
433
|
const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
|
|
@@ -383,7 +438,7 @@ export async function getSessionTree(
|
|
|
383
438
|
};
|
|
384
439
|
for (const row of rows) {
|
|
385
440
|
sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
|
|
386
|
-
if (hidden.has(row.source)) continue;
|
|
441
|
+
if (hidden.has(row.source) || (shown && !shown.has(row.source))) continue;
|
|
387
442
|
const key = sessionGroupKey(row.key);
|
|
388
443
|
const noGroup =
|
|
389
444
|
key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
|
|
@@ -425,7 +480,10 @@ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
|
|
|
425
480
|
*/
|
|
426
481
|
export async function triageSessionList(
|
|
427
482
|
userId: string,
|
|
428
|
-
filters: Pick<
|
|
483
|
+
filters: Pick<
|
|
484
|
+
SessionFilters,
|
|
485
|
+
"triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "showSource" | "now"
|
|
486
|
+
>,
|
|
429
487
|
action: SessionListTriage,
|
|
430
488
|
): Promise<number> {
|
|
431
489
|
const now = filters.now ?? new Date().toISOString();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getDbClient } from "./runtime";
|
|
2
|
+
|
|
3
|
+
// bm25() takes one weight per FTS column, in declaration order.
|
|
4
|
+
// skills_fts (migration 239): skill_id (UNINDEXED), name, description, content.
|
|
5
|
+
// skills_meta_fts (migration 241): skill_id (UNINDEXED), name, description.
|
|
6
|
+
const SKILLS_FTS_BM25 = "bm25(skills_fts, 0.0, 3.0, 2.0, 1.0)";
|
|
7
|
+
const SKILLS_META_FTS_BM25 = "bm25(skills_meta_fts, 0.0, 3.0, 2.0)";
|
|
8
|
+
|
|
9
|
+
// FTS5 bm25 normalizes by the length of the whole row, so in skills_fts a
|
|
10
|
+
// long SKILL.md body buries a name or description hit. skills_meta_fts holds
|
|
11
|
+
// only those short fields; its score is added at this multiple. On the live
|
|
12
|
+
// catalog, multiples from 2 to 10 rank the same; 3 is mid-range.
|
|
13
|
+
const META_SCORE_MULTIPLE = 3;
|
|
14
|
+
|
|
15
|
+
// English function words that agents put in natural-language queries. Each
|
|
16
|
+
// one matches most of the catalog ("a" matched 189 of 189 skills) and only
|
|
17
|
+
// adds noise to the ranking.
|
|
18
|
+
const STOPWORDS = new Set(
|
|
19
|
+
(
|
|
20
|
+
"a an and are as at be but by can do does for from get got have how i if in into is it its " +
|
|
21
|
+
"me my need of on or our should so that the their them then there these this to use using " +
|
|
22
|
+
"want was we what when where which who why will with would you your"
|
|
23
|
+
).split(" "),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a skills_fts MATCH expression from free text. Every term is quoted as
|
|
28
|
+
* an FTS5 string, so user text cannot inject operators (NEAR, AND, -, *, ^,
|
|
29
|
+
* column filters). Stopwords are dropped and the remaining terms are OR-ed:
|
|
30
|
+
* ranked recall, ordered by bm25. Returns null when no term is left, so the
|
|
31
|
+
* caller falls back to the substring match.
|
|
32
|
+
*/
|
|
33
|
+
export function buildSkillsFtsMatch(query: string): string | null {
|
|
34
|
+
const terms = query
|
|
35
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
36
|
+
.filter((term) => /[\p{L}\p{N}]/u.test(term) && !STOPWORDS.has(term.toLowerCase()))
|
|
37
|
+
.slice(0, 12);
|
|
38
|
+
if (terms.length === 0) return null;
|
|
39
|
+
return terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enabled skills ranked by BM25: the skills_fts score (name 3, description 2,
|
|
44
|
+
* content 1) plus META_SCORE_MULTIPLE times the skills_meta_fts score (name 3,
|
|
45
|
+
* description 2). A skill whose name equals the whole query (case-insensitive)
|
|
46
|
+
* ranks first. Returns [] when the query has no indexable terms or the
|
|
47
|
+
* backend cannot run the FTS query, so the caller can fall back.
|
|
48
|
+
*
|
|
49
|
+
* `columns` is a select list over `skills`; it must not name a bare
|
|
50
|
+
* `skill_id` or `score`, which the joined `hits` and `meta` CTEs also expose.
|
|
51
|
+
*/
|
|
52
|
+
export async function searchSkillRowsRanked<T>(
|
|
53
|
+
query: string,
|
|
54
|
+
limit: number,
|
|
55
|
+
columns: string,
|
|
56
|
+
): Promise<T[]> {
|
|
57
|
+
const match = buildSkillsFtsMatch(query);
|
|
58
|
+
if (!match) return [];
|
|
59
|
+
const exactName = query.trim();
|
|
60
|
+
try {
|
|
61
|
+
return await getDbClient().query<T>(
|
|
62
|
+
`WITH hits AS (
|
|
63
|
+
SELECT skill_id, ${SKILLS_FTS_BM25} AS score FROM skills_fts WHERE skills_fts MATCH ?
|
|
64
|
+
),
|
|
65
|
+
meta AS (
|
|
66
|
+
SELECT skill_id, ${SKILLS_META_FTS_BM25} AS score
|
|
67
|
+
FROM skills_meta_fts WHERE skills_meta_fts MATCH ?
|
|
68
|
+
)
|
|
69
|
+
SELECT ${columns} FROM skills
|
|
70
|
+
LEFT JOIN hits ON hits.skill_id = skills.id
|
|
71
|
+
LEFT JOIN meta ON meta.skill_id = skills.id
|
|
72
|
+
WHERE skills.isEnabled = 1
|
|
73
|
+
AND (hits.skill_id IS NOT NULL OR lower(skills.name) = lower(?))
|
|
74
|
+
ORDER BY lower(skills.name) = lower(?) DESC,
|
|
75
|
+
COALESCE(hits.score, 0) + ${META_SCORE_MULTIPLE} * COALESCE(meta.score, 0) ASC,
|
|
76
|
+
skills.name ASC
|
|
77
|
+
LIMIT ?`,
|
|
78
|
+
[match, match, exactName, exactName, limit],
|
|
79
|
+
);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn(
|
|
82
|
+
"[skills-fts] ranked query failed, using substring match:",
|
|
83
|
+
(err as Error).message,
|
|
84
|
+
);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/be/db/tasks/read.ts
CHANGED
|
@@ -196,12 +196,39 @@ type AgentTaskTimelineRow = Pick<
|
|
|
196
196
|
* past it).
|
|
197
197
|
*/
|
|
198
198
|
const TASK_PREVIEW_BYTES = 4 * (TASK_PREVIEW_LENGTH + 1);
|
|
199
|
-
|
|
199
|
+
/**
|
|
200
|
+
* A leading `<sibling_tasks_in_progress>` block (`src/tasks/sibling-block.ts`)
|
|
201
|
+
* is generated context that can fill the whole preview, so the list row never
|
|
202
|
+
* reaches the ask. The preview starts after the block and the `\n\n`
|
|
203
|
+
* `prependSiblingBlock` puts after it. A block with no closing tag stays.
|
|
204
|
+
*/
|
|
205
|
+
const SIBLING_OPEN = "<sibling_tasks_in_progress>";
|
|
206
|
+
const SIBLING_CLOSE = "</sibling_tasks_in_progress>";
|
|
207
|
+
const TASK_BLOB = "CAST(agent_tasks.task AS BLOB)";
|
|
208
|
+
const SIBLING_CLOSE_AT = `instr(${TASK_BLOB}, CAST('${SIBLING_CLOSE}' AS BLOB))`;
|
|
209
|
+
const SIBLING_END = `(${SIBLING_CLOSE_AT} + ${SIBLING_CLOSE.length})`;
|
|
210
|
+
const TASK_PREVIEW_START = `CASE
|
|
211
|
+
WHEN substr(${TASK_BLOB}, 1, ${SIBLING_OPEN.length}) = CAST('${SIBLING_OPEN}' AS BLOB)
|
|
212
|
+
AND ${SIBLING_CLOSE_AT} > 0
|
|
213
|
+
THEN ${SIBLING_END} + (substr(${TASK_BLOB}, ${SIBLING_END}, 2) = x'0a0a') * 2
|
|
214
|
+
ELSE 1 END`;
|
|
215
|
+
const TASK_PREVIEW_SQL = `substr(${TASK_BLOB}, ${TASK_PREVIEW_START}, ${TASK_PREVIEW_BYTES}) AS task`;
|
|
200
216
|
// ignoreBOM keeps a leading U+FEFF, which the full-text preview keeps too.
|
|
201
217
|
const taskPreviewDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
202
218
|
|
|
219
|
+
/** What `TASK_PREVIEW_START` does in SQL, for a full-text row. */
|
|
220
|
+
function skipSiblingBlock(text: string): string {
|
|
221
|
+
if (!text.startsWith(SIBLING_OPEN)) return text;
|
|
222
|
+
const close = text.indexOf(SIBLING_CLOSE);
|
|
223
|
+
if (close === -1) return text;
|
|
224
|
+
const end = close + SIBLING_CLOSE.length;
|
|
225
|
+
return text.slice(text.startsWith("\n\n", end) ? end + 2 : end);
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
function taskPreview(task: TaskPreviewSource["task"]): string {
|
|
204
|
-
|
|
229
|
+
// Bytes come from `TASK_PREVIEW_SQL`, which already skipped the block.
|
|
230
|
+
const text =
|
|
231
|
+
task instanceof Uint8Array ? taskPreviewDecoder.decode(task) : skipSiblingBlock(task ?? "");
|
|
205
232
|
return dependencies.previewText(text, TASK_PREVIEW_LENGTH);
|
|
206
233
|
}
|
|
207
234
|
|
package/src/be/db/tasks/write.ts
CHANGED
|
@@ -657,7 +657,15 @@ export async function overwriteTerminalTaskResultText(
|
|
|
657
657
|
return row ? rowToAgentTask(row) : task;
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
-
|
|
660
|
+
/**
|
|
661
|
+
* `cascadeDependents: false` skips the dependents sweep for a caller that
|
|
662
|
+
* already knows no task depends on this one (a bulk cancel looks once).
|
|
663
|
+
*/
|
|
664
|
+
export async function cancelTask(
|
|
665
|
+
id: string,
|
|
666
|
+
reason?: string,
|
|
667
|
+
options: { cascadeDependents?: boolean } = {},
|
|
668
|
+
): Promise<AgentTask | null> {
|
|
661
669
|
const oldTask = await getTaskById(id);
|
|
662
670
|
if (!oldTask) return null;
|
|
663
671
|
|
|
@@ -736,10 +744,12 @@ export async function cancelTask(id: string, reason?: string): Promise<AgentTask
|
|
|
736
744
|
);
|
|
737
745
|
}
|
|
738
746
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
747
|
+
if (options.cascadeDependents !== false) {
|
|
748
|
+
try {
|
|
749
|
+
await dependencies.cascadeFailDependents(id, "cancelled");
|
|
750
|
+
} catch (err) {
|
|
751
|
+
console.error("[cancelTask] cascade-fail dependents error:", err);
|
|
752
|
+
}
|
|
743
753
|
}
|
|
744
754
|
}
|
|
745
755
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { getCurrentRequestUserId } from "../../utils/request-auth-context";
|
|
2
|
+
import { openSealedJson, sealJson } from "../sealed-json";
|
|
3
|
+
import { getDbClient } from "./runtime";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-side `Idempotency-Key` records for workflow create and trigger.
|
|
7
|
+
*
|
|
8
|
+
* A record is written in the transaction that accepts the operation (the
|
|
9
|
+
* workflow insert, or the run insert with its initial state), so it exists if
|
|
10
|
+
* and only if the operation committed. Readers look it up before re-running
|
|
11
|
+
* any check that can change between attempts (enabled, cooldown, readiness,
|
|
12
|
+
* trigger schema) and replay the stored response instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type WorkflowIdempotencyOperation = "workflow.create" | "workflow.trigger";
|
|
16
|
+
|
|
17
|
+
/** Who may replay a record: one caller, one operation, one target, one key. */
|
|
18
|
+
export interface WorkflowIdempotencyScope {
|
|
19
|
+
/** Stable caller identity, e.g. `user:<id>`; never a credential value. */
|
|
20
|
+
principal: string;
|
|
21
|
+
operation: WorkflowIdempotencyOperation;
|
|
22
|
+
/** The triggered workflow id; empty for create. */
|
|
23
|
+
targetId: string;
|
|
24
|
+
/** The client's `Idempotency-Key` value. Stored only as a hash. */
|
|
25
|
+
key: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A claim for one request: its scope plus the fingerprint of what it asked for. */
|
|
29
|
+
export interface WorkflowIdempotencyClaim extends WorkflowIdempotencyScope {
|
|
30
|
+
fingerprint: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AcceptedWorkflowOperation {
|
|
34
|
+
fingerprint: string;
|
|
35
|
+
status: number;
|
|
36
|
+
/** The original response body; null once the workflow it names was deleted. */
|
|
37
|
+
body: unknown;
|
|
38
|
+
resourceDeletedAt: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The key was accepted for another request while this one was getting ready.
|
|
43
|
+
* Thrown inside the accepting transaction, so this request commits nothing;
|
|
44
|
+
* the caller looks the record up again and answers from it.
|
|
45
|
+
*/
|
|
46
|
+
export class IdempotencyKeyTakenError extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("Idempotency-Key was accepted by a concurrent request");
|
|
49
|
+
this.name = "IdempotencyKeyTakenError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type AcceptedRow = {
|
|
54
|
+
requestFingerprint: string;
|
|
55
|
+
responseStatus: number;
|
|
56
|
+
responseBody: string | null;
|
|
57
|
+
resourceDeletedAt: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function sha256(text: string): string {
|
|
61
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
62
|
+
hasher.update(text);
|
|
63
|
+
return hasher.digest("hex");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sortKeys(value: unknown): unknown {
|
|
67
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
68
|
+
if (!value || typeof value !== "object") return value;
|
|
69
|
+
// Code-unit order, not localeCompare: the fingerprint must not depend on the
|
|
70
|
+
// server's locale.
|
|
71
|
+
return Object.fromEntries(
|
|
72
|
+
Object.entries(value as Record<string, unknown>)
|
|
73
|
+
.filter(([, nested]) => nested !== undefined)
|
|
74
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
75
|
+
.map(([key, nested]) => [key, sortKeys(nested)]),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fingerprint of an accepted request body. Object key order does not matter,
|
|
81
|
+
* array order does, and every value counts as sent (nothing is redacted first,
|
|
82
|
+
* so two requests that differ only in a secret never match). Only the hash is
|
|
83
|
+
* stored.
|
|
84
|
+
*/
|
|
85
|
+
export function workflowRequestFingerprint(
|
|
86
|
+
operation: WorkflowIdempotencyOperation,
|
|
87
|
+
body: unknown,
|
|
88
|
+
): string {
|
|
89
|
+
return sha256(`${operation}\n${JSON.stringify(sortKeys(body ?? null))}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SCOPE_WHERE = "principal = ? AND operation = ? AND targetId = ? AND keyHash = ?";
|
|
93
|
+
|
|
94
|
+
function scopeParams(scope: WorkflowIdempotencyScope): string[] {
|
|
95
|
+
return [scope.principal, scope.operation, scope.targetId, sha256(scope.key)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function findAcceptedWorkflowOperation(
|
|
99
|
+
scope: WorkflowIdempotencyScope,
|
|
100
|
+
): Promise<AcceptedWorkflowOperation | null> {
|
|
101
|
+
const row = await getDbClient().get<AcceptedRow>(
|
|
102
|
+
`SELECT requestFingerprint, responseStatus, responseBody, resourceDeletedAt
|
|
103
|
+
FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
|
|
104
|
+
scopeParams(scope),
|
|
105
|
+
);
|
|
106
|
+
if (!row) return null;
|
|
107
|
+
return {
|
|
108
|
+
fingerprint: row.requestFingerprint,
|
|
109
|
+
status: row.responseStatus,
|
|
110
|
+
body: row.responseBody === null ? null : openSealedJson(row.responseBody),
|
|
111
|
+
resourceDeletedAt: row.resourceDeletedAt,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throw `IdempotencyKeyTakenError` when the scope already has a record. Inside
|
|
117
|
+
* the accepting transaction, call it before writing a resource whose insert
|
|
118
|
+
* could collide with the concurrent winner's (a unique workflow name), so the
|
|
119
|
+
* loser replays instead of failing on that constraint.
|
|
120
|
+
*/
|
|
121
|
+
export async function assertWorkflowIdempotencyKeyUnused(
|
|
122
|
+
scope: WorkflowIdempotencyScope,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const taken = await getDbClient().get(
|
|
125
|
+
`SELECT 1 FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
|
|
126
|
+
scopeParams(scope),
|
|
127
|
+
);
|
|
128
|
+
if (taken) throw new IdempotencyKeyTakenError();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record the accepted operation. Call it inside the transaction that creates
|
|
133
|
+
* the resource: a rollback drops both. Throws `IdempotencyKeyTakenError` when
|
|
134
|
+
* the scope already has a record (a concurrent request committed first); the
|
|
135
|
+
* unique index backs this across processes.
|
|
136
|
+
*/
|
|
137
|
+
export async function recordAcceptedWorkflowOperation(
|
|
138
|
+
claim: WorkflowIdempotencyClaim,
|
|
139
|
+
accepted: { workflowId: string; runId?: string; status: number; body: unknown },
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
await assertWorkflowIdempotencyKeyUnused(claim);
|
|
142
|
+
const actor = getCurrentRequestUserId() ?? null;
|
|
143
|
+
try {
|
|
144
|
+
await getDbClient().run(
|
|
145
|
+
`INSERT INTO workflow_idempotency_keys
|
|
146
|
+
(id, principal, operation, targetId, keyHash, requestFingerprint, workflowId, runId,
|
|
147
|
+
responseStatus, responseBody, createdAt, created_by, updated_by)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
149
|
+
[
|
|
150
|
+
crypto.randomUUID(),
|
|
151
|
+
...scopeParams(claim),
|
|
152
|
+
claim.fingerprint,
|
|
153
|
+
accepted.workflowId,
|
|
154
|
+
accepted.runId ?? null,
|
|
155
|
+
accepted.status,
|
|
156
|
+
sealJson(accepted.body),
|
|
157
|
+
new Date().toISOString(),
|
|
158
|
+
actor,
|
|
159
|
+
actor,
|
|
160
|
+
],
|
|
161
|
+
);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) {
|
|
164
|
+
throw new IdempotencyKeyTakenError();
|
|
165
|
+
}
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Turn every record naming `workflowId` into a tombstone: the stored response
|
|
172
|
+
* (which may carry the deleted workflow's definition) is cleared and the key
|
|
173
|
+
* stays taken, so a retry is told the resource is gone instead of creating or
|
|
174
|
+
* triggering anything again. Runs in the delete's transaction.
|
|
175
|
+
*/
|
|
176
|
+
export async function tombstoneWorkflowIdempotencyKeys(workflowId: string): Promise<void> {
|
|
177
|
+
await getDbClient().run(
|
|
178
|
+
`UPDATE workflow_idempotency_keys
|
|
179
|
+
SET responseBody = NULL, resourceDeletedAt = ?, updated_by = ?
|
|
180
|
+
WHERE workflowId = ? AND resourceDeletedAt IS NULL`,
|
|
181
|
+
[new Date().toISOString(), getCurrentRequestUserId() ?? null, workflowId],
|
|
182
|
+
);
|
|
183
|
+
}
|