@desplega.ai/agent-swarm 2.7.2 → 2.7.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/README.md +6 -0
- package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
- package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
- package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
- package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
- package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
- package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
- package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
- package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
- package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
- package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
- package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
- package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
- package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
- package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
- package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
- package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
- package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
- package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
- package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
- package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
- package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
- package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
- package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
- package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
- package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
- package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
- package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
- package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
- package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
- package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
- package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
- package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
- package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
- package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
- package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
- package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
- package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
- package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
- package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
- package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
- package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
- package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
- package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
- package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
- package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
- package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
- package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
- package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
- package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
- package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
- package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
- package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
- package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
- package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
- package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
- package/dist/cli.js +19 -19
- package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
- package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
- package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
- package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
- package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
- package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
- package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
- package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
- package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
- package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
- package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
- package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
- package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
- package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
- package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
- package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
- package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
- package/dist/handlers-ss5948rq.js +42 -0
- package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
- package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
- package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
- package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
- package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
- package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
- package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
- package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
- package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
- package/dist/index-z3b1vfjx.js +44 -0
- package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
- package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
- package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
- package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
- package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
- package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
- package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
- package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
- package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
- package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
- package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
- package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
- package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
- package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
- package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
- package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
- package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
- package/openapi.json +134 -3
- package/package.json +5 -5
- package/src/be/db/agents.ts +16 -1
- package/src/be/db/runtime.ts +25 -15
- package/src/be/db/session-summaries.ts +120 -0
- package/src/be/db/tasks/read.ts +7 -4
- package/src/be/db/tasks/search-index.ts +159 -0
- package/src/be/db/tasks/write.ts +3 -0
- package/src/be/db-queries/slack-inbound.ts +77 -0
- package/src/be/db.ts +27 -8
- package/src/be/mcp-overflow-sweep.ts +39 -0
- package/src/be/migrations/253_session_summaries.sql +300 -0
- package/src/be/migrations/254_query_audit_indexes.sql +25 -0
- package/src/be/migrations/256_agent_slack_icon.sql +2 -0
- package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
- package/src/comb/config.ts +6 -2
- package/src/comb/requester.ts +69 -0
- package/src/comb/review-batch.ts +24 -2
- package/src/commands/onboard/agent-fs-version.ts +1 -1
- package/src/commands/onboard/compose-generator.ts +7 -2
- package/src/commands/onboard/env-generator.ts +8 -3
- package/src/commands/runner.ts +41 -24
- package/src/fs/agent-fs-provider.ts +9 -0
- package/src/heartbeat/heartbeat.ts +13 -6
- package/src/http/agents.ts +10 -2
- package/src/http/index.ts +15 -0
- package/src/http/integrations.ts +20 -0
- package/src/http/list-total-cache.ts +62 -0
- package/src/http/sessions.ts +9 -2
- package/src/http/slack-inbound.ts +1 -0
- package/src/http/status.ts +4 -1
- package/src/http/tasks.ts +12 -2
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/queue-stall-alarm.ts +4 -1
- package/src/slack/actions.ts +2 -2
- package/src/slack/app-icon.ts +50 -0
- package/src/slack/app.ts +13 -1
- package/src/slack/assistant.ts +22 -1
- package/src/slack/event-dedup.test.ts +11 -0
- package/src/slack/event-dedup.ts +8 -4
- package/src/slack/inbound-dispatch.ts +58 -0
- package/src/slack/render-v2.ts +7 -8
- package/src/slack/responses.ts +21 -8
- package/src/slack/socket-ack.ts +237 -0
- package/src/slack/thread-buffer.ts +3 -5
- package/src/slack/watcher.ts +2 -2
- package/src/slack/work-objects.ts +5 -1
- package/src/tests/agent-fs-release-notes.test.ts +40 -0
- package/src/tests/agent-slack-icon.test.ts +85 -0
- package/src/tests/approval-cancellation-migration.test.ts +2 -0
- package/src/tests/asset-key-migration.test.ts +26 -0
- package/src/tests/comb-default.test.ts +159 -0
- package/src/tests/comb-review-batch.test.ts +192 -4
- package/src/tests/comb-status.test.ts +65 -2
- package/src/tests/db-agents.test.ts +13 -0
- package/src/tests/db-tasks-read.test.ts +10 -15
- package/src/tests/deploy-one-box.test.ts +161 -0
- package/src/tests/deploy-stubs.ts +68 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
- package/src/tests/fixtures/deploy-stubs/bun +3 -0
- package/src/tests/fixtures/deploy-stubs/bunx +27 -0
- package/src/tests/fixtures/deploy-stubs/curl +26 -0
- package/src/tests/fixtures/deploy-stubs/docker +122 -0
- package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
- package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
- package/src/tests/heartbeat-expiry.test.ts +151 -25
- package/src/tests/http-api-integration.test.ts +31 -0
- package/src/tests/human-free-reclassify.test.ts +6 -0
- package/src/tests/install-sh-bind.test.ts +242 -0
- package/src/tests/install-sh-tunnel.test.ts +89 -0
- package/src/tests/kv-storage.test.ts +28 -0
- package/src/tests/list-endpoint-slimming.test.ts +3 -0
- package/src/tests/list-total-cache.test.ts +63 -0
- package/src/tests/mcp-overflow-sweep.test.ts +84 -0
- package/src/tests/onboard-compose.test.ts +17 -3
- package/src/tests/onboard-env.test.ts +13 -0
- package/src/tests/onboarding.test.ts +1 -1
- package/src/tests/preload.ts +1 -0
- package/src/tests/query-audit-plans.test.ts +314 -0
- package/src/tests/script-connections.test.ts +6 -0
- package/src/tests/session-summaries.test.ts +384 -0
- package/src/tests/sessions-tree.test.ts +5 -1
- package/src/tests/slack-app-icon.test.ts +90 -0
- package/src/tests/slack-external-channel-egress.test.ts +1 -1
- package/src/tests/slack-inbound-receipts.test.ts +34 -0
- package/src/tests/slack-inline-output.test.ts +29 -2
- package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
- package/src/tests/slack-render-v2.test.ts +35 -0
- package/src/tests/slack-socket-ack.test.ts +553 -0
- package/src/tests/task-lineage.test.ts +3 -0
- package/src/tests/task-search-filter.test.ts +4 -4
- package/src/tests/task-search-fts.test.ts +320 -0
- package/src/tests/update-profile-auth.test.ts +28 -0
- package/src/tools/get-swarm.ts +1 -0
- package/src/tools/get-tasks.ts +6 -1
- package/src/tools/join-swarm.ts +1 -0
- package/src/tools/my-agent-info.ts +1 -0
- package/src/tools/slack-post.ts +2 -2
- package/src/tools/slack-reply.ts +2 -2
- package/src/tools/slack-start-thread.ts +2 -2
- package/src/tools/slack-update.ts +1 -1
- package/src/tools/update-profile.ts +20 -4
- package/src/tools/utils.ts +3 -4
- package/src/types.ts +47 -0
- package/src/utils/constants.ts +127 -5
- package/templates/official/lead/HEARTBEAT.md +4 -2
- package/templates/official/lead/config.json +2 -2
- package/templates/skills/swarm-docs/content.md +2 -0
- package/dist/handlers-7x8sywgr.js +0 -42
- package/dist/index-mc30r1hs.js +0 -44
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
-- Session summaries: one narrow row per session root, kept exact by triggers.
|
|
2
|
+
--
|
|
3
|
+
-- A session is a root task (parentTaskId IS NULL) plus every task reachable
|
|
4
|
+
-- through parentTaskId. The session list, count and tree walk that chain with
|
|
5
|
+
-- a recursive CTE on every request (src/be/db/session-tree.ts). These tables
|
|
6
|
+
-- store the walk's result so the read path can skip it:
|
|
7
|
+
--
|
|
8
|
+
-- - session_members(taskId, rootId): the root of every chain task. A task
|
|
9
|
+
-- whose parent is missing belongs to no session, as in the chain walk.
|
|
10
|
+
-- - session_summaries(rootId, ...): copies of the root columns the list
|
|
11
|
+
-- filters on, plus the chain aggregates the list, count and tree read.
|
|
12
|
+
--
|
|
13
|
+
-- Nothing reads these tables yet. The read path switches over in a later
|
|
14
|
+
-- change, after a production drift check against the chain walk
|
|
15
|
+
-- (sessionSummaryDriftQuery in src/be/db/session-summaries.ts).
|
|
16
|
+
--
|
|
17
|
+
-- Consistency rules:
|
|
18
|
+
-- - Inserts and progress writes update a summary incrementally (the hot path).
|
|
19
|
+
-- - Rare, non-monotonic changes recompute the whole chain through the
|
|
20
|
+
-- session_summary_recompute view, the one copy of the recompute SQL.
|
|
21
|
+
-- - parentTaskId is immutable: no code moves a task between chains, and the
|
|
22
|
+
-- members table depends on it. Changing it aborts.
|
|
23
|
+
-- - Trigger bodies cannot use WITH, so deleteTask (the only task delete)
|
|
24
|
+
-- drops the members rows of a deleted task's descendants itself, before the
|
|
25
|
+
-- DELETE. The delete trigger then recomputes the chain.
|
|
26
|
+
-- - session_summary_recompute_apply reads agent_tasks, task_steering_messages
|
|
27
|
+
-- and approval_requests. A later migration that rebuilds one of those
|
|
28
|
+
-- tables (create, copy, drop, rename) must drop that trigger before the
|
|
29
|
+
-- rename and re-create it after, or the rename fails its schema check. The
|
|
30
|
+
-- boot-time agent_tasks rebuild in src/be/db/runtime.ts already does.
|
|
31
|
+
|
|
32
|
+
CREATE TABLE session_members (
|
|
33
|
+
taskId TEXT PRIMARY KEY,
|
|
34
|
+
rootId TEXT NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX idx_session_members_root ON session_members(rootId);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE session_summaries (
|
|
39
|
+
rootId TEXT PRIMARY KEY,
|
|
40
|
+
-- Root copies (filters, ordering, tree grouping).
|
|
41
|
+
createdAt TEXT NOT NULL,
|
|
42
|
+
source TEXT,
|
|
43
|
+
"key" TEXT NOT NULL,
|
|
44
|
+
requestedByUserId TEXT,
|
|
45
|
+
scheduleId TEXT,
|
|
46
|
+
taskType TEXT,
|
|
47
|
+
slackChannelId TEXT,
|
|
48
|
+
contextKey TEXT,
|
|
49
|
+
-- Chain aggregates.
|
|
50
|
+
chainCount INTEGER NOT NULL,
|
|
51
|
+
lastActivityAt TEXT NOT NULL,
|
|
52
|
+
latestStatus TEXT NOT NULL,
|
|
53
|
+
pendingApprovalCount INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
-- Newest event that reopens a Done session: a human message (a task a
|
|
55
|
+
-- person requested directly from a human channel), a person's live steering
|
|
56
|
+
-- message, or a pending approval. Done reopens when this is newer than the
|
|
57
|
+
-- Done mark, which is what the chain walk's per-row check asks.
|
|
58
|
+
lastHumanAt TEXT
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX idx_session_summaries_activity
|
|
61
|
+
ON session_summaries(lastActivityAt DESC, createdAt DESC, rootId DESC);
|
|
62
|
+
CREATE INDEX idx_session_summaries_thread
|
|
63
|
+
ON session_summaries("key") WHERE requestedByUserId IS NOT NULL;
|
|
64
|
+
|
|
65
|
+
-- INSERT INTO session_summary_recompute (rootId) VALUES (?) recomputes one
|
|
66
|
+
-- summary from its members. A missing root is a no-op, including a root whose
|
|
67
|
+
-- row is mid-delete: an FK cascade (steering messages) fires its trigger after
|
|
68
|
+
-- the row is gone but before the delete trigger drops the summary.
|
|
69
|
+
CREATE VIEW session_summary_recompute AS SELECT NULL AS rootId;
|
|
70
|
+
|
|
71
|
+
CREATE TRIGGER session_summary_recompute_apply
|
|
72
|
+
INSTEAD OF INSERT ON session_summary_recompute
|
|
73
|
+
BEGIN
|
|
74
|
+
UPDATE session_summaries SET
|
|
75
|
+
chainCount = (SELECT COUNT(*) FROM session_members m WHERE m.rootId = NEW.rootId),
|
|
76
|
+
lastActivityAt = (SELECT MAX(t.lastUpdatedAt) FROM session_members m
|
|
77
|
+
JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId),
|
|
78
|
+
latestStatus = (SELECT t.status FROM session_members m
|
|
79
|
+
JOIN agent_tasks t ON t.id = m.taskId WHERE m.rootId = NEW.rootId
|
|
80
|
+
ORDER BY t.lastUpdatedAt DESC LIMIT 1),
|
|
81
|
+
pendingApprovalCount = (SELECT COUNT(*) FROM session_members m
|
|
82
|
+
JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
|
|
83
|
+
WHERE m.rootId = NEW.rootId AND ar.status = 'pending'),
|
|
84
|
+
lastHumanAt = (SELECT MAX(at) FROM (
|
|
85
|
+
SELECT t.createdAt AS at FROM session_members m JOIN agent_tasks t ON t.id = m.taskId
|
|
86
|
+
WHERE m.rootId = NEW.rootId AND t.requestedByUserId IS NOT NULL
|
|
87
|
+
AND t.requestedByUserIdInherited = 0
|
|
88
|
+
AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
|
|
89
|
+
UNION ALL
|
|
90
|
+
SELECT sm.created_at FROM session_members m
|
|
91
|
+
JOIN task_steering_messages sm ON sm.task_id = m.taskId
|
|
92
|
+
WHERE m.rootId = NEW.rootId AND sm.created_by_kind = 'user' AND sm.status != 'cancelled'
|
|
93
|
+
UNION ALL
|
|
94
|
+
SELECT ar.createdAt FROM session_members m
|
|
95
|
+
JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
|
|
96
|
+
WHERE m.rootId = NEW.rootId AND ar.status = 'pending'))
|
|
97
|
+
WHERE rootId = NEW.rootId
|
|
98
|
+
AND EXISTS (SELECT 1 FROM agent_tasks WHERE id = NEW.rootId);
|
|
99
|
+
END;
|
|
100
|
+
|
|
101
|
+
-- A new root opens a session; a new child joins its parent's session. A child
|
|
102
|
+
-- of a missing parent joins nothing (task ids are fresh UUIDs, so no later
|
|
103
|
+
-- insert can adopt it).
|
|
104
|
+
CREATE TRIGGER session_task_insert
|
|
105
|
+
AFTER INSERT ON agent_tasks
|
|
106
|
+
BEGIN
|
|
107
|
+
INSERT OR REPLACE INTO session_members (taskId, rootId)
|
|
108
|
+
SELECT NEW.id, NEW.id WHERE NEW.parentTaskId IS NULL
|
|
109
|
+
UNION ALL
|
|
110
|
+
SELECT NEW.id, m.rootId FROM session_members m WHERE m.taskId = NEW.parentTaskId;
|
|
111
|
+
|
|
112
|
+
INSERT OR REPLACE INTO session_summaries (rootId, createdAt, source, "key", requestedByUserId,
|
|
113
|
+
scheduleId, taskType, slackChannelId, contextKey, chainCount, lastActivityAt, latestStatus,
|
|
114
|
+
pendingApprovalCount, lastHumanAt)
|
|
115
|
+
SELECT NEW.id, NEW.createdAt, NEW.source, NEW."key", NEW.requestedByUserId, NEW.scheduleId,
|
|
116
|
+
NEW.taskType, NEW.slackChannelId, NEW.contextKey, 1, NEW.lastUpdatedAt, NEW.status, 0,
|
|
117
|
+
CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
|
|
118
|
+
AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
|
|
119
|
+
THEN NEW.createdAt END
|
|
120
|
+
WHERE NEW.parentTaskId IS NULL;
|
|
121
|
+
|
|
122
|
+
UPDATE session_summaries SET
|
|
123
|
+
chainCount = chainCount + 1,
|
|
124
|
+
latestStatus = CASE WHEN NEW.lastUpdatedAt >= lastActivityAt THEN NEW.status ELSE latestStatus END,
|
|
125
|
+
lastActivityAt = MAX(lastActivityAt, NEW.lastUpdatedAt),
|
|
126
|
+
lastHumanAt = CASE WHEN NEW.requestedByUserId IS NOT NULL AND NEW.requestedByUserIdInherited = 0
|
|
127
|
+
AND COALESCE(NEW.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
|
|
128
|
+
AND (lastHumanAt IS NULL OR NEW.createdAt > lastHumanAt)
|
|
129
|
+
THEN NEW.createdAt ELSE lastHumanAt END
|
|
130
|
+
WHERE NEW.parentTaskId IS NOT NULL
|
|
131
|
+
AND rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
|
|
132
|
+
END;
|
|
133
|
+
|
|
134
|
+
-- Progress and status writes (the hot path): newest activity wins.
|
|
135
|
+
CREATE TRIGGER session_task_activity
|
|
136
|
+
AFTER UPDATE OF lastUpdatedAt, status ON agent_tasks
|
|
137
|
+
WHEN NEW.lastUpdatedAt >= OLD.lastUpdatedAt
|
|
138
|
+
BEGIN
|
|
139
|
+
UPDATE session_summaries SET
|
|
140
|
+
latestStatus = CASE WHEN NEW.lastUpdatedAt >= lastActivityAt THEN NEW.status ELSE latestStatus END,
|
|
141
|
+
lastActivityAt = MAX(lastActivityAt, NEW.lastUpdatedAt)
|
|
142
|
+
WHERE rootId = (SELECT rootId FROM session_members WHERE taskId = NEW.id);
|
|
143
|
+
END;
|
|
144
|
+
|
|
145
|
+
-- A timestamp moved back may have been the chain's newest one.
|
|
146
|
+
CREATE TRIGGER session_task_activity_rewind
|
|
147
|
+
AFTER UPDATE OF lastUpdatedAt ON agent_tasks
|
|
148
|
+
WHEN NEW.lastUpdatedAt < OLD.lastUpdatedAt
|
|
149
|
+
BEGIN
|
|
150
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
151
|
+
SELECT rootId FROM session_members WHERE taskId = NEW.id;
|
|
152
|
+
END;
|
|
153
|
+
|
|
154
|
+
-- Requester or source edits change which tasks count as human messages.
|
|
155
|
+
CREATE TRIGGER session_task_human
|
|
156
|
+
AFTER UPDATE OF requestedByUserId, requestedByUserIdInherited, source, createdAt ON agent_tasks
|
|
157
|
+
WHEN OLD.requestedByUserId IS NOT NEW.requestedByUserId
|
|
158
|
+
OR OLD.requestedByUserIdInherited IS NOT NEW.requestedByUserIdInherited
|
|
159
|
+
OR OLD.source IS NOT NEW.source
|
|
160
|
+
OR OLD.createdAt IS NOT NEW.createdAt
|
|
161
|
+
BEGIN
|
|
162
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
163
|
+
SELECT rootId FROM session_members WHERE taskId = NEW.id;
|
|
164
|
+
END;
|
|
165
|
+
|
|
166
|
+
-- Root column copies (session moves rewrite the root key).
|
|
167
|
+
CREATE TRIGGER session_root_columns
|
|
168
|
+
AFTER UPDATE OF createdAt, source, "key", requestedByUserId, scheduleId, taskType,
|
|
169
|
+
slackChannelId, contextKey ON agent_tasks
|
|
170
|
+
WHEN NEW.parentTaskId IS NULL AND (
|
|
171
|
+
OLD.createdAt IS NOT NEW.createdAt OR OLD.source IS NOT NEW.source
|
|
172
|
+
OR OLD."key" IS NOT NEW."key" OR OLD.requestedByUserId IS NOT NEW.requestedByUserId
|
|
173
|
+
OR OLD.scheduleId IS NOT NEW.scheduleId OR OLD.taskType IS NOT NEW.taskType
|
|
174
|
+
OR OLD.slackChannelId IS NOT NEW.slackChannelId OR OLD.contextKey IS NOT NEW.contextKey)
|
|
175
|
+
BEGIN
|
|
176
|
+
UPDATE session_summaries SET
|
|
177
|
+
createdAt = NEW.createdAt, source = NEW.source, "key" = NEW."key",
|
|
178
|
+
requestedByUserId = NEW.requestedByUserId, scheduleId = NEW.scheduleId,
|
|
179
|
+
taskType = NEW.taskType, slackChannelId = NEW.slackChannelId, contextKey = NEW.contextKey
|
|
180
|
+
WHERE rootId = NEW.id;
|
|
181
|
+
END;
|
|
182
|
+
|
|
183
|
+
CREATE TRIGGER session_task_parent_immutable
|
|
184
|
+
BEFORE UPDATE OF parentTaskId ON agent_tasks
|
|
185
|
+
WHEN OLD.parentTaskId IS NOT NEW.parentTaskId
|
|
186
|
+
BEGIN
|
|
187
|
+
SELECT RAISE(ABORT, 'agent_tasks.parentTaskId is immutable (session_members depends on it)');
|
|
188
|
+
END;
|
|
189
|
+
|
|
190
|
+
-- A deleted root ends its session, and its chain tasks join nothing. A deleted
|
|
191
|
+
-- chain task leaves its chain (deleteTask already dropped its descendants).
|
|
192
|
+
CREATE TRIGGER session_task_delete
|
|
193
|
+
AFTER DELETE ON agent_tasks
|
|
194
|
+
BEGIN
|
|
195
|
+
DELETE FROM session_members WHERE taskId = OLD.id OR rootId = OLD.id;
|
|
196
|
+
DELETE FROM session_summaries WHERE rootId = OLD.id;
|
|
197
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
198
|
+
SELECT rootId FROM session_members WHERE taskId = OLD.parentTaskId;
|
|
199
|
+
END;
|
|
200
|
+
|
|
201
|
+
-- Steering messages and approvals are rare: recompute on any change that
|
|
202
|
+
-- can move lastHumanAt or pendingApprovalCount.
|
|
203
|
+
CREATE TRIGGER session_steering_insert
|
|
204
|
+
AFTER INSERT ON task_steering_messages
|
|
205
|
+
WHEN NEW.created_by_kind = 'user' AND NEW.status != 'cancelled'
|
|
206
|
+
BEGIN
|
|
207
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
208
|
+
SELECT rootId FROM session_members WHERE taskId = NEW.task_id;
|
|
209
|
+
END;
|
|
210
|
+
|
|
211
|
+
CREATE TRIGGER session_steering_update
|
|
212
|
+
AFTER UPDATE OF task_id, created_by_kind, status, created_at ON task_steering_messages
|
|
213
|
+
WHEN OLD.task_id IS NOT NEW.task_id OR OLD.created_by_kind IS NOT NEW.created_by_kind
|
|
214
|
+
OR OLD.created_at IS NOT NEW.created_at
|
|
215
|
+
OR (OLD.status = 'cancelled') IS NOT (NEW.status = 'cancelled')
|
|
216
|
+
BEGIN
|
|
217
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
218
|
+
SELECT rootId FROM session_members WHERE taskId IN (OLD.task_id, NEW.task_id);
|
|
219
|
+
END;
|
|
220
|
+
|
|
221
|
+
CREATE TRIGGER session_steering_delete
|
|
222
|
+
AFTER DELETE ON task_steering_messages
|
|
223
|
+
WHEN OLD.created_by_kind = 'user' AND OLD.status != 'cancelled'
|
|
224
|
+
BEGIN
|
|
225
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
226
|
+
SELECT rootId FROM session_members WHERE taskId = OLD.task_id;
|
|
227
|
+
END;
|
|
228
|
+
|
|
229
|
+
CREATE TRIGGER session_approval_insert
|
|
230
|
+
AFTER INSERT ON approval_requests
|
|
231
|
+
WHEN NEW.sourceTaskId IS NOT NULL AND NEW.status = 'pending'
|
|
232
|
+
BEGIN
|
|
233
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
234
|
+
SELECT rootId FROM session_members WHERE taskId = NEW.sourceTaskId;
|
|
235
|
+
END;
|
|
236
|
+
|
|
237
|
+
CREATE TRIGGER session_approval_update
|
|
238
|
+
AFTER UPDATE OF sourceTaskId, status, createdAt ON approval_requests
|
|
239
|
+
WHEN OLD.sourceTaskId IS NOT NEW.sourceTaskId OR OLD.createdAt IS NOT NEW.createdAt
|
|
240
|
+
OR (OLD.status = 'pending') IS NOT (NEW.status = 'pending')
|
|
241
|
+
BEGIN
|
|
242
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
243
|
+
SELECT rootId FROM session_members WHERE taskId IN (OLD.sourceTaskId, NEW.sourceTaskId);
|
|
244
|
+
END;
|
|
245
|
+
|
|
246
|
+
CREATE TRIGGER session_approval_delete
|
|
247
|
+
AFTER DELETE ON approval_requests
|
|
248
|
+
WHEN OLD.sourceTaskId IS NOT NULL AND OLD.status = 'pending'
|
|
249
|
+
BEGIN
|
|
250
|
+
INSERT INTO session_summary_recompute (rootId)
|
|
251
|
+
SELECT rootId FROM session_members WHERE taskId = OLD.sourceTaskId;
|
|
252
|
+
END;
|
|
253
|
+
|
|
254
|
+
-- Backfill: one chain walk for the members, then set-based aggregates (a
|
|
255
|
+
-- recompute per root reads every chain task several times). The packed MAX
|
|
256
|
+
-- takes the status of the newest task, as the bare column does in the walk.
|
|
257
|
+
INSERT INTO session_members (taskId, rootId)
|
|
258
|
+
WITH RECURSIVE chain(root_id, id) AS (
|
|
259
|
+
SELECT id, id FROM agent_tasks WHERE parentTaskId IS NULL
|
|
260
|
+
UNION ALL
|
|
261
|
+
SELECT c.root_id, t.id FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
|
|
262
|
+
)
|
|
263
|
+
SELECT id, root_id FROM chain;
|
|
264
|
+
|
|
265
|
+
INSERT INTO session_summaries (rootId, createdAt, source, "key", requestedByUserId, scheduleId,
|
|
266
|
+
taskType, slackChannelId, contextKey, chainCount, lastActivityAt, latestStatus, lastHumanAt)
|
|
267
|
+
SELECT r.id, r.createdAt, r.source, r."key", r.requestedByUserId, r.scheduleId, r.taskType,
|
|
268
|
+
r.slackChannelId, r.contextKey, w.chainCount, w.lastActivityAt,
|
|
269
|
+
substr(w.newest, instr(w.newest, char(31)) + 1), w.humanAt
|
|
270
|
+
FROM (
|
|
271
|
+
SELECT m.rootId, COUNT(*) AS chainCount, MAX(t.lastUpdatedAt) AS lastActivityAt,
|
|
272
|
+
MAX(t.lastUpdatedAt || char(31) || t.status) AS newest,
|
|
273
|
+
MAX(CASE WHEN t.requestedByUserId IS NOT NULL AND t.requestedByUserIdInherited = 0
|
|
274
|
+
AND COALESCE(t.source, '') NOT IN ('mcp', 'system', 'schedule', 'workflow')
|
|
275
|
+
THEN t.createdAt END) AS humanAt
|
|
276
|
+
FROM session_members m JOIN agent_tasks t ON t.id = m.taskId
|
|
277
|
+
GROUP BY m.rootId
|
|
278
|
+
) w JOIN agent_tasks r ON r.id = w.rootId;
|
|
279
|
+
|
|
280
|
+
UPDATE session_summaries SET
|
|
281
|
+
pendingApprovalCount = (SELECT COUNT(*) FROM session_members m
|
|
282
|
+
JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
|
|
283
|
+
WHERE m.rootId = session_summaries.rootId AND ar.status = 'pending'),
|
|
284
|
+
lastHumanAt = (SELECT MAX(at) FROM (
|
|
285
|
+
SELECT session_summaries.lastHumanAt AS at
|
|
286
|
+
UNION ALL
|
|
287
|
+
SELECT sm.created_at FROM session_members m
|
|
288
|
+
JOIN task_steering_messages sm ON sm.task_id = m.taskId
|
|
289
|
+
WHERE m.rootId = session_summaries.rootId AND sm.created_by_kind = 'user'
|
|
290
|
+
AND sm.status != 'cancelled'
|
|
291
|
+
UNION ALL
|
|
292
|
+
SELECT ar.createdAt FROM session_members m
|
|
293
|
+
JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
|
|
294
|
+
WHERE m.rootId = session_summaries.rootId AND ar.status = 'pending'))
|
|
295
|
+
WHERE rootId IN (
|
|
296
|
+
SELECT m.rootId FROM session_members m JOIN task_steering_messages sm ON sm.task_id = m.taskId
|
|
297
|
+
WHERE sm.created_by_kind = 'user' AND sm.status != 'cancelled'
|
|
298
|
+
UNION
|
|
299
|
+
SELECT m.rootId FROM session_members m JOIN approval_requests ar ON ar.sourceTaskId = m.taskId
|
|
300
|
+
WHERE ar.status = 'pending');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Indexes for hot lookups that scan agent_tasks or script_runs today.
|
|
2
|
+
--
|
|
3
|
+
-- agent_tasks rows carry large `task` text, so every scan of the table reads
|
|
4
|
+
-- its overflow pages and costs hundreds of milliseconds, whatever column it
|
|
5
|
+
-- filters. Production has no sqlite_stat1, so each index below is shaped so
|
|
6
|
+
-- the planner picks it without statistics.
|
|
7
|
+
|
|
8
|
+
-- getTaskByWorkflowRunStepId and getStuckWorkflowRuns look tasks up by step.
|
|
9
|
+
-- Partial: only workflow-step tasks carry the column.
|
|
10
|
+
CREATE INDEX IF NOT EXISTS idx_agent_tasks_workflowRunStepId
|
|
11
|
+
ON agent_tasks(workflowRunStepId) WHERE workflowRunStepId IS NOT NULL;
|
|
12
|
+
|
|
13
|
+
-- getRecentCompletedCount: `status = ? AND finishedAt > ?` becomes a covering
|
|
14
|
+
-- range instead of reading finishedAt from every completed row.
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_agent_tasks_status_finishedAt
|
|
16
|
+
ON agent_tasks(status, finishedAt);
|
|
17
|
+
|
|
18
|
+
-- Session root counts: `parentTaskId IS NULL AND source IN (...)` becomes a
|
|
19
|
+
-- covering range instead of reading source from every root.
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId_source
|
|
21
|
+
ON agent_tasks(parentTaskId, source);
|
|
22
|
+
|
|
23
|
+
-- countScriptRuns / listScriptRuns filtered by script name.
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_script_runs_scriptName_startedAt
|
|
25
|
+
ON script_runs(scriptName, startedAt DESC);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- Token index for task search (getAllTasks / getTasksCount `search`), which
|
|
2
|
+
-- used to run `task LIKE '%q%'` over every task body. External content: the
|
|
3
|
+
-- index stores no copy of the text, rows are keyed by agent_tasks.rowid, and
|
|
4
|
+
-- the default unicode61 tokenizer (no porter: ids and numbers stay exact).
|
|
5
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS agent_tasks_fts USING fts5(
|
|
6
|
+
task,
|
|
7
|
+
title,
|
|
8
|
+
content='agent_tasks',
|
|
9
|
+
content_rowid='rowid'
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
-- Rows with rowid <= indexedThrough are in the index. A full backfill would
|
|
13
|
+
-- block boot and hold the write lock, so the migration indexes nothing and
|
|
14
|
+
-- the API backfills in rowid batches after boot (backfillTaskSearchIndex in
|
|
15
|
+
-- src/be/db/tasks/search-index.ts), raising indexedThrough in the same
|
|
16
|
+
-- transaction as each batch. Once it reaches 9007199254740991 every row is
|
|
17
|
+
-- indexed and search switches from LIKE to the index. An empty table starts
|
|
18
|
+
-- complete.
|
|
19
|
+
CREATE TABLE IF NOT EXISTS agent_tasks_fts_state (
|
|
20
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
21
|
+
indexedThrough INTEGER NOT NULL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
INSERT OR IGNORE INTO agent_tasks_fts_state (id, indexedThrough)
|
|
25
|
+
SELECT 1, CASE WHEN EXISTS (SELECT 1 FROM agent_tasks) THEN 0 ELSE 9007199254740991 END;
|
|
26
|
+
|
|
27
|
+
-- Triggers keep the index in sync on every write path (the two INSERT sites,
|
|
28
|
+
-- title updates, the draft append, the boot scrub sweep, deletes). They touch
|
|
29
|
+
-- only rows the backfill has reached: an external-content 'delete' must name
|
|
30
|
+
-- a row the index holds, or it corrupts the index. A rebuild of agent_tasks
|
|
31
|
+
-- must keep rowids (the index is keyed by them) and recreate these triggers;
|
|
32
|
+
-- src/tests/task-search-fts.test.ts covers the runtime.ts rebuild.
|
|
33
|
+
CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_insert
|
|
34
|
+
AFTER INSERT ON agent_tasks
|
|
35
|
+
WHEN NEW.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
|
|
36
|
+
BEGIN
|
|
37
|
+
INSERT INTO agent_tasks_fts(rowid, task, title) VALUES (NEW.rowid, NEW.task, NEW.title);
|
|
38
|
+
END;
|
|
39
|
+
|
|
40
|
+
CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_update
|
|
41
|
+
AFTER UPDATE OF task, title ON agent_tasks
|
|
42
|
+
WHEN OLD.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
|
|
43
|
+
BEGIN
|
|
44
|
+
INSERT INTO agent_tasks_fts(agent_tasks_fts, rowid, task, title)
|
|
45
|
+
VALUES ('delete', OLD.rowid, OLD.task, OLD.title);
|
|
46
|
+
INSERT INTO agent_tasks_fts(rowid, task, title) VALUES (NEW.rowid, NEW.task, NEW.title);
|
|
47
|
+
END;
|
|
48
|
+
|
|
49
|
+
CREATE TRIGGER IF NOT EXISTS agent_tasks_fts_delete
|
|
50
|
+
AFTER DELETE ON agent_tasks
|
|
51
|
+
WHEN OLD.rowid <= (SELECT indexedThrough FROM agent_tasks_fts_state WHERE id = 1)
|
|
52
|
+
BEGIN
|
|
53
|
+
INSERT INTO agent_tasks_fts(agent_tasks_fts, rowid, task, title)
|
|
54
|
+
VALUES ('delete', OLD.rowid, OLD.task, OLD.title);
|
|
55
|
+
END;
|
package/src/comb/config.ts
CHANGED
|
@@ -2,14 +2,16 @@ import {
|
|
|
2
2
|
getAgentFsDefaultDriveId,
|
|
3
3
|
getAgentFsDefaultOrgId,
|
|
4
4
|
getAgentFsLiveUrl,
|
|
5
|
+
getCombState,
|
|
5
6
|
getPublicMcpBaseUrl,
|
|
6
7
|
isAgentFsProxyEnabled,
|
|
7
|
-
isCombEnabled,
|
|
8
8
|
} from "../utils/constants";
|
|
9
9
|
|
|
10
10
|
/** The Comb block of `/status` (`agent_fs.comb`), before snake-casing. */
|
|
11
11
|
export interface CombConfig {
|
|
12
12
|
enabled: boolean;
|
|
13
|
+
/** Why Comb is off; null when on. */
|
|
14
|
+
reason: string | null;
|
|
13
15
|
/** Browser-facing agent-fs URL. */
|
|
14
16
|
apiUrl: string | null;
|
|
15
17
|
liveUrl: string;
|
|
@@ -32,8 +34,10 @@ export function getCombConfig(): CombConfig {
|
|
|
32
34
|
// With the proxy on, the browser reaches agent-fs through the API itself.
|
|
33
35
|
const browserDefault =
|
|
34
36
|
isAgentFsProxyEnabled() && internalUrl ? `${getPublicMcpBaseUrl()}/agentfs` : internalUrl;
|
|
37
|
+
const state = getCombState();
|
|
35
38
|
return {
|
|
36
|
-
enabled:
|
|
39
|
+
enabled: state.enabled,
|
|
40
|
+
reason: state.reason,
|
|
37
41
|
apiUrl: trimUrl(process.env.AGENT_FS_PUBLIC_URL) ?? browserDefault,
|
|
38
42
|
liveUrl: getAgentFsLiveUrl(),
|
|
39
43
|
orgId: getAgentFsDefaultOrgId() ?? null,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sent a Comb batch, when the request names no user.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard sends with the swarm API key, so the request carries no user.
|
|
5
|
+
* The comment authors do: an agent-fs author maps to a swarm user through a
|
|
6
|
+
* linked `agent-fs` identity, else through the email agent-fs has for that
|
|
7
|
+
* drive member. The browser's agent-fs key never reaches the swarm API, so no
|
|
8
|
+
* request proves which author pressed Send. With several mapped authors, the
|
|
9
|
+
* author of the newest comment wins: the person who commented last is the
|
|
10
|
+
* one most likely to have sent the batch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { findUserByEmail, findUserByExternalId } from "../be/users";
|
|
14
|
+
import type { AgentFsComment, AgentFsDrive, AgentFsProvider } from "../fs/agent-fs-provider";
|
|
15
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
16
|
+
|
|
17
|
+
/** `user_external_ids.kind` of an agent-fs user id. */
|
|
18
|
+
export const AGENT_FS_IDENTITY_KIND = "agent-fs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* agent-fs user id -> swarm user id, for each author that maps. The swarm
|
|
22
|
+
* service account is never a requester. Reads the drive members from
|
|
23
|
+
* agent-fs only when an author has no linked identity.
|
|
24
|
+
*/
|
|
25
|
+
export async function commentAuthorUsers(
|
|
26
|
+
agentFs: AgentFsProvider,
|
|
27
|
+
drive: AgentFsDrive,
|
|
28
|
+
comments: AgentFsComment[],
|
|
29
|
+
serviceUserId: string,
|
|
30
|
+
): Promise<Map<string, string>> {
|
|
31
|
+
const authors = [...new Set(comments.map((comment) => comment.author))].filter(
|
|
32
|
+
(author) => author && author !== serviceUserId,
|
|
33
|
+
);
|
|
34
|
+
const users = new Map<string, string>();
|
|
35
|
+
const unlinked: string[] = [];
|
|
36
|
+
for (const author of authors) {
|
|
37
|
+
const user = await findUserByExternalId(AGENT_FS_IDENTITY_KIND, author);
|
|
38
|
+
if (user) users.set(author, user.id);
|
|
39
|
+
else unlinked.push(author);
|
|
40
|
+
}
|
|
41
|
+
if (unlinked.length === 0) return users;
|
|
42
|
+
|
|
43
|
+
const members = await agentFs.getDriveMembers(drive).catch((error: unknown) => {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
console.warn(scrubSecrets(`[comb] could not read the drive members: ${message}`));
|
|
46
|
+
return [];
|
|
47
|
+
});
|
|
48
|
+
for (const author of unlinked) {
|
|
49
|
+
const email = members.find((member) => member.userId === author)?.email;
|
|
50
|
+
const user = email ? await findUserByEmail(email) : null;
|
|
51
|
+
if (user) users.set(author, user.id);
|
|
52
|
+
}
|
|
53
|
+
return users;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The author of the newest comment that maps to a swarm user, else null. */
|
|
57
|
+
export function batchRequester(
|
|
58
|
+
comments: AgentFsComment[],
|
|
59
|
+
users: Map<string, string>,
|
|
60
|
+
): string | null {
|
|
61
|
+
let newest: AgentFsComment | null = null;
|
|
62
|
+
for (const comment of comments) {
|
|
63
|
+
if (!users.has(comment.author)) continue;
|
|
64
|
+
if (!newest || Date.parse(comment.createdAt) > Date.parse(newest.createdAt)) {
|
|
65
|
+
newest = comment;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return newest ? (users.get(newest.author) ?? null) : null;
|
|
69
|
+
}
|
package/src/comb/review-batch.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
|
|
|
27
27
|
import { combAgentFs } from "./agent-fs";
|
|
28
28
|
import { getCombConfig } from "./config";
|
|
29
29
|
import { sentMarker, sentTaskId } from "./markers";
|
|
30
|
+
import { batchRequester, commentAuthorUsers } from "./requester";
|
|
30
31
|
// Side-effect import: registers the comb.review.* templates.
|
|
31
32
|
import "./templates";
|
|
32
33
|
|
|
@@ -67,6 +68,7 @@ export interface ReviewBatchInput {
|
|
|
67
68
|
scopePath: string;
|
|
68
69
|
/** The sender's note for the lead about the whole batch. Blank means none. */
|
|
69
70
|
note?: string;
|
|
71
|
+
/** The authenticated sender. Null: the author of the newest mapped comment (`./requester`). */
|
|
70
72
|
requestedByUserId: string | null;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -153,6 +155,18 @@ export async function sendReviewBatch(
|
|
|
153
155
|
if (skip) skipped.push(skip);
|
|
154
156
|
else candidates.push(read);
|
|
155
157
|
}
|
|
158
|
+
// Without an authenticated sender, map the comment authors to swarm users
|
|
159
|
+
// now: it may read the drive members from agent-fs. Without the service
|
|
160
|
+
// identity, a swarm-authored comment could pass for a person: no requester.
|
|
161
|
+
// The caller picks the comments, so an author requester is caller-chosen
|
|
162
|
+
// attribution: TRUST_BODY_REQUESTED_BY_USER_ID=false turns it off, like a
|
|
163
|
+
// body `requestedByUserId` on POST /api/tasks.
|
|
164
|
+
const authorUsers =
|
|
165
|
+
input.requestedByUserId ||
|
|
166
|
+
!serviceUserId ||
|
|
167
|
+
process.env.TRUST_BODY_REQUESTED_BY_USER_ID === "false"
|
|
168
|
+
? null
|
|
169
|
+
: await commentAuthorUsers(agentFs, drive, candidates.map(commentOf), serviceUserId);
|
|
156
170
|
|
|
157
171
|
// 2. Claim in one transaction, so two sends never split one set of comments.
|
|
158
172
|
const claim = crypto.randomUUID();
|
|
@@ -175,8 +189,11 @@ export async function sendReviewBatch(
|
|
|
175
189
|
let taskId: string | null = null;
|
|
176
190
|
let leadState: ReviewBatchLead = "none";
|
|
177
191
|
if (won.length > 0) {
|
|
192
|
+
const requestedByUserId =
|
|
193
|
+
input.requestedByUserId ??
|
|
194
|
+
(authorUsers ? batchRequester(won.map(commentOf), authorUsers) : null);
|
|
178
195
|
try {
|
|
179
|
-
const text = await renderTaskText(input, won);
|
|
196
|
+
const text = await renderTaskText({ ...input, requestedByUserId }, won);
|
|
180
197
|
if (text === null) {
|
|
181
198
|
throw new ReviewBatchError(503, "The Comb review template is disabled", [
|
|
182
199
|
...skipped,
|
|
@@ -194,7 +211,7 @@ export async function sendReviewBatch(
|
|
|
194
211
|
source: "comb",
|
|
195
212
|
taskType: "comb-review",
|
|
196
213
|
tags: ["comb"],
|
|
197
|
-
requestedByUserId:
|
|
214
|
+
requestedByUserId: requestedByUserId ?? undefined,
|
|
198
215
|
},
|
|
199
216
|
{ origin: "rest" },
|
|
200
217
|
);
|
|
@@ -279,6 +296,11 @@ async function readComment(
|
|
|
279
296
|
return { id, thread, version };
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/** The root comment of a read that `skipOf` kept. */
|
|
300
|
+
function commentOf(read: CommentRead): AgentFsComment {
|
|
301
|
+
return (read.thread as AgentFsCommentThread).comment;
|
|
302
|
+
}
|
|
303
|
+
|
|
282
304
|
function skipOf({ id, thread }: CommentRead, serviceUserId: string | null): SkippedComment | null {
|
|
283
305
|
if (!thread) return { id, reason: "not-found" };
|
|
284
306
|
if (thread.comment.parentId) return { id, reason: "reply" };
|
|
@@ -14,6 +14,11 @@ const DEFAULT_WORKER_MAX_CONCURRENT_TASKS = 1;
|
|
|
14
14
|
// which moves on every commit to main; operators pin a release in .env.
|
|
15
15
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
16
16
|
const IMAGE_TAG_EXPRESSION = "${AGENT_SWARM_VERSION:-latest}";
|
|
17
|
+
// Host address for published ports, from AGENT_SWARM_BIND_ADDRESS in .env. Blank
|
|
18
|
+
// publishes on every interface, as before. 127.0.0.1 keeps the ports behind a host
|
|
19
|
+
// reverse proxy: Docker-published ports bypass host firewalls such as ufw.
|
|
20
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
21
|
+
const BIND_ADDRESS_PREFIX = "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}";
|
|
17
22
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
18
23
|
const ENV_API_KEY = " - API_KEY=${API_KEY}";
|
|
19
24
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
@@ -275,7 +280,7 @@ export function generateCompose(state: OnboardState): string {
|
|
|
275
280
|
|
|
276
281
|
lines.push("");
|
|
277
282
|
lines.push(" ports:");
|
|
278
|
-
lines.push(` - "${port}:3013"`);
|
|
283
|
+
lines.push(` - "${BIND_ADDRESS_PREFIX}${port}:3013"`);
|
|
279
284
|
lines.push("");
|
|
280
285
|
lines.push(" volumes:");
|
|
281
286
|
lines.push(" - swarm_data:/app/data");
|
|
@@ -353,7 +358,7 @@ export function generateCompose(state: OnboardState): string {
|
|
|
353
358
|
|
|
354
359
|
lines.push("");
|
|
355
360
|
lines.push(" ports:");
|
|
356
|
-
lines.push(` - "${port}:3000"`);
|
|
361
|
+
lines.push(` - "${BIND_ADDRESS_PREFIX}${port}:3000"`);
|
|
357
362
|
lines.push("");
|
|
358
363
|
lines.push(" volumes:");
|
|
359
364
|
// Same targets as docker-compose.example.yml: the worker logs to /logs and
|
|
@@ -35,9 +35,14 @@ export function generateEnv(state: OnboardState, env: NodeJS.ProcessEnv = proces
|
|
|
35
35
|
lines.push(`INSTALL_PRESET=${state.presetId}`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
38
|
+
// Knobs from the installer's environment, so the first boot already honours
|
|
39
|
+
// them: telemetry (`ANONYMIZED_TELEMETRY=false`, `DESPLEGA_TELEMETRY_ENV=<cohort>`)
|
|
40
|
+
// and the host address for published ports (`AGENT_SWARM_BIND_ADDRESS=127.0.0.1`).
|
|
41
|
+
for (const key of [
|
|
42
|
+
"ANONYMIZED_TELEMETRY",
|
|
43
|
+
"DESPLEGA_TELEMETRY_ENV",
|
|
44
|
+
"AGENT_SWARM_BIND_ADDRESS",
|
|
45
|
+
]) {
|
|
41
46
|
const value = env[key]?.trim();
|
|
42
47
|
if (value) lines.push(`${key}=${value}`);
|
|
43
48
|
}
|