@desplega.ai/agent-swarm 1.115.0 → 1.117.0
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 +2 -2
- package/dist/{actions-17ya5wde.js → actions-7bnvhcgs.js} +10 -8
- package/dist/{app-4f718yk0.js → app-976fykvr.js} +3 -3
- package/dist/{assistant-ah7csnjz.js → assistant-5drs03t0.js} +13 -11
- package/dist/{boot-reembed-4jpxhb25.js → boot-reembed-0zhftca3.js} +4 -4
- package/dist/{boot-reembed-qata34xa.js → boot-reembed-wce58xfv.js} +3 -3
- package/dist/{boot-scrub-logs-tv48nn3z.js → boot-scrub-logs-vkd2vvxp.js} +2 -2
- package/dist/{cli-zsrdty74.js → cli-09j7m5v3.js} +4 -4
- package/dist/{cli-beea4a3y.js → cli-1987vgjc.js} +3 -3
- package/dist/{cli-k3y4z3h5.js → cli-5ekf4gcj.js} +2 -76
- package/dist/{cli-cnwssmsr.js → cli-6058t3w0.js} +1 -1
- package/dist/{cli-tffvrh6j.js → cli-9kk0xa5w.js} +1 -1
- package/dist/{cli-q1rx7g0d.js → cli-e0xv4nd4.js} +11 -9
- package/dist/{cli-12vb0xg6.js → cli-f5cx2j92.js} +2 -2
- package/dist/{cli-3samkyap.js → cli-fep22bhf.js} +2 -2
- package/dist/{cli-t0vqevnm.js → cli-hmw1vk30.js} +1 -1
- package/dist/{cli-pet3aev6.js → cli-jkp1gndy.js} +5 -5
- package/dist/{cli-52fg7s7x.js → cli-kfjs4fjw.js} +1 -1
- package/dist/{cli-65ekje01.js → cli-m4p6c600.js} +10 -8
- package/dist/{cli-cchksf5q.js → cli-ndsyqqqk.js} +2 -2
- package/dist/{cli-tece6zgm.js → cli-q0yn57de.js} +1 -1
- package/dist/{cli-hrhye2rm.js → cli-r06ed00c.js} +70 -24
- package/dist/{cli-7xje5amz.js → cli-rymspw51.js} +1 -1
- package/dist/{cli-15y7qhzp.js → cli-s5q026mr.js} +295 -4
- package/dist/{cli-shvaxw27.js → cli-t6n84nqv.js} +8 -6
- package/dist/{cli-r9f6ngrf.js → cli-vwhg6sbn.js} +2 -2
- package/dist/{cli-6aq8xhvm.js → cli-w5axeg22.js} +1 -1
- package/dist/{cli-b23axgz4.js → cli-xcp5dmts.js} +2 -2
- package/dist/{cli-dcca6j8m.js → cli-yarjrgd5.js} +1 -1
- package/dist/{cli-cmw9rzy5.js → cli-z824vphk.js} +2 -2
- package/dist/cli.js +10 -10
- package/dist/{commands-hch4e2k1.js → commands-b3p0fjzd.js} +2 -2
- package/dist/{db-p3h2j06x.js → db-6xw82vz3.js} +2 -2
- package/dist/{handlers-9er4zx9q.js → handlers-kwrk085v.js} +13 -11
- package/dist/{hook-4axs756z.js → hook-e4wdztg5.js} +1 -1
- package/dist/{http-809fvtz2.js → http-x7svk8q5.js} +41 -37
- package/dist/{index-rm85cfdx.js → index-35tw5g2v.js} +6 -6
- package/dist/{index-47em3d2n.js → index-aadhkkde.js} +10 -10
- package/dist/{index-9fqaktjr.js → index-qb6nfqmy.js} +8 -8
- package/dist/{index-9ht8gr56.js → index-zj38e3q2.js} +9 -9
- package/dist/{keepalive-3cps6e3e.js → keepalive-enehyqc8.js} +4 -4
- package/dist/{lead-fkk95wgq.js → lead-nmzd0ykq.js} +18 -18
- package/dist/{maintenance-fweqh0yj.js → maintenance-staxb3xt.js} +4 -4
- package/dist/{oauth-refresh-sweep-sycqtbph.js → oauth-refresh-sweep-fqczcwxn.js} +3 -3
- package/dist/{onboard-cy0rkgvx.js → onboard-shmqj6ep.js} +2 -2
- package/dist/{otel-impl-wbns8hf7.js → otel-impl-726bmdyc.js} +1 -1
- package/dist/{pricing-refresh-0hvazmkf.js → pricing-refresh-rzwka8rf.js} +4 -4
- package/dist/{rbac-roles-kfesjy9y.js → rbac-roles-chkhakmf.js} +3 -3
- package/dist/{rbac-roles-mwcwhbxc.js → rbac-roles-j2k0h8sx.js} +2 -2
- package/dist/{seed-pricing-h2kdq4cq.js → seed-pricing-vta6a1q3.js} +3 -3
- package/dist/{setup-8506q97w.js → setup-yv9ctywz.js} +2 -2
- package/dist/{worker-4r5c6zyb.js → worker-7w6571j0.js} +18 -18
- package/openapi.json +1 -1
- package/package.json +2 -2
- package/src/be/db.ts +89 -3
- package/src/server-user.ts +1 -0
- package/src/tasks/slack-routing.ts +147 -0
- package/src/tests/approval-requests.test.ts +36 -0
- package/src/tests/http-api-integration.test.ts +38 -0
- package/src/tests/pages-complete-fetch.test.ts +54 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +355 -0
- package/src/tests/slack-metadata-inheritance.test.ts +279 -1
- package/src/tests/slack-routing-guard.test.ts +164 -0
- package/src/tools/send-task.ts +145 -82
|
@@ -7,13 +7,13 @@ import {
|
|
|
7
7
|
getUserGrant,
|
|
8
8
|
listUserRoles,
|
|
9
9
|
runRbacCliCommand
|
|
10
|
-
} from "./cli-
|
|
10
|
+
} from "./cli-kfjs4fjw.js";
|
|
11
11
|
import"./cli-86fdnerz.js";
|
|
12
|
-
import"./cli-
|
|
12
|
+
import"./cli-s5q026mr.js";
|
|
13
13
|
import"./cli-z2zcxes1.js";
|
|
14
14
|
import"./cli-bw80ck94.js";
|
|
15
15
|
import"./cli-xz9aq0rf.js";
|
|
16
|
-
import"./cli-
|
|
16
|
+
import"./cli-xcp5dmts.js";
|
|
17
17
|
import"./cli-4j2mrzag.js";
|
|
18
18
|
import"./cli-p9swy5t3.js";
|
|
19
19
|
export {
|
|
@@ -4,11 +4,11 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
getDb,
|
|
6
6
|
init_db
|
|
7
|
-
} from "./cli-
|
|
7
|
+
} from "./cli-s5q026mr.js";
|
|
8
8
|
import"./cli-z2zcxes1.js";
|
|
9
9
|
import"./cli-bw80ck94.js";
|
|
10
10
|
import"./cli-xz9aq0rf.js";
|
|
11
|
-
import"./cli-
|
|
11
|
+
import"./cli-xcp5dmts.js";
|
|
12
12
|
import"./cli-4j2mrzag.js";
|
|
13
13
|
import"./cli-p9swy5t3.js";
|
|
14
14
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildModelsDevSeedRows,
|
|
3
3
|
seedPricingFromModelsDev
|
|
4
|
-
} from "./cli-
|
|
5
|
-
import"./cli-
|
|
4
|
+
} from "./cli-q0yn57de.js";
|
|
5
|
+
import"./cli-s5q026mr.js";
|
|
6
6
|
import"./cli-z2zcxes1.js";
|
|
7
7
|
import"./cli-bw80ck94.js";
|
|
8
8
|
import"./cli-xz9aq0rf.js";
|
|
9
|
-
import"./cli-
|
|
9
|
+
import"./cli-xcp5dmts.js";
|
|
10
10
|
import"./cli-4j2mrzag.js";
|
|
11
11
|
import"./cli-p9swy5t3.js";
|
|
12
12
|
export {
|
|
@@ -4,14 +4,14 @@ import {
|
|
|
4
4
|
createDefaultMcpJson,
|
|
5
5
|
createDefaultSettingsLocal,
|
|
6
6
|
createHooksConfig
|
|
7
|
-
} from "./cli-
|
|
7
|
+
} from "./cli-6058t3w0.js";
|
|
8
8
|
import {
|
|
9
9
|
require_jsx_dev_runtime
|
|
10
10
|
} from "./cli-wx82tw1k.js";
|
|
11
11
|
import {
|
|
12
12
|
getApiKey
|
|
13
13
|
} from "./cli-f14fvzag.js";
|
|
14
|
-
import"./cli-
|
|
14
|
+
import"./cli-xcp5dmts.js";
|
|
15
15
|
import {
|
|
16
16
|
Box_default,
|
|
17
17
|
Spinner,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
runAgent
|
|
3
|
-
} from "./cli-
|
|
3
|
+
} from "./cli-09j7m5v3.js";
|
|
4
4
|
import"./cli-tyfxrrdd.js";
|
|
5
5
|
import"./cli-bc89my0z.js";
|
|
6
6
|
import"./cli-yazx6hap.js";
|
|
@@ -14,7 +14,7 @@ import"./cli-d6n0thxw.js";
|
|
|
14
14
|
import"./cli-yv9dttkh.js";
|
|
15
15
|
import {
|
|
16
16
|
getEnabledCapabilities
|
|
17
|
-
} from "./cli-
|
|
17
|
+
} from "./cli-r06ed00c.js";
|
|
18
18
|
import"./cli-1z7048dy.js";
|
|
19
19
|
import"./cli-ftyf58xj.js";
|
|
20
20
|
import"./cli-tk9vyqzh.js";
|
|
@@ -25,36 +25,36 @@ import"./cli-psrqtw2m.js";
|
|
|
25
25
|
import"./cli-zm7njwzd.js";
|
|
26
26
|
import"./cli-tghre7g6.js";
|
|
27
27
|
import"./cli-e61fs2hp.js";
|
|
28
|
-
import"./cli-
|
|
29
|
-
import"./cli-
|
|
30
|
-
import"./cli-
|
|
28
|
+
import"./cli-fep22bhf.js";
|
|
29
|
+
import"./cli-q0yn57de.js";
|
|
30
|
+
import"./cli-kfjs4fjw.js";
|
|
31
31
|
import"./cli-86fdnerz.js";
|
|
32
|
-
import"./cli-
|
|
32
|
+
import"./cli-yarjrgd5.js";
|
|
33
33
|
import"./cli-fad8m16k.js";
|
|
34
|
-
import"./cli-
|
|
35
|
-
import"./cli-
|
|
34
|
+
import"./cli-t6n84nqv.js";
|
|
35
|
+
import"./cli-e0xv4nd4.js";
|
|
36
36
|
import"./cli-f14fvzag.js";
|
|
37
|
-
import"./cli-
|
|
37
|
+
import"./cli-vwhg6sbn.js";
|
|
38
38
|
import"./cli-jkhwmmgg.js";
|
|
39
|
-
import"./cli-
|
|
40
|
-
import"./cli-
|
|
39
|
+
import"./cli-z824vphk.js";
|
|
40
|
+
import"./cli-rymspw51.js";
|
|
41
41
|
import"./cli-q25ejbyn.js";
|
|
42
|
-
import"./cli-
|
|
42
|
+
import"./cli-hmw1vk30.js";
|
|
43
43
|
import"./cli-0pygq9r1.js";
|
|
44
|
-
import"./cli-
|
|
45
|
-
import"./cli-
|
|
44
|
+
import"./cli-9kk0xa5w.js";
|
|
45
|
+
import"./cli-jkp1gndy.js";
|
|
46
46
|
import"./cli-rkndnn90.js";
|
|
47
47
|
import"./cli-b0p7rfnd.js";
|
|
48
|
-
import"./cli-
|
|
48
|
+
import"./cli-w5axeg22.js";
|
|
49
49
|
import"./cli-ma04faw5.js";
|
|
50
50
|
import"./cli-wspgs9bt.js";
|
|
51
|
-
import"./cli-
|
|
51
|
+
import"./cli-5ekf4gcj.js";
|
|
52
52
|
import"./cli-dh55d5fg.js";
|
|
53
|
-
import"./cli-
|
|
53
|
+
import"./cli-s5q026mr.js";
|
|
54
54
|
import"./cli-z2zcxes1.js";
|
|
55
55
|
import"./cli-bw80ck94.js";
|
|
56
56
|
import"./cli-xz9aq0rf.js";
|
|
57
|
-
import"./cli-
|
|
57
|
+
import"./cli-xcp5dmts.js";
|
|
58
58
|
import"./cli-4j2mrzag.js";
|
|
59
59
|
import"./cli-e15jhafb.js";
|
|
60
60
|
import"./cli-9aryf9tv.js";
|
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.117.0",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.117.0",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.sh <contact@desplega.sh>",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"@inkjs/ui": "^2.0.0",
|
|
147
147
|
"@linear/sdk": "^77.0.0",
|
|
148
148
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
149
|
-
"@openai/codex-sdk": "^0.
|
|
149
|
+
"@openai/codex-sdk": "^0.144.1",
|
|
150
150
|
"@opencode-ai/sdk": "^1.17.15",
|
|
151
151
|
"@openfort/openfort-node": "^0.9.1",
|
|
152
152
|
"@opentelemetry/api": "^1.9.1",
|
package/src/be/db.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { parseProviderMeta } from "@/utils/provider-metadata.ts";
|
|
3
3
|
import pkg from "../../package.json";
|
|
4
4
|
import { configureDbResolver } from "../prompts/resolver";
|
|
5
|
+
import { slackChannelFromContextKey } from "../tasks/slack-routing";
|
|
5
6
|
import { telemetry } from "../telemetry";
|
|
6
7
|
import type {
|
|
7
8
|
ActiveSession,
|
|
@@ -3338,6 +3339,16 @@ export interface CreateTaskOptions {
|
|
|
3338
3339
|
slackChannelId?: string;
|
|
3339
3340
|
slackThreadTs?: string;
|
|
3340
3341
|
slackUserId?: string;
|
|
3342
|
+
/**
|
|
3343
|
+
* Opt out of the residual Slack/contextKey normalization below (see the
|
|
3344
|
+
* "Residual-mismatch guard" comment near the INSERT): a deliberate
|
|
3345
|
+
* cross-channel/thread dispatch (e.g. `send-task`'s
|
|
3346
|
+
* `overrideSlackContext: true`) sets this so its explicit slackChannelId/
|
|
3347
|
+
* slackThreadTs survive even when they disagree with a slack-family
|
|
3348
|
+
* `contextKey`/parent. Trusted callers that don't set this get normalized —
|
|
3349
|
+
* this boundary must not let a caller silently persist a mismatch.
|
|
3350
|
+
*/
|
|
3351
|
+
overrideSlackContext?: boolean;
|
|
3341
3352
|
vcsProvider?: "github" | "gitlab";
|
|
3342
3353
|
vcsRepo?: string;
|
|
3343
3354
|
vcsEventType?: string;
|
|
@@ -3450,14 +3461,24 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
|
|
|
3450
3461
|
// is the single source of truth — `createResumeFollowUp` and the other
|
|
3451
3462
|
// follow-up creators rely on this block instead of re-listing fields.
|
|
3452
3463
|
|
|
3453
|
-
// Slack context
|
|
3464
|
+
// Slack context — inherited as an atomic unit. A foreign channelId's
|
|
3465
|
+
// thread ts is meaningless to Slack's API, so an explicit slackChannelId
|
|
3466
|
+
// that DIFFERS from the parent's must not pull in the parent's
|
|
3467
|
+
// slackThreadTs/slackUserId (that combination misroutes/fails to thread —
|
|
3468
|
+
// see swarm memory dispatch-slack-channel-must-match-parent-context-2026-07-10).
|
|
3469
|
+
// When the explicit channel matches the parent's (or is unset), per-field
|
|
3470
|
+
// fill-in proceeds as before.
|
|
3471
|
+
const explicitForeignChannel =
|
|
3472
|
+
!!options.slackChannelId &&
|
|
3473
|
+
!!parent.slackChannelId &&
|
|
3474
|
+
options.slackChannelId !== parent.slackChannelId;
|
|
3454
3475
|
if (parent.slackChannelId && !options.slackChannelId) {
|
|
3455
3476
|
options.slackChannelId = parent.slackChannelId;
|
|
3456
3477
|
}
|
|
3457
|
-
if (parent.slackThreadTs && !options.slackThreadTs) {
|
|
3478
|
+
if (parent.slackThreadTs && !options.slackThreadTs && !explicitForeignChannel) {
|
|
3458
3479
|
options.slackThreadTs = parent.slackThreadTs;
|
|
3459
3480
|
}
|
|
3460
|
-
if (parent.slackUserId && !options.slackUserId) {
|
|
3481
|
+
if (parent.slackUserId && !options.slackUserId && !explicitForeignChannel) {
|
|
3461
3482
|
options.slackUserId = parent.slackUserId;
|
|
3462
3483
|
}
|
|
3463
3484
|
|
|
@@ -3585,6 +3606,71 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
|
|
|
3585
3606
|
}
|
|
3586
3607
|
}
|
|
3587
3608
|
|
|
3609
|
+
// contextKey → Slack-fields backfill: a slack-family contextKey is the
|
|
3610
|
+
// durable record of where this task belongs, but delivery code
|
|
3611
|
+
// (src/slack/responses.ts, src/slack/watcher.ts, src/tools/slack-reply.ts)
|
|
3612
|
+
// reads slackChannelId/slackThreadTs exclusively. Without this, a task that
|
|
3613
|
+
// inherited only a slack contextKey (no Slack fields) would silently never
|
|
3614
|
+
// deliver. Deliberately does NOT backfill slackUserId — the key doesn't
|
|
3615
|
+
// encode it.
|
|
3616
|
+
if (!options?.slackChannelId) {
|
|
3617
|
+
const backfill = slackChannelFromContextKey(options?.contextKey);
|
|
3618
|
+
if (backfill && options) {
|
|
3619
|
+
options.slackChannelId = backfill.channelId;
|
|
3620
|
+
options.slackThreadTs = backfill.threadTs;
|
|
3621
|
+
}
|
|
3622
|
+
} else if (!options.slackThreadTs) {
|
|
3623
|
+
// Channel present but no thread: a trusted caller supplied slackChannelId
|
|
3624
|
+
// without slackThreadTs. If it matches the slack-family contextKey's
|
|
3625
|
+
// channel, fill the thread from there too — otherwise delivery (which
|
|
3626
|
+
// reads slackThreadTs directly) silently fails to thread.
|
|
3627
|
+
const backfill = slackChannelFromContextKey(options.contextKey);
|
|
3628
|
+
if (backfill && backfill.channelId === options.slackChannelId) {
|
|
3629
|
+
options.slackThreadTs = backfill.threadTs;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
// Residual-mismatch guard: after all inheritance/backfill above, a final
|
|
3634
|
+
// slackChannelId/slackThreadTs that still disagrees with a slack-family
|
|
3635
|
+
// contextKey would misroute delivery — src/slack/responses.ts,
|
|
3636
|
+
// src/slack/watcher.ts, and src/tools/slack-reply.ts all read these fields
|
|
3637
|
+
// directly, so logging a warning and inserting the mismatch unchanged does
|
|
3638
|
+
// NOT prevent misdelivery. This boundary is reachable by untrusted-ish
|
|
3639
|
+
// callers too (e.g. POST /api/tasks accepts client-supplied parentTaskId +
|
|
3640
|
+
// contextKey), so a non-override caller must not be able to persist a
|
|
3641
|
+
// mismatch: the durable contextKey wins and the Slack fields are
|
|
3642
|
+
// normalized to match it. `overrideSlackContext` (set by `send-task` when
|
|
3643
|
+
// the caller passed `overrideSlackContext: true`) opts out, preserving the
|
|
3644
|
+
// deliberately divergent lineage. Never throw here — this also runs on
|
|
3645
|
+
// trusted ingress hot paths that must not fail task creation.
|
|
3646
|
+
const finalSlackContext = slackChannelFromContextKey(options?.contextKey);
|
|
3647
|
+
if (finalSlackContext && options?.slackChannelId) {
|
|
3648
|
+
if (options.slackChannelId !== finalSlackContext.channelId) {
|
|
3649
|
+
if (options.overrideSlackContext) {
|
|
3650
|
+
console.log(
|
|
3651
|
+
`[slack-routing] override: keeping slackChannelId="${options.slackChannelId}" despite disagreeing with contextKey channel "${finalSlackContext.channelId}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
|
|
3652
|
+
);
|
|
3653
|
+
} else {
|
|
3654
|
+
console.warn(
|
|
3655
|
+
`[slack-routing] MISMATCH task creation: normalizing slackChannelId="${options.slackChannelId}" to contextKey channel "${finalSlackContext.channelId}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
|
|
3656
|
+
);
|
|
3657
|
+
options.slackChannelId = finalSlackContext.channelId;
|
|
3658
|
+
options.slackThreadTs = finalSlackContext.threadTs;
|
|
3659
|
+
}
|
|
3660
|
+
} else if (options.slackThreadTs && options.slackThreadTs !== finalSlackContext.threadTs) {
|
|
3661
|
+
if (options.overrideSlackContext) {
|
|
3662
|
+
console.log(
|
|
3663
|
+
`[slack-routing] override: keeping slackThreadTs="${options.slackThreadTs}" despite disagreeing with contextKey thread "${finalSlackContext.threadTs}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
|
|
3664
|
+
);
|
|
3665
|
+
} else {
|
|
3666
|
+
console.warn(
|
|
3667
|
+
`[slack-routing] MISMATCH task creation: normalizing slackThreadTs="${options.slackThreadTs}" to contextKey thread "${finalSlackContext.threadTs}" (contextKey=${options.contextKey}, sourceTaskId=${options.sourceTaskId ?? "n/a"}, parentTaskId=${options.parentTaskId ?? "n/a"})`,
|
|
3668
|
+
);
|
|
3669
|
+
options.slackThreadTs = finalSlackContext.threadTs;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3588
3674
|
const auditUserId = getCurrentRequestUserId() ?? null;
|
|
3589
3675
|
const row = getDb()
|
|
3590
3676
|
.prepare<AgentTaskRow, (string | number | null)[]>(
|
package/src/server-user.ts
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack-routing coherence guard.
|
|
3
|
+
*
|
|
4
|
+
* A task's Slack destination is carried by three fields — `slackChannelId`,
|
|
5
|
+
* `slackThreadTs`, `slackUserId` — which delivery code (`src/slack/responses.ts`,
|
|
6
|
+
* `src/slack/watcher.ts`, `src/tools/slack-reply.ts`) reads directly, alongside
|
|
7
|
+
* the durable, immutable `contextKey` that records where a task's ingress
|
|
8
|
+
* entity actually lives. Nothing upstream compared the two before creating a
|
|
9
|
+
* task, so a caller could hand-type a `slackChannelId` that disagreed with
|
|
10
|
+
* both the parent task and the inherited `contextKey`, silently misrouting a
|
|
11
|
+
* worker's completion summary into the wrong human's DM (see swarm memory
|
|
12
|
+
* `dispatch-slack-channel-must-match-parent-context-2026-07-10`).
|
|
13
|
+
*
|
|
14
|
+
* This module is pure and dependency-light (only the `AgentTask` type +
|
|
15
|
+
* `parseContextKey`) so it can be imported from both the agent-facing tool
|
|
16
|
+
* boundary (`src/tools/send-task.ts`, strict-reject) and the DB boundary
|
|
17
|
+
* (`src/be/db.ts` `createTaskExtended`, normalize-and-warn).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { AgentTask } from "../types";
|
|
21
|
+
import { parseContextKey } from "./context-key";
|
|
22
|
+
|
|
23
|
+
export type SlackUnit = {
|
|
24
|
+
channelId?: string | null;
|
|
25
|
+
threadTs?: string | null;
|
|
26
|
+
userId?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SlackRoutingVerdict =
|
|
30
|
+
| { verdict: "ok" }
|
|
31
|
+
| { verdict: "partial-unit"; detail: string }
|
|
32
|
+
| {
|
|
33
|
+
verdict: "mismatch";
|
|
34
|
+
field: "slackChannelId" | "slackThreadTs";
|
|
35
|
+
expected: string;
|
|
36
|
+
expectedSource: "parent" | "contextKey";
|
|
37
|
+
got: string;
|
|
38
|
+
detail: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse the Slack channel/thread encoded in a `contextKey`, iff it is
|
|
43
|
+
* slack-family. Returns `null` for non-slack families and for malformed keys
|
|
44
|
+
* — a parse failure here must never throw, since this runs on hot creation
|
|
45
|
+
* paths that also handle non-Slack ingress.
|
|
46
|
+
*/
|
|
47
|
+
export function slackChannelFromContextKey(
|
|
48
|
+
key: string | null | undefined,
|
|
49
|
+
): { channelId: string; threadTs: string } | null {
|
|
50
|
+
if (!key) return null;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = parseContextKey(key);
|
|
53
|
+
if (parsed.family !== "slack") return null;
|
|
54
|
+
return { channelId: parsed.parts.channelId, threadTs: parsed.parts.threadTs };
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check whether an explicit Slack unit is coherent with the parent task and
|
|
62
|
+
* the contextKey the new task will carry. `channelId`/`threadTs` must be
|
|
63
|
+
* both-or-neither (delivery requires both); when a channel is present it
|
|
64
|
+
* must agree with the parent's `slackChannelId` AND `slackThreadTs` (if set)
|
|
65
|
+
* and with the channel/thread encoded in a slack-family `inheritedContextKey`
|
|
66
|
+
* (if any). `userId` is attribution only and is never checked — it is
|
|
67
|
+
* harmless on its own. Callers that want a deliberate cross-channel/thread
|
|
68
|
+
* dispatch skip this check entirely (see `overrideSlackContext` at the
|
|
69
|
+
* `send-task` boundary) rather than passing a bypass flag into this helper.
|
|
70
|
+
*/
|
|
71
|
+
export function checkSlackRoutingCoherence(input: {
|
|
72
|
+
explicit: SlackUnit;
|
|
73
|
+
parent?: Pick<AgentTask, "slackChannelId" | "slackThreadTs" | "contextKey"> | null;
|
|
74
|
+
inheritedContextKey?: string | null;
|
|
75
|
+
}): SlackRoutingVerdict {
|
|
76
|
+
const { explicit, parent, inheritedContextKey } = input;
|
|
77
|
+
const hasChannel = !!explicit.channelId;
|
|
78
|
+
const hasThread = !!explicit.threadTs;
|
|
79
|
+
|
|
80
|
+
if (hasChannel !== hasThread) {
|
|
81
|
+
return {
|
|
82
|
+
verdict: "partial-unit",
|
|
83
|
+
detail: hasChannel
|
|
84
|
+
? `slackChannelId "${explicit.channelId}" was passed without slackThreadTs — both are required together for Slack delivery to work.`
|
|
85
|
+
: `slackThreadTs "${explicit.threadTs}" was passed without slackChannelId — both are required together for Slack delivery to work.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!hasChannel) {
|
|
90
|
+
return { verdict: "ok" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const channelId = explicit.channelId as string;
|
|
94
|
+
const threadTs = explicit.threadTs as string;
|
|
95
|
+
|
|
96
|
+
if (parent?.slackChannelId && channelId !== parent.slackChannelId) {
|
|
97
|
+
return {
|
|
98
|
+
verdict: "mismatch",
|
|
99
|
+
field: "slackChannelId",
|
|
100
|
+
expected: parent.slackChannelId,
|
|
101
|
+
expectedSource: "parent",
|
|
102
|
+
got: channelId,
|
|
103
|
+
detail: `explicit slackChannelId "${channelId}" does not match the parent task's slackChannelId "${parent.slackChannelId}".`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Same channel as the parent, but a different thread: the DB still
|
|
108
|
+
// preserves an explicit slackThreadTs verbatim and delivery reads it
|
|
109
|
+
// directly, so this would post to the wrong thread in the parent's own
|
|
110
|
+
// channel. Only meaningful once we know the channel agrees (above).
|
|
111
|
+
if (parent?.slackThreadTs && threadTs !== parent.slackThreadTs) {
|
|
112
|
+
return {
|
|
113
|
+
verdict: "mismatch",
|
|
114
|
+
field: "slackThreadTs",
|
|
115
|
+
expected: parent.slackThreadTs,
|
|
116
|
+
expectedSource: "parent",
|
|
117
|
+
got: threadTs,
|
|
118
|
+
detail: `explicit slackThreadTs "${threadTs}" does not match the parent task's slackThreadTs "${parent.slackThreadTs}" (same channel "${parent.slackChannelId}").`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const contextKeySlack = slackChannelFromContextKey(inheritedContextKey);
|
|
123
|
+
if (contextKeySlack) {
|
|
124
|
+
if (channelId !== contextKeySlack.channelId) {
|
|
125
|
+
return {
|
|
126
|
+
verdict: "mismatch",
|
|
127
|
+
field: "slackChannelId",
|
|
128
|
+
expected: contextKeySlack.channelId,
|
|
129
|
+
expectedSource: "contextKey",
|
|
130
|
+
got: channelId,
|
|
131
|
+
detail: `explicit slackChannelId "${channelId}" does not match the channel encoded in contextKey "${inheritedContextKey}" ("${contextKeySlack.channelId}").`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (threadTs !== contextKeySlack.threadTs) {
|
|
135
|
+
return {
|
|
136
|
+
verdict: "mismatch",
|
|
137
|
+
field: "slackThreadTs",
|
|
138
|
+
expected: contextKeySlack.threadTs,
|
|
139
|
+
expectedSource: "contextKey",
|
|
140
|
+
got: threadTs,
|
|
141
|
+
detail: `explicit slackThreadTs "${threadTs}" does not match the thread encoded in contextKey "${inheritedContextKey}" ("${contextKeySlack.threadTs}").`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { verdict: "ok" };
|
|
147
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getAgentCurrentTask,
|
|
11
11
|
getApprovalRequestById,
|
|
12
12
|
getApprovalRequestByStepId,
|
|
13
|
+
getDb,
|
|
13
14
|
getExpiredPendingApprovals,
|
|
14
15
|
initDb,
|
|
15
16
|
listApprovalRequests,
|
|
@@ -441,6 +442,41 @@ describe("Approval Requests", () => {
|
|
|
441
442
|
}
|
|
442
443
|
});
|
|
443
444
|
|
|
445
|
+
test("applies status before limiting newer rows", async () => {
|
|
446
|
+
const pending = createApprovalRequest(makeApprovalData({ title: "Older pending request" }));
|
|
447
|
+
getDb()
|
|
448
|
+
.prepare("UPDATE approval_requests SET createdAt = ? WHERE id = ?")
|
|
449
|
+
.run("2099-01-01T00:00:00.000Z", pending.id);
|
|
450
|
+
|
|
451
|
+
for (const index of [1, 2]) {
|
|
452
|
+
const resolved = createApprovalRequest(
|
|
453
|
+
makeApprovalData({ title: `Newer resolved request ${index}` }),
|
|
454
|
+
);
|
|
455
|
+
resolveApprovalRequest(resolved.id, {
|
|
456
|
+
status: "approved",
|
|
457
|
+
responses: { q1: { approved: true } },
|
|
458
|
+
});
|
|
459
|
+
getDb()
|
|
460
|
+
.prepare("UPDATE approval_requests SET createdAt = ? WHERE id = ?")
|
|
461
|
+
.run(`2100-01-0${index}T00:00:00.000Z`, resolved.id);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const unfiltered = await fetch(`${baseUrl}/api/approval-requests?limit=1`);
|
|
465
|
+
const unfilteredData = (await unfiltered.json()) as {
|
|
466
|
+
approvalRequests: Array<{ id: string }>;
|
|
467
|
+
};
|
|
468
|
+
expect(unfilteredData.approvalRequests[0]?.id).not.toBe(pending.id);
|
|
469
|
+
|
|
470
|
+
const filtered = await fetch(`${baseUrl}/api/approval-requests?status=pending&limit=1`);
|
|
471
|
+
expect(filtered.status).toBe(200);
|
|
472
|
+
const filteredData = (await filtered.json()) as {
|
|
473
|
+
approvalRequests: Array<{ id: string; status: string }>;
|
|
474
|
+
};
|
|
475
|
+
expect(filteredData.approvalRequests).toEqual([
|
|
476
|
+
expect.objectContaining({ id: pending.id, status: "pending" }),
|
|
477
|
+
]);
|
|
478
|
+
});
|
|
479
|
+
|
|
444
480
|
test("filters by workflowRunId", async () => {
|
|
445
481
|
const runId = crypto.randomUUID();
|
|
446
482
|
// Create one with this runId
|
|
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import { rm, unlink } from "node:fs/promises";
|
|
10
10
|
import type { Subprocess } from "bun";
|
|
11
11
|
import { Webhook } from "svix";
|
|
12
|
+
import { slackContextKey } from "../tasks/context-key";
|
|
12
13
|
|
|
13
14
|
const TEST_PORT = 19876;
|
|
14
15
|
const TEST_DB_PATH = `/tmp/test-http-integration-${Date.now()}.sqlite`;
|
|
@@ -432,6 +433,43 @@ describe("Tasks", () => {
|
|
|
432
433
|
expect(status).toBe(404);
|
|
433
434
|
});
|
|
434
435
|
|
|
436
|
+
test("POST /api/tasks — client-supplied parentTaskId + contextKey cannot persist a Slack route/contextKey mismatch", async () => {
|
|
437
|
+
// POST /api/tasks doesn't accept slackChannelId/slackThreadTs directly,
|
|
438
|
+
// but a slack-family contextKey backfills them (src/be/db.ts). Build a
|
|
439
|
+
// parent whose Slack unit lives in channel A this way.
|
|
440
|
+
const contextKeyA = slackContextKey({ channelId: "HTTP_CHAN_A", threadTs: "1111.1111" });
|
|
441
|
+
const parent = await post("/api/tasks", {
|
|
442
|
+
agentId: ids.leadAgent,
|
|
443
|
+
body: {
|
|
444
|
+
task: "HTTP parent task in channel A",
|
|
445
|
+
contextKey: contextKeyA,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
expect(parent.status).toBe(201);
|
|
449
|
+
expect(parent.body.slackChannelId).toBe("HTTP_CHAN_A");
|
|
450
|
+
|
|
451
|
+
// A child that inherits the parent's Slack unit (via parentTaskId) but
|
|
452
|
+
// carries an explicit contextKey for a DIFFERENT channel B — exactly the
|
|
453
|
+
// client-supplied parentTaskId + contextKey shape the reviewer flagged.
|
|
454
|
+
const contextKeyB = slackContextKey({ channelId: "HTTP_CHAN_B", threadTs: "2222.2222" });
|
|
455
|
+
const child = await post("/api/tasks", {
|
|
456
|
+
agentId: ids.leadAgent,
|
|
457
|
+
body: {
|
|
458
|
+
task: "HTTP child task claiming a different channel via contextKey",
|
|
459
|
+
parentTaskId: parent.body.id,
|
|
460
|
+
contextKey: contextKeyB,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
expect(child.status).toBe(201);
|
|
464
|
+
|
|
465
|
+
// The durable contextKey (channel B) must win over the parent-inherited
|
|
466
|
+
// Slack unit (channel A) — a non-override HTTP caller cannot persist the
|
|
467
|
+
// mismatch that misroutes delivery.
|
|
468
|
+
expect(child.body.slackChannelId).toBe("HTTP_CHAN_B");
|
|
469
|
+
expect(child.body.slackThreadTs).toBe("2222.2222");
|
|
470
|
+
expect(child.body.contextKey).toBe(contextKeyB);
|
|
471
|
+
});
|
|
472
|
+
|
|
435
473
|
test("PUT /api/tasks/:id/session — update session ID", async () => {
|
|
436
474
|
const sessionId = randomUUID();
|
|
437
475
|
const { status, body } = await put(`/api/tasks/${ids.task2}/session`, {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
fetchAllPages,
|
|
4
|
+
PAGES_BATCH_SIZE,
|
|
5
|
+
type PagesBatchFilters,
|
|
6
|
+
} from "../../apps/ui/src/api/fetch-all-pages";
|
|
7
|
+
import type { PageListItem } from "../../apps/ui/src/api/types";
|
|
8
|
+
|
|
9
|
+
function page(index: number): PageListItem {
|
|
10
|
+
const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
|
|
11
|
+
return {
|
|
12
|
+
id: `page-${index}`,
|
|
13
|
+
agentId: index === 620 ? "matching-agent" : "other-agent",
|
|
14
|
+
slug: `page-${index}`,
|
|
15
|
+
title: `Page ${index}`,
|
|
16
|
+
contentType: "text/html",
|
|
17
|
+
authMode: "public",
|
|
18
|
+
body: "",
|
|
19
|
+
viewCount: 0,
|
|
20
|
+
createdAt: timestamp,
|
|
21
|
+
updatedAt: timestamp,
|
|
22
|
+
app_url: `/pages/page-${index}`,
|
|
23
|
+
api_url: `/p/page-${index}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("fetchAllPages", () => {
|
|
28
|
+
test("loads candidates beyond the default page and endpoint batch cap", async () => {
|
|
29
|
+
const source = Array.from({ length: 625 }, (_, index) => page(index));
|
|
30
|
+
const calls: PagesBatchFilters[] = [];
|
|
31
|
+
|
|
32
|
+
const result = await fetchAllPages(async (filters) => {
|
|
33
|
+
calls.push(filters);
|
|
34
|
+
const offset = filters.offset ?? 0;
|
|
35
|
+
const limit = filters.limit ?? 50;
|
|
36
|
+
return {
|
|
37
|
+
pages: source.slice(offset, offset + limit),
|
|
38
|
+
total: source.length,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(calls).toEqual([
|
|
43
|
+
{ limit: PAGES_BATCH_SIZE, offset: 0 },
|
|
44
|
+
{ limit: PAGES_BATCH_SIZE, offset: PAGES_BATCH_SIZE },
|
|
45
|
+
]);
|
|
46
|
+
expect(result.pages).toHaveLength(source.length);
|
|
47
|
+
expect(result.pages.find((candidate) => candidate.agentId === "matching-agent")?.id).toBe(
|
|
48
|
+
"page-620",
|
|
49
|
+
);
|
|
50
|
+
expect(result.pages.map((candidate) => candidate.id)).toEqual(
|
|
51
|
+
source.map((candidate) => candidate.id),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|