@desplega.ai/agent-swarm 1.161.0 → 1.162.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 +3 -2
- package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
- package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
- package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
- package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
- package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
- package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
- package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
- package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
- package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
- package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
- package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
- package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
- package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
- package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
- package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
- package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
- package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
- package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
- package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
- package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
- package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
- package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
- package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
- package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
- package/dist/cli-bsky8g7w.js +449 -0
- package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
- package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
- package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
- package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
- package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
- package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
- package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
- package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
- package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
- package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
- package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
- package/dist/cli-kdkwd3yr.js +615 -0
- package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
- package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
- package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
- package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
- package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
- package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
- package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
- package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
- package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
- package/dist/cli-qjjvzwzd.js +222 -0
- package/dist/cli-qrgt5bqf.js +189 -0
- package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
- package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
- package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
- package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
- package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
- package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
- package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
- package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
- package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
- package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
- package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
- package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
- package/dist/cli-wbvnn6tk.js +861 -0
- package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
- package/dist/cli-wxkkd0e0.js +33 -0
- package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
- package/dist/cli-zp38xvtm.js +9487 -0
- package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
- package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
- package/dist/cli.js +32 -25
- package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
- package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
- package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
- package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
- package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
- package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
- package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
- package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
- package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
- package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
- package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
- package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
- package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
- package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
- package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
- package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
- package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
- package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
- package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
- package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
- package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
- package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
- package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
- package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
- package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
- package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
- package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
- package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
- package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
- package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
- package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
- package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
- package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
- package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
- package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
- package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
- package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
- package/dist/rbac-roles-vpk4ebm8.js +45 -0
- package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
- package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
- package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
- package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
- package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
- package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
- package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
- package/openapi.json +491 -12
- package/package.json +13 -6
- package/src/be/api-drain.ts +184 -0
- package/src/be/db/tasks/read.ts +31 -6
- package/src/be/db/tasks/write.ts +5 -4
- package/src/be/db.ts +42 -18
- package/src/be/memory/link-resolver.ts +29 -3
- package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
- package/src/be/onboarding.ts +7 -1
- package/src/be/seed-prompt-templates.ts +1 -0
- package/src/be/swarm-config-guard.ts +35 -16
- package/src/be/task-citations.ts +11 -5
- package/src/be/users.ts +3 -0
- package/src/cli.tsx +3 -2
- package/src/comb/agent-fs.ts +39 -0
- package/src/comb/config.ts +37 -0
- package/src/comb/markers.ts +37 -0
- package/src/comb/review-batch.ts +486 -0
- package/src/comb/templates.ts +88 -0
- package/src/commands/context-preamble.ts +4 -1
- package/src/commands/hook.ts +2 -1
- package/src/commands/runner.ts +262 -68
- package/src/commands/templates.ts +10 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/fs/agent-fs-provider.ts +106 -2
- package/src/heartbeat/heartbeat.ts +4 -0
- package/src/hooks/codex-hook.ts +25 -1
- package/src/hooks/hook.ts +13 -0
- package/src/hooks/pr-body-guard.ts +983 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/assets.ts +5 -17
- package/src/http/comb.ts +131 -0
- package/src/http/core.ts +2 -1
- package/src/http/fs.ts +9 -17
- package/src/http/index.ts +23 -9
- package/src/http/onboarding.ts +21 -2
- package/src/http/poll.ts +30 -3
- package/src/http/realtime.ts +62 -0
- package/src/http/request-principal.ts +22 -0
- package/src/http/status.ts +34 -2
- package/src/kv-reserved-namespaces.ts +10 -5
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +123 -0
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/realtime/auth.ts +63 -1
- package/src/realtime/tickets.ts +52 -0
- package/src/realtime/transport.ts +51 -22
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
- package/src/telemetry-context.ts +175 -0
- package/src/telemetry-contract/EVENTS.md +81 -0
- package/src/telemetry-contract/SOURCE.json +9 -0
- package/src/telemetry-contract/catalog.json +1335 -0
- package/src/telemetry-contract/index.ts +60 -0
- package/src/telemetry-contract/types.gen.ts +1971 -0
- package/src/telemetry-identity.ts +129 -0
- package/src/telemetry-snapshot.ts +211 -0
- package/src/telemetry-trigger.ts +125 -0
- package/src/telemetry-types.typetest.ts +40 -0
- package/src/telemetry.ts +271 -67
- package/src/tests/api-drain-e2e.test.ts +249 -0
- package/src/tests/api-drain-worker-e2e.test.ts +216 -0
- package/src/tests/api-drain.test.ts +351 -0
- package/src/tests/comb-config-guard.test.ts +38 -0
- package/src/tests/comb-links.test.ts +162 -0
- package/src/tests/comb-markers.test.ts +68 -0
- package/src/tests/comb-review-batch.test.ts +770 -0
- package/src/tests/comb-status.test.ts +152 -0
- package/src/tests/db-task-read-contract.test.ts +2 -0
- package/src/tests/db-task-write-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/favorites-agent-fs-path.test.ts +226 -0
- package/src/tests/memory-link-resolver.test.ts +34 -0
- package/src/tests/onboarding.test.ts +108 -0
- package/src/tests/pi-mono-adapter.test.ts +265 -2
- package/src/tests/pr-body-leaks.test.ts +789 -0
- package/src/tests/preload.ts +8 -0
- package/src/tests/rbac-engine.test.ts +19 -0
- package/src/tests/realtime-room-auth.test.ts +104 -1
- package/src/tests/realtime-tickets.test.ts +74 -0
- package/src/tests/realtime-transport.test.ts +131 -4
- package/src/tests/runner-api-drain.test.ts +301 -0
- package/src/tests/runner-fallback-output.test.ts +61 -0
- package/src/tests/runner-fenced-json-output.test.ts +178 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/send-task-requested-by.test.ts +133 -15
- package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
- package/src/tests/telemetry-context.test.ts +314 -0
- package/src/tests/telemetry-init.test.ts +51 -19
- package/src/tests/telemetry-snapshot.test.ts +229 -0
- package/src/tests/telemetry-trigger.test.ts +200 -0
- package/src/tests/template-registry-helpers.ts +1 -0
- package/src/tests/workflow-agent-task-retry.test.ts +363 -0
- package/src/tests/workflow-definition-telemetry.test.ts +4 -4
- package/src/tools/poll-task.ts +20 -1
- package/src/tools/send-task.ts +9 -1
- package/src/tools/task-action.ts +11 -0
- package/src/types.ts +2 -1
- package/src/utils/api-drain.ts +24 -0
- package/src/utils/constants.ts +57 -16
- package/src/utils/pr-body-leaks.ts +172 -0
- package/src/utils/task-attachment-links.ts +14 -8
- package/src/workflows/recovery.ts +26 -1
- package/src/workflows/resume.ts +31 -4
- package/src/workflows/task-step-routing.ts +75 -2
- package/tsconfig.json +3 -0
- package/dist/cli-tjf4cn8z.js +0 -9119
- package/dist/cli-tsk3nz8a.js +0 -582
- package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
package/src/http/all-routes.ts
CHANGED
package/src/http/assets.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { auditAssetKeys } from "../be/asset-key-audit";
|
|
|
5
5
|
import { AssetKeyAuthorizationError, authorizeAssetKeyWrite } from "../be/asset-key-auth";
|
|
6
6
|
import { resolveHttpAuditUserId } from "../be/audit-user";
|
|
7
7
|
import {
|
|
8
|
-
getAgentById,
|
|
9
8
|
getDb,
|
|
10
9
|
getDbClient,
|
|
11
10
|
getTaskById,
|
|
@@ -14,7 +13,7 @@ import {
|
|
|
14
13
|
upsertAssetKeyMapping,
|
|
15
14
|
} from "../be/db";
|
|
16
15
|
import { getScriptById } from "../be/scripts/db";
|
|
17
|
-
import { can, type
|
|
16
|
+
import { can, type RbacResource } from "../rbac";
|
|
18
17
|
import {
|
|
19
18
|
type AssetEntityType,
|
|
20
19
|
AssetEntityTypeSchema,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
AssetSummarySchema,
|
|
24
23
|
} from "../types";
|
|
25
24
|
import { getRequestAuth } from "../utils/request-auth-context";
|
|
25
|
+
import { requestPrincipal } from "./request-principal";
|
|
26
26
|
import { route } from "./route-def";
|
|
27
27
|
import { jsonError } from "./utils";
|
|
28
28
|
|
|
@@ -176,18 +176,6 @@ const moveAssetRoute = route({
|
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
async function assetMovePrincipal(
|
|
180
|
-
req: IncomingMessage,
|
|
181
|
-
myAgentId: string | undefined,
|
|
182
|
-
): Promise<RbacPrincipal | null> {
|
|
183
|
-
const auth = getRequestAuth(req);
|
|
184
|
-
if (auth?.kind === "operator") return { kind: "operator" };
|
|
185
|
-
if (auth?.kind === "user") return { kind: "user", userId: auth.userId };
|
|
186
|
-
if (!myAgentId) return null;
|
|
187
|
-
const agent = await getAgentById(myAgentId);
|
|
188
|
-
return { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
179
|
async function canMutateTaskNamespace(
|
|
192
180
|
task: { id: string; agentId: string | null; creatorAgentId?: string },
|
|
193
181
|
myAgentId: string | undefined,
|
|
@@ -199,7 +187,7 @@ async function canMutateTaskNamespace(
|
|
|
199
187
|
agentId: task.agentId,
|
|
200
188
|
creatorAgentId: task.creatorAgentId,
|
|
201
189
|
};
|
|
202
|
-
const principal = await
|
|
190
|
+
const principal = await requestPrincipal(req, myAgentId);
|
|
203
191
|
if (!principal) return false;
|
|
204
192
|
return can({ principal, verb: "task.fs.mutate", resource, source: "http" }).allow;
|
|
205
193
|
}
|
|
@@ -209,7 +197,7 @@ async function canManageAppNamespace(
|
|
|
209
197
|
req: IncomingMessage,
|
|
210
198
|
myAgentId: string | undefined,
|
|
211
199
|
): Promise<boolean> {
|
|
212
|
-
const principal = await
|
|
200
|
+
const principal = await requestPrincipal(req, myAgentId);
|
|
213
201
|
return (
|
|
214
202
|
!!principal &&
|
|
215
203
|
can({ principal, verb: "app.manage", resource: { kind: "app", appId: id }, source: "http" })
|
|
@@ -222,7 +210,7 @@ async function canManageScriptNamespace(
|
|
|
222
210
|
req: IncomingMessage,
|
|
223
211
|
myAgentId: string | undefined,
|
|
224
212
|
): Promise<boolean> {
|
|
225
|
-
const principal = await
|
|
213
|
+
const principal = await requestPrincipal(req, myAgentId);
|
|
226
214
|
if (!principal) return false;
|
|
227
215
|
if (principal.kind === "operator") return true;
|
|
228
216
|
if (principal.kind !== "agent") return false;
|
package/src/http/comb.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { resolveHttpAuditUserId } from "../be/audit-user";
|
|
4
|
+
import { getCombConfig } from "../comb/config";
|
|
5
|
+
import { REVIEW_BATCH_MAX, ReviewBatchError, sendReviewBatch } from "../comb/review-batch";
|
|
6
|
+
import { can } from "../rbac";
|
|
7
|
+
import { TaskCreationBlockedError } from "../tasks/errors";
|
|
8
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
9
|
+
import { requestPrincipal } from "./request-principal";
|
|
10
|
+
import { route } from "./route-def";
|
|
11
|
+
import { jsonError } from "./utils";
|
|
12
|
+
|
|
13
|
+
const skippedSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
reason: z.enum(["not-found", "reply", "resolved", "already-sent"]),
|
|
16
|
+
/** The task an "already-sent" comment went to, when known. */
|
|
17
|
+
taskId: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const resultSchema = z.object({
|
|
21
|
+
/** The new task. Null on a 200: the batch only posted missing "sent" replies again. */
|
|
22
|
+
taskId: z.string().nullable(),
|
|
23
|
+
sent: z.array(z.string()),
|
|
24
|
+
skipped: z.array(skippedSchema),
|
|
25
|
+
/** Comments of an earlier send whose missing "sent" reply this batch posted again. */
|
|
26
|
+
repaired: z.array(z.object({ id: z.string(), taskId: z.string() })),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const batchErrorSchema = z.object({ error: z.string(), skipped: z.array(skippedSchema) });
|
|
30
|
+
|
|
31
|
+
/** A drive path: starts with "/", no "." or ".." segment. */
|
|
32
|
+
function isDrivePath(path: string): boolean {
|
|
33
|
+
return (
|
|
34
|
+
path.startsWith("/") && !path.split("/").some((segment) => segment === "." || segment === "..")
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const reviewBatchRoute = route({
|
|
39
|
+
method: "post",
|
|
40
|
+
path: "/api/comb/review-batches",
|
|
41
|
+
pattern: ["api", "comb", "review-batches"],
|
|
42
|
+
summary: "Send agent-fs comments to the swarm as one lead task",
|
|
43
|
+
description:
|
|
44
|
+
"Comb's 'Send to swarm'. The server reads each comment again from agent-fs with its bootstrap key, skips replies, resolved comments, and comments already sent, and creates ONE task for the lead. Each sent comment gets a `[comb:sent task=<id>]` reply from the swarm service account. A comment is sent at most once. When an earlier send lost its reply, the batch posts that reply again (`repaired`). Answers 404 while COMB_ENABLED is off.",
|
|
45
|
+
tags: ["Comb"],
|
|
46
|
+
body: z.object({
|
|
47
|
+
orgId: z.string().min(1),
|
|
48
|
+
driveId: z.string().min(1),
|
|
49
|
+
// agent-fs comment ids are UUIDs, so a malformed id never costs an agent-fs read.
|
|
50
|
+
commentIds: z.array(z.uuid()).min(1).max(REVIEW_BATCH_MAX),
|
|
51
|
+
/** The file or folder the batch was sent from (a Comb path such as "/docs/"). */
|
|
52
|
+
scopePath: z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(1024)
|
|
56
|
+
.refine(isDrivePath, "scopePath must start with '/' and have no '.' or '..' segment"),
|
|
57
|
+
}),
|
|
58
|
+
responses: {
|
|
59
|
+
200: {
|
|
60
|
+
description: "No new task: the batch only posted missing 'sent' replies again",
|
|
61
|
+
schema: resultSchema,
|
|
62
|
+
},
|
|
63
|
+
201: { description: "Task created", schema: resultSchema },
|
|
64
|
+
400: { description: "Invalid body, or not the swarm drive" },
|
|
65
|
+
403: { description: "Caller cannot create tasks" },
|
|
66
|
+
404: { description: "Comb is not enabled" },
|
|
67
|
+
409: {
|
|
68
|
+
description: "Nothing to send (every comment was skipped)",
|
|
69
|
+
schema: batchErrorSchema,
|
|
70
|
+
},
|
|
71
|
+
422: { description: "Task creation blocked by an extension" },
|
|
72
|
+
502: { description: "agent-fs could not read a comment" },
|
|
73
|
+
503: {
|
|
74
|
+
description:
|
|
75
|
+
"agent-fs or the swarm drive is not set up, or an operator disabled a Comb review template",
|
|
76
|
+
schema: batchErrorSchema,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
rbac: { permission: "task.create.own" },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export async function handleComb(
|
|
83
|
+
req: IncomingMessage,
|
|
84
|
+
res: ServerResponse,
|
|
85
|
+
pathSegments: string[],
|
|
86
|
+
queryParams: URLSearchParams,
|
|
87
|
+
myAgentId?: string,
|
|
88
|
+
): Promise<boolean> {
|
|
89
|
+
if (!reviewBatchRoute.match(req.method, pathSegments)) return false;
|
|
90
|
+
// Comb off: the route does not exist. No body parsing, no RBAC answer.
|
|
91
|
+
if (!getCombConfig().enabled) {
|
|
92
|
+
jsonError(res, "Comb is not enabled", 404);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parsed = await reviewBatchRoute.parse(req, res, pathSegments, queryParams);
|
|
97
|
+
if (!parsed) return true;
|
|
98
|
+
const principal = await requestPrincipal(req, myAgentId);
|
|
99
|
+
if (
|
|
100
|
+
!principal ||
|
|
101
|
+
!can({ principal, verb: "task.create.own", resource: { kind: "none" }, source: "http" }).allow
|
|
102
|
+
) {
|
|
103
|
+
jsonError(res, "Not authorized to create tasks", 403);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const result = await sendReviewBatch({
|
|
109
|
+
...parsed.body,
|
|
110
|
+
requestedByUserId: await resolveHttpAuditUserId(req, myAgentId),
|
|
111
|
+
});
|
|
112
|
+
reviewBatchRoute.respond(res, result.taskId === null ? 200 : 201, result);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof ReviewBatchError) {
|
|
115
|
+
if (error.status === 409 || error.status === 503) {
|
|
116
|
+
reviewBatchRoute.respond(res, error.status, {
|
|
117
|
+
error: error.message,
|
|
118
|
+
skipped: error.skipped,
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
jsonError(res, error.message, error.status);
|
|
122
|
+
}
|
|
123
|
+
} else if (error instanceof TaskCreationBlockedError) {
|
|
124
|
+
jsonError(res, error.reason, 422);
|
|
125
|
+
} else {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
127
|
+
jsonError(res, scrubSecrets(`Failed to send comments to the swarm: ${message}`), 500);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
package/src/http/core.ts
CHANGED
|
@@ -360,7 +360,8 @@ const pingRoute = route({
|
|
|
360
360
|
`Refreshes the calling agent's status. ${RUNTIME_HEADER_DOC} With multi-runtime mode on, ` +
|
|
361
361
|
"the header must identify a live runtime of this agent; an absent, unknown, offline, or " +
|
|
362
362
|
"foreign identifier makes the call a no-op instead of an error, so workers predating the " +
|
|
363
|
-
"flag keep running."
|
|
363
|
+
"flag keep running. While the API is draining after SIGTERM, every response (this one " +
|
|
364
|
+
"included) carries `X-Swarm-Draining: 1`: workers hand off in-flight tasks and take no new work.",
|
|
364
365
|
tags: ["Core"],
|
|
365
366
|
auth: { apiKey: true, agentId: true },
|
|
366
367
|
headers: runtimeInstanceHeader("refresh a runtime's liveness"),
|
package/src/http/fs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { deleteTaskAttachment,
|
|
3
|
+
import { deleteTaskAttachment, getTaskAttachments, getTaskById } from "../be/db";
|
|
4
4
|
import {
|
|
5
5
|
ensureAgentFsCredentialsForAgent,
|
|
6
6
|
inviteEmailToSharedOrg,
|
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
|
|
9
9
|
import { type FileObject, type FileScope, FilesError, normalizeFilesError } from "../fs/provider";
|
|
10
10
|
import { getFileStorageProvider } from "../fs/registry";
|
|
11
|
-
import { can, type
|
|
11
|
+
import { can, type RbacResource } from "../rbac";
|
|
12
12
|
import { type TaskAttachment, TaskAttachmentSchema } from "../types";
|
|
13
13
|
import { attachmentContentDisposition } from "../utils/content-disposition";
|
|
14
|
-
import { getCurrentRequestAuth
|
|
14
|
+
import { getCurrentRequestAuth } from "../utils/request-auth-context";
|
|
15
15
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
16
|
+
import { requestPrincipal } from "./request-principal";
|
|
16
17
|
import { route } from "./route-def";
|
|
17
18
|
import { BODY_TOO_LARGE, enforceContentLengthCap, jsonError } from "./utils";
|
|
18
19
|
|
|
@@ -522,20 +523,11 @@ async function canMutateTask(
|
|
|
522
523
|
// Decision order preserved (plan Appendix A row 36): operator/user request
|
|
523
524
|
// auth short-circuits BEFORE agent identity — an operator bearer with a
|
|
524
525
|
// non-owner X-Agent-ID is still allowed. The agent branches only bind when
|
|
525
|
-
// the request-auth context is unset.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
} else if (auth?.kind === "user") {
|
|
531
|
-
principal = { kind: "user", userId: auth.userId };
|
|
532
|
-
} else {
|
|
533
|
-
// A missing caller identity cannot be lead/assignee/creator — same denial
|
|
534
|
-
// as before (no separate "agent not found" branch).
|
|
535
|
-
if (!myAgentId) return false;
|
|
536
|
-
const agent = await getAgentById(myAgentId);
|
|
537
|
-
principal = { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
|
|
538
|
-
}
|
|
526
|
+
// the request-auth context is unset. A missing caller identity cannot be
|
|
527
|
+
// lead/assignee/creator: same denial as before (no separate "agent not
|
|
528
|
+
// found" branch).
|
|
529
|
+
const principal = await requestPrincipal(req, myAgentId);
|
|
530
|
+
if (!principal) return false;
|
|
539
531
|
return can({ principal, verb: "task.fs.mutate", resource, source: "http" }).allow;
|
|
540
532
|
}
|
|
541
533
|
|
package/src/http/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/se
|
|
|
9
9
|
import { getEnabledCapabilities, hasCapability } from "@/server";
|
|
10
10
|
import { initAgentMail } from "../agentmail";
|
|
11
11
|
import { initAzureDevOps } from "../azure-devops";
|
|
12
|
+
import { drainApi, isApiDraining } from "../be/api-drain";
|
|
12
13
|
import {
|
|
13
14
|
closeDb,
|
|
14
15
|
emitBuiltInIntegrationConnectedOnce,
|
|
@@ -53,6 +54,8 @@ import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-wor
|
|
|
53
54
|
import { getServerSessionsProcessed } from "../server-runtime-counters";
|
|
54
55
|
import { startSlackApp, stopSlackApp } from "../slack";
|
|
55
56
|
import { initTelemetry, telemetry } from "../telemetry";
|
|
57
|
+
import { startTelemetryTicker } from "../telemetry-snapshot";
|
|
58
|
+
import { API_DRAINING_HEADER } from "../utils/api-drain";
|
|
56
59
|
import { getApiKey } from "../utils/api-key";
|
|
57
60
|
import { getMcpBaseUrl } from "../utils/constants";
|
|
58
61
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
@@ -67,6 +70,7 @@ import { handleAssets } from "./assets";
|
|
|
67
70
|
import { handleBudgets } from "./budgets";
|
|
68
71
|
import { handleCodexOAuthDevice } from "./codex-oauth-device";
|
|
69
72
|
import { handleCodexOAuthKeepWarm } from "./codex-oauth-keep-warm";
|
|
73
|
+
import { handleComb } from "./comb";
|
|
70
74
|
import { handleConfig } from "./config";
|
|
71
75
|
import { handleContext } from "./context";
|
|
72
76
|
import { handleCore, loadGlobalConfigsIntoEnv } from "./core";
|
|
@@ -104,7 +108,7 @@ import { handlePagesPublic } from "./pages-public";
|
|
|
104
108
|
import { handlePoll } from "./poll";
|
|
105
109
|
import { handlePricing } from "./pricing";
|
|
106
110
|
import { handlePromptTemplates } from "./prompt-templates";
|
|
107
|
-
import { handleRealtimeAsset } from "./realtime";
|
|
111
|
+
import { handleRealtimeAsset, handleRealtimeTicket } from "./realtime";
|
|
108
112
|
import { handleRepos } from "./repos";
|
|
109
113
|
import { handleRooms } from "./rooms";
|
|
110
114
|
import { describeRequestRoute } from "./route-def";
|
|
@@ -318,6 +322,8 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
318
322
|
// nest under it instead of attaching to the root with no parent.
|
|
319
323
|
const handleRequest = async () => {
|
|
320
324
|
setCorsHeaders(req, res);
|
|
325
|
+
// Tells polling workers to hand off in-flight tasks while this API still serves.
|
|
326
|
+
if (isApiDraining()) res.setHeader(API_DRAINING_HEADER, "1");
|
|
321
327
|
|
|
322
328
|
const queryParams = parseQueryParams(req.url || "");
|
|
323
329
|
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
@@ -348,8 +354,10 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
348
354
|
() => handleApps(req, res, pathSegments, queryParams, myAgentId),
|
|
349
355
|
() => handleConfig(req, res, pathSegments, queryParams),
|
|
350
356
|
() => handleFs(req, res, pathSegments, queryParams, myAgentId),
|
|
357
|
+
() => handleComb(req, res, pathSegments, queryParams, myAgentId),
|
|
351
358
|
() => handleKv(req, res, pathSegments, queryParams),
|
|
352
359
|
() => handleRooms(req, res, pathSegments, queryParams),
|
|
360
|
+
() => handleRealtimeTicket(req, res, pathSegments, queryParams),
|
|
353
361
|
() => handleRealtimeAsset(req, res),
|
|
354
362
|
() => handleIntegrations(req, res, pathSegments, queryParams),
|
|
355
363
|
() => handlePromptTemplates(req, res, pathSegments, queryParams),
|
|
@@ -469,6 +477,10 @@ async function shutdown() {
|
|
|
469
477
|
// Stop the out-of-band queue alarm before disconnecting its Slack notifier.
|
|
470
478
|
stopQueueStallAlarm();
|
|
471
479
|
|
|
480
|
+
// Dispatch has stopped. Keep serving, bounded, while workers hand off their
|
|
481
|
+
// in-flight tasks; new work waits for the next API (see src/be/api-drain.ts).
|
|
482
|
+
await drainApi();
|
|
483
|
+
|
|
472
484
|
// Stop durable script workflow subprocesses
|
|
473
485
|
await stopScriptRunSupervisor();
|
|
474
486
|
|
|
@@ -684,15 +696,17 @@ httpServer
|
|
|
684
696
|
// The api-server is the sole authority for the install identity — pass
|
|
685
697
|
// generateIfMissing so it mints a new install ID on first boot. Workers
|
|
686
698
|
// must NOT mint (see src/commands/runner.ts).
|
|
687
|
-
|
|
688
|
-
"
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
);
|
|
699
|
+
const telemetryGetConfig = async (key: string) =>
|
|
700
|
+
(await getSwarmConfigs({ scope: "global", key }))?.[0]?.value;
|
|
701
|
+
const telemetrySetConfig = async (key: string, value: string) => {
|
|
702
|
+
await upsertSwarmConfig({ scope: "global", key, value });
|
|
703
|
+
};
|
|
704
|
+
await initTelemetry("api-server", telemetryGetConfig, telemetrySetConfig, {
|
|
705
|
+
generateIfMissing: true,
|
|
706
|
+
});
|
|
695
707
|
telemetry.server("started", { port });
|
|
708
|
+
// Org email domain (hourly + on user changes) and the daily org.snapshot.
|
|
709
|
+
startTelemetryTicker({ getConfig: telemetryGetConfig, setConfig: telemetrySetConfig });
|
|
696
710
|
if (process.env.GITHUB_TOKEN) {
|
|
697
711
|
await emitBuiltInIntegrationConnectedOnce("github");
|
|
698
712
|
}
|
package/src/http/onboarding.ts
CHANGED
|
@@ -128,10 +128,27 @@ function memoryErrorStatus(error: unknown): number | undefined {
|
|
|
128
128
|
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A 404 names the model or deployment only when the provider says so: OpenAI
|
|
133
|
+
* answers `model_not_found`, Azure `DeploymentNotFound`. A bare 404 (Azure's
|
|
134
|
+
* `Resource not found`, a proxy page) means the path itself does not exist.
|
|
135
|
+
*/
|
|
136
|
+
function notFoundNamesModel(error: unknown): boolean {
|
|
137
|
+
if (typeof error !== "object" || error === null) return false;
|
|
138
|
+
const { code, type, error: body } = error as { code?: unknown; type?: unknown; error?: unknown };
|
|
139
|
+
const bodyMessage =
|
|
140
|
+
typeof body === "object" && body !== null ? (body as { message?: unknown }).message : undefined;
|
|
141
|
+
const text = [code, type, bodyMessage]
|
|
142
|
+
.filter((part): part is string => typeof part === "string")
|
|
143
|
+
.join(" ")
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
return text.includes("model") || text.includes("deployment");
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
function classifyMemoryError(error: unknown): OnboardingErrorClass {
|
|
132
149
|
const status = memoryErrorStatus(error);
|
|
133
150
|
if (status === 401 || status === 403) return "auth";
|
|
134
|
-
if (status === 404) return "model";
|
|
151
|
+
if (status === 404) return notFoundNamesModel(error) ? "model" : "endpoint";
|
|
135
152
|
if (status === 408) return "timeout";
|
|
136
153
|
const text =
|
|
137
154
|
error instanceof Error
|
|
@@ -157,7 +174,9 @@ function memoryErrorMessage(errorClass: OnboardingErrorClass, status?: number):
|
|
|
157
174
|
case "auth":
|
|
158
175
|
return `The endpoint rejected the key${statusSuffix}.`;
|
|
159
176
|
case "model":
|
|
160
|
-
return `The endpoint could not find the embedding model${statusSuffix}.`;
|
|
177
|
+
return `The endpoint could not find the embedding model or deployment${statusSuffix}.`;
|
|
178
|
+
case "endpoint":
|
|
179
|
+
return `The endpoint path was not found${statusSuffix}. Check the base URL.`;
|
|
161
180
|
case "timeout":
|
|
162
181
|
return "The endpoint timed out.";
|
|
163
182
|
case "network":
|
package/src/http/poll.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { ensure } from "@desplega.ai/business-use";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { isApiDraining } from "../be/api-drain";
|
|
4
5
|
import { canClaim } from "../be/budget-admission";
|
|
5
6
|
import {
|
|
6
7
|
type BudgetRefusalContext,
|
|
@@ -37,7 +38,7 @@ import { poolTaskRunsOnHarness } from "../be/model-validation";
|
|
|
37
38
|
import { touchRuntimeInstance } from "../be/multi-runtime";
|
|
38
39
|
import { hasCapability } from "../server";
|
|
39
40
|
import { fetchChannelActivity } from "../slack/channel-activity";
|
|
40
|
-
import {
|
|
41
|
+
import { emitTaskTelemetry, resolveTriggerSurface } from "../telemetry-trigger";
|
|
41
42
|
import {
|
|
42
43
|
AgentTaskSchema,
|
|
43
44
|
BudgetRefusedTriggerSchema,
|
|
@@ -123,6 +124,9 @@ const PollTaskOfferedTriggerSchema = z.object({
|
|
|
123
124
|
const PollTaskAssignedTriggerSchema = z.object({
|
|
124
125
|
type: z.literal("task_assigned"),
|
|
125
126
|
taskId: z.string(),
|
|
127
|
+
// Surface that started the whole task chain (root task's source, mapped to
|
|
128
|
+
// the telemetry catalog). Workers tag their session telemetry with it.
|
|
129
|
+
triggerSurface: z.string().optional(),
|
|
126
130
|
task: AgentTaskSchema.extend({
|
|
127
131
|
attachments: z.array(PollTriggerAttachmentSchema),
|
|
128
132
|
}),
|
|
@@ -172,6 +176,9 @@ const pollTriggers = route({
|
|
|
172
176
|
path: "/api/poll",
|
|
173
177
|
pattern: ["api", "poll"],
|
|
174
178
|
summary: "Poll for triggers (tasks, mentions)",
|
|
179
|
+
description:
|
|
180
|
+
"While the API is draining after SIGTERM it dispatches nothing: the answer is `{ trigger: null }` " +
|
|
181
|
+
"and carries `X-Swarm-Draining: 1`, the signal for a worker to hand off in-flight tasks.",
|
|
175
182
|
tags: ["Poll"],
|
|
176
183
|
auth: { apiKey: true, agentId: true },
|
|
177
184
|
headers: runtimeInstanceHeader("poll for work").extend({
|
|
@@ -369,6 +376,13 @@ export async function handlePoll(
|
|
|
369
376
|
return { trigger: null };
|
|
370
377
|
}
|
|
371
378
|
|
|
379
|
+
// A draining API dispatches nothing: offers, assignments and pool claims
|
|
380
|
+
// wait for the next API. Workers learn to hand off from the
|
|
381
|
+
// X-Swarm-Draining header the HTTP pipeline adds (src/be/api-drain.ts).
|
|
382
|
+
if (isApiDraining()) {
|
|
383
|
+
return { trigger: null };
|
|
384
|
+
}
|
|
385
|
+
|
|
372
386
|
// Check for offered tasks first (highest priority for both workers and leads)
|
|
373
387
|
// Atomically claim the task for review to prevent duplicate processing.
|
|
374
388
|
// Capacity is checked in the same transaction as the claim: with
|
|
@@ -470,7 +484,7 @@ export async function handlePoll(
|
|
|
470
484
|
conditions: [{ timeout_ms: 300_000 }], // 5 min: polling interval + queue wait
|
|
471
485
|
});
|
|
472
486
|
|
|
473
|
-
|
|
487
|
+
void emitTaskTelemetry("started", {
|
|
474
488
|
taskId: pendingTask.id,
|
|
475
489
|
source: pendingTask.source,
|
|
476
490
|
agentId: myAgentId,
|
|
@@ -480,11 +494,18 @@ export async function handlePoll(
|
|
|
480
494
|
// Resolve requesting user if available (UNKNOWN sentinel handling
|
|
481
495
|
// lives in buildTriggerRequestedBy).
|
|
482
496
|
const assignedRequestedBy = await buildTriggerRequestedBy(pendingTask);
|
|
497
|
+
// The surface that started the whole chain, so the worker can tag
|
|
498
|
+
// its session events with it (workers cannot read the task tree).
|
|
499
|
+
const assignedTriggerSurface = await resolveTriggerSurface(
|
|
500
|
+
pendingTask.id,
|
|
501
|
+
pendingTask.source,
|
|
502
|
+
);
|
|
483
503
|
|
|
484
504
|
return {
|
|
485
505
|
trigger: {
|
|
486
506
|
type: "task_assigned",
|
|
487
507
|
taskId: pendingTask.id,
|
|
508
|
+
triggerSurface: assignedTriggerSurface,
|
|
488
509
|
task: {
|
|
489
510
|
...pendingTask,
|
|
490
511
|
...(await claimModelFields(pendingTask, agent)),
|
|
@@ -612,17 +633,22 @@ export async function handlePoll(
|
|
|
612
633
|
// Post-commit (see the `started` path above): a rolled-back
|
|
613
634
|
// claim must not report the task as claimed.
|
|
614
635
|
getDbClient().afterCommit(() => {
|
|
615
|
-
|
|
636
|
+
void emitTaskTelemetry("claimed", {
|
|
616
637
|
taskId: claimed.id,
|
|
617
638
|
source: claimed.source,
|
|
618
639
|
agentId: myAgentId,
|
|
619
640
|
});
|
|
620
641
|
});
|
|
621
642
|
const claimedRequestedBy = await buildTriggerRequestedBy(claimed);
|
|
643
|
+
const claimedTriggerSurface = await resolveTriggerSurface(
|
|
644
|
+
claimed.id,
|
|
645
|
+
claimed.source,
|
|
646
|
+
);
|
|
622
647
|
return {
|
|
623
648
|
trigger: {
|
|
624
649
|
type: "task_assigned",
|
|
625
650
|
taskId: claimed.id,
|
|
651
|
+
triggerSurface: claimedTriggerSurface,
|
|
626
652
|
task: {
|
|
627
653
|
...claimed,
|
|
628
654
|
...(await claimModelFields(claimed, agent)),
|
|
@@ -672,6 +698,7 @@ export async function handlePoll(
|
|
|
672
698
|
// Throttled to avoid Slack API rate limits (~50 calls/min).
|
|
673
699
|
if (
|
|
674
700
|
result.trigger === null &&
|
|
701
|
+
!isApiDraining() &&
|
|
675
702
|
process.env.LEAD_MONITOR_CHANNELS === "true" &&
|
|
676
703
|
Date.now() - lastChannelActivityCheckAt >= CHANNEL_ACTIVITY_INTERVAL_MS
|
|
677
704
|
) {
|
package/src/http/realtime.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getCombConfig } from "../comb/config";
|
|
4
|
+
import { can } from "../rbac";
|
|
2
5
|
import bundle from "../realtime/browser.generated.txt" with { type: "text" };
|
|
6
|
+
import { issueRealtimeTicket } from "../realtime/tickets";
|
|
7
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
3
8
|
import { route } from "./route-def";
|
|
9
|
+
import { jsonError } from "./utils";
|
|
10
|
+
|
|
11
|
+
const realtimeTicketRoute = route({
|
|
12
|
+
method: "post",
|
|
13
|
+
path: "/api/realtime/ticket",
|
|
14
|
+
pattern: ["api", "realtime", "ticket"],
|
|
15
|
+
summary: "Issue a one-shot dashboard realtime ticket",
|
|
16
|
+
tags: ["Rooms"],
|
|
17
|
+
rbac: { permission: "comb.presence" },
|
|
18
|
+
responses: {
|
|
19
|
+
200: {
|
|
20
|
+
description: "One-shot realtime ticket",
|
|
21
|
+
schema: z.object({ ticket: z.string(), expiresAt: z.number().int() }),
|
|
22
|
+
},
|
|
23
|
+
403: { description: "Comb presence is not allowed" },
|
|
24
|
+
404: { description: "Comb is not enabled" },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
4
27
|
|
|
5
28
|
route({
|
|
6
29
|
method: "get",
|
|
@@ -14,6 +37,45 @@ route({
|
|
|
14
37
|
},
|
|
15
38
|
});
|
|
16
39
|
|
|
40
|
+
/** `POST /api/realtime/ticket`: a one-shot ticket for a dashboard realtime socket (Comb presence). */
|
|
41
|
+
export async function handleRealtimeTicket(
|
|
42
|
+
req: IncomingMessage,
|
|
43
|
+
res: ServerResponse,
|
|
44
|
+
pathSegments: string[],
|
|
45
|
+
queryParams: URLSearchParams,
|
|
46
|
+
): Promise<boolean> {
|
|
47
|
+
if (!realtimeTicketRoute.match(req.method, pathSegments)) return false;
|
|
48
|
+
// Comb presence is the only ticket consumer, so the route is off with Comb.
|
|
49
|
+
if (!getCombConfig().enabled) {
|
|
50
|
+
jsonError(res, "Comb is not enabled", 404);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const parsed = await realtimeTicketRoute.parse(req, res, pathSegments, queryParams);
|
|
54
|
+
if (!parsed) return true;
|
|
55
|
+
const auth = getRequestAuth(req);
|
|
56
|
+
if (!auth || auth.kind === "agent") {
|
|
57
|
+
jsonError(res, "Comb presence requires dashboard authentication", 403);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const principal =
|
|
61
|
+
auth.kind === "operator"
|
|
62
|
+
? ({ kind: "operator" } as const)
|
|
63
|
+
: ({ kind: "user", userId: auth.userId } as const);
|
|
64
|
+
if (
|
|
65
|
+
!can({
|
|
66
|
+
principal,
|
|
67
|
+
verb: "comb.presence",
|
|
68
|
+
resource: { kind: "none" },
|
|
69
|
+
source: "http",
|
|
70
|
+
}).allow
|
|
71
|
+
) {
|
|
72
|
+
jsonError(res, "Comb presence requires the comb.presence permission", 403);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
realtimeTicketRoute.respond(res, 200, issueRealtimeTicket(auth));
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
17
79
|
export async function handleRealtimeAsset(
|
|
18
80
|
req: IncomingMessage,
|
|
19
81
|
res: ServerResponse,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { getAgentById } from "../be/db";
|
|
3
|
+
import type { RbacPrincipal } from "../rbac";
|
|
4
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The RBAC principal of an HTTP request, for a handler-side `can()` check.
|
|
8
|
+
* Operator and user request auth win over the X-Agent-ID header (an operator
|
|
9
|
+
* bearer with any agent id stays the operator). An agent id that is not
|
|
10
|
+
* registered is a non-lead agent. Null when the request has neither.
|
|
11
|
+
*/
|
|
12
|
+
export async function requestPrincipal(
|
|
13
|
+
req: IncomingMessage,
|
|
14
|
+
myAgentId: string | undefined,
|
|
15
|
+
): Promise<RbacPrincipal | null> {
|
|
16
|
+
const auth = getRequestAuth(req);
|
|
17
|
+
if (auth?.kind === "operator") return { kind: "operator" };
|
|
18
|
+
if (auth?.kind === "user") return { kind: "user", userId: auth.userId };
|
|
19
|
+
if (!myAgentId) return null;
|
|
20
|
+
const agent = await getAgentById(myAgentId);
|
|
21
|
+
return { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
|
|
22
|
+
}
|