@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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import * as db from "../be/db";
|
|
5
|
+
import {
|
|
6
|
+
cancelTask,
|
|
7
|
+
closeDb,
|
|
8
|
+
completeTask,
|
|
9
|
+
createWorkflow,
|
|
10
|
+
failTask,
|
|
11
|
+
getTaskById,
|
|
12
|
+
getTaskByWorkflowRunStepId,
|
|
13
|
+
getWorkflowRun,
|
|
14
|
+
getWorkflowRunStepsByRunId,
|
|
15
|
+
initDb,
|
|
16
|
+
} from "../be/db";
|
|
17
|
+
import type { AgentTask, WorkflowDefinition, WorkflowRunStep } from "../types";
|
|
18
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
19
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
20
|
+
import { AgentTaskExecutor } from "../workflows/executors/agent-task";
|
|
21
|
+
import {
|
|
22
|
+
BaseExecutor,
|
|
23
|
+
type ExecutorDependencies,
|
|
24
|
+
type ExecutorResult,
|
|
25
|
+
} from "../workflows/executors/base";
|
|
26
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
27
|
+
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
28
|
+
import { setupWorkflowResumeListener } from "../workflows/resume";
|
|
29
|
+
import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
|
|
30
|
+
import { interpolate } from "../workflows/template";
|
|
31
|
+
|
|
32
|
+
const TEST_DB_PATH = "./test-workflow-agent-task-retry.sqlite";
|
|
33
|
+
|
|
34
|
+
class NotifyStubExecutor extends BaseExecutor<
|
|
35
|
+
typeof NotifyStubExecutor.schema,
|
|
36
|
+
typeof NotifyStubExecutor.outSchema
|
|
37
|
+
> {
|
|
38
|
+
static readonly schema = z.object({ channel: z.string(), template: z.string() });
|
|
39
|
+
static readonly outSchema = z.object({ sent: z.boolean() });
|
|
40
|
+
|
|
41
|
+
readonly type = "notify";
|
|
42
|
+
readonly mode = "instant" as const;
|
|
43
|
+
readonly configSchema = NotifyStubExecutor.schema;
|
|
44
|
+
readonly outputSchema = NotifyStubExecutor.outSchema;
|
|
45
|
+
|
|
46
|
+
protected async execute(): Promise<ExecutorResult<z.infer<typeof NotifyStubExecutor.outSchema>>> {
|
|
47
|
+
return { status: "success", output: { sent: true } };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const deps: ExecutorDependencies = {
|
|
52
|
+
db: db as typeof import("../be/db"),
|
|
53
|
+
eventBus: workflowEventBus,
|
|
54
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let registry: ExecutorRegistry;
|
|
58
|
+
let teardownResumeListener: (() => void) | undefined;
|
|
59
|
+
|
|
60
|
+
beforeAll(() => {
|
|
61
|
+
initDb(TEST_DB_PATH);
|
|
62
|
+
registry = new ExecutorRegistry();
|
|
63
|
+
registry.register(new NotifyStubExecutor(deps));
|
|
64
|
+
registry.register(new AgentTaskExecutor(deps));
|
|
65
|
+
teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
stopRetryPoller();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
// Detach from the process-wide singleton bus (see setupWorkflowResumeListener).
|
|
74
|
+
teardownResumeListener?.();
|
|
75
|
+
stopRetryPoller();
|
|
76
|
+
closeDb();
|
|
77
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
78
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
79
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let counter = 0;
|
|
83
|
+
|
|
84
|
+
/** synthesize (agent-task) → done (notify); `retry` lands on the node itself. */
|
|
85
|
+
async function startRun(opts: {
|
|
86
|
+
retry?: WorkflowDefinition["nodes"][number]["retry"];
|
|
87
|
+
onNodeFailure?: "fail" | "continue";
|
|
88
|
+
}): Promise<string> {
|
|
89
|
+
counter++;
|
|
90
|
+
const workflow = await createWorkflow({
|
|
91
|
+
name: `agent-task-retry-${counter}-${Date.now()}`,
|
|
92
|
+
definition: {
|
|
93
|
+
...(opts.onNodeFailure ? { onNodeFailure: opts.onNodeFailure } : {}),
|
|
94
|
+
nodes: [
|
|
95
|
+
{
|
|
96
|
+
id: "synthesize",
|
|
97
|
+
type: "agent-task",
|
|
98
|
+
config: { template: "Synthesize" },
|
|
99
|
+
...(opts.retry ? { retry: opts.retry } : {}),
|
|
100
|
+
next: "done",
|
|
101
|
+
},
|
|
102
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "done" } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
107
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
108
|
+
return runId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function synthesizeStep(runId: string): Promise<WorkflowRunStep> {
|
|
112
|
+
const steps = await getWorkflowRunStepsByRunId(runId);
|
|
113
|
+
return steps.find((s) => s.nodeId === "synthesize")!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function waitFor<T>(
|
|
117
|
+
probe: () => Promise<T | null | undefined | false>,
|
|
118
|
+
label: string,
|
|
119
|
+
timeoutMs = 3000,
|
|
120
|
+
): Promise<T> {
|
|
121
|
+
const deadline = Date.now() + timeoutMs;
|
|
122
|
+
while (Date.now() < deadline) {
|
|
123
|
+
const value = await probe();
|
|
124
|
+
if (value) return value;
|
|
125
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Wait until a task other than `previous` is bound to the step. */
|
|
131
|
+
async function waitForRedrive(stepId: string, previous: AgentTask): Promise<AgentTask> {
|
|
132
|
+
return waitFor(async () => {
|
|
133
|
+
const bound = await getTaskByWorkflowRunStepId(stepId);
|
|
134
|
+
return bound && bound.id !== previous.id ? bound : null;
|
|
135
|
+
}, "redriven task");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const RETRY_ONCE = { maxRetries: 1, strategy: "static" as const, baseDelayMs: 0, maxDelayMs: 0 };
|
|
139
|
+
|
|
140
|
+
describe("agent-task node.retry on task failure", () => {
|
|
141
|
+
test("a superseded task with retry {maxRetries:1} is redriven once and the run continues", async () => {
|
|
142
|
+
const runId = await startRun({ retry: RETRY_ONCE });
|
|
143
|
+
const step = await synthesizeStep(runId);
|
|
144
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
145
|
+
|
|
146
|
+
await failTask(first.id, "superseded_workflow_task");
|
|
147
|
+
|
|
148
|
+
// Queued for the poller: step failed with nextRetryAt, run not failed,
|
|
149
|
+
// failed task detached so the executor creates a fresh one.
|
|
150
|
+
const queued = await waitFor(async () => {
|
|
151
|
+
const s = await synthesizeStep(runId);
|
|
152
|
+
return s.status === "failed" && s.nextRetryAt != null ? s : null;
|
|
153
|
+
}, "step queued for retry");
|
|
154
|
+
expect(queued.retryCount).toBe(1);
|
|
155
|
+
expect(queued.error).toBe("superseded_workflow_task");
|
|
156
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
157
|
+
expect((await getTaskById(first.id))!.workflowRunStepId).toBeFalsy();
|
|
158
|
+
|
|
159
|
+
startRetryPoller(registry, 10);
|
|
160
|
+
const second = await waitForRedrive(step.id, first);
|
|
161
|
+
expect(second.status).not.toBe("failed");
|
|
162
|
+
const waiting = await waitFor(async () => {
|
|
163
|
+
const s = await synthesizeStep(runId);
|
|
164
|
+
return s.status === "waiting" ? s : null;
|
|
165
|
+
}, "step waiting on redriven task");
|
|
166
|
+
expect(waiting.id).toBe(step.id);
|
|
167
|
+
stopRetryPoller();
|
|
168
|
+
|
|
169
|
+
await completeTask(second.id, "synthesis done");
|
|
170
|
+
const run = await waitFor(async () => {
|
|
171
|
+
const r = await getWorkflowRun(runId);
|
|
172
|
+
return r?.status === "completed" ? r : null;
|
|
173
|
+
}, "run completed");
|
|
174
|
+
expect(run.status).toBe("completed");
|
|
175
|
+
const steps = await getWorkflowRunStepsByRunId(runId);
|
|
176
|
+
expect(steps.filter((s) => s.nodeId === "synthesize")).toHaveLength(1);
|
|
177
|
+
expect(steps.find((s) => s.nodeId === "done")?.status).toBe("completed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a second failure exhausts retries and falls through to onNodeFailure", async () => {
|
|
181
|
+
const runId = await startRun({ retry: RETRY_ONCE, onNodeFailure: "continue" });
|
|
182
|
+
const step = await synthesizeStep(runId);
|
|
183
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
184
|
+
|
|
185
|
+
await failTask(first.id, "superseded_workflow_task");
|
|
186
|
+
startRetryPoller(registry, 10);
|
|
187
|
+
const second = await waitForRedrive(step.id, first);
|
|
188
|
+
await waitFor(async () => {
|
|
189
|
+
const s = await synthesizeStep(runId);
|
|
190
|
+
return s.status === "waiting" ? s : null;
|
|
191
|
+
}, "step waiting on redriven task");
|
|
192
|
+
stopRetryPoller();
|
|
193
|
+
|
|
194
|
+
// A late duplicate event for the FIRST task must not consume the retry
|
|
195
|
+
// budget or touch the step now bound to the second task.
|
|
196
|
+
workflowEventBus.emit("task.failed", {
|
|
197
|
+
taskId: first.id,
|
|
198
|
+
failureReason: "duplicate",
|
|
199
|
+
workflowRunId: runId,
|
|
200
|
+
workflowRunStepId: step.id,
|
|
201
|
+
});
|
|
202
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
203
|
+
expect((await synthesizeStep(runId)).status).toBe("waiting");
|
|
204
|
+
|
|
205
|
+
await failTask(second.id, "superseded_workflow_task");
|
|
206
|
+
const run = await waitFor(async () => {
|
|
207
|
+
const r = await getWorkflowRun(runId);
|
|
208
|
+
return r?.status === "completed" ? r : null;
|
|
209
|
+
}, "run completed via onNodeFailure:continue");
|
|
210
|
+
expect(run.status).toBe("completed");
|
|
211
|
+
const finalStep = await synthesizeStep(runId);
|
|
212
|
+
expect(finalStep.status).toBe("completed");
|
|
213
|
+
expect(finalStep.retryCount).toBe(1);
|
|
214
|
+
expect(finalStep.nextRetryAt).toBeFalsy();
|
|
215
|
+
expect(JSON.stringify(finalStep.output)).toContain("[FAILED:");
|
|
216
|
+
// No third task was ever created.
|
|
217
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a node without retry fails the run exactly as before", async () => {
|
|
221
|
+
const runId = await startRun({});
|
|
222
|
+
const step = await synthesizeStep(runId);
|
|
223
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
224
|
+
|
|
225
|
+
await failTask(first.id, "superseded_workflow_task");
|
|
226
|
+
|
|
227
|
+
const run = await waitFor(async () => {
|
|
228
|
+
const r = await getWorkflowRun(runId);
|
|
229
|
+
return r?.status === "failed" ? r : null;
|
|
230
|
+
}, "run failed");
|
|
231
|
+
expect(run.error).toContain("superseded_workflow_task");
|
|
232
|
+
const failedStep = await synthesizeStep(runId);
|
|
233
|
+
expect(failedStep.status).toBe("failed");
|
|
234
|
+
expect(failedStep.retryCount).toBe(0);
|
|
235
|
+
expect(failedStep.nextRetryAt).toBeFalsy();
|
|
236
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a cancelled task never retries, even with retry set", async () => {
|
|
240
|
+
const runId = await startRun({ retry: RETRY_ONCE });
|
|
241
|
+
const step = await synthesizeStep(runId);
|
|
242
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
243
|
+
|
|
244
|
+
await cancelTask(first.id, "operator cancel");
|
|
245
|
+
|
|
246
|
+
const run = await waitFor(async () => {
|
|
247
|
+
const r = await getWorkflowRun(runId);
|
|
248
|
+
return r?.status === "failed" ? r : null;
|
|
249
|
+
}, "run failed");
|
|
250
|
+
expect(run.error).toContain("cancelled");
|
|
251
|
+
const failedStep = await synthesizeStep(runId);
|
|
252
|
+
expect(failedStep.retryCount).toBe(0);
|
|
253
|
+
expect(failedStep.nextRetryAt).toBeFalsy();
|
|
254
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("the recovery sweep applies the same retry when it sees the failed task first", async () => {
|
|
258
|
+
const runId = await startRun({ retry: RETRY_ONCE });
|
|
259
|
+
const step = await synthesizeStep(runId);
|
|
260
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
261
|
+
|
|
262
|
+
// Detach the live listener so only the heartbeat sweep sees the failure.
|
|
263
|
+
teardownResumeListener?.();
|
|
264
|
+
try {
|
|
265
|
+
await failTask(first.id, "superseded_workflow_task");
|
|
266
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
267
|
+
await recoverIncompleteRuns(registry);
|
|
268
|
+
} finally {
|
|
269
|
+
teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const queued = await synthesizeStep(runId);
|
|
273
|
+
expect(queued.status).toBe("failed");
|
|
274
|
+
expect(queued.nextRetryAt).toBeTruthy();
|
|
275
|
+
expect(queued.retryCount).toBe(1);
|
|
276
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
277
|
+
|
|
278
|
+
startRetryPoller(registry, 10);
|
|
279
|
+
const second = await waitForRedrive(step.id, first);
|
|
280
|
+
expect(second.id).not.toBe(first.id);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("recovery sweep holding a stale failed-task snapshot", () => {
|
|
285
|
+
/**
|
|
286
|
+
* Recovery snapshots task A as failed, then pauses. Meanwhile the live
|
|
287
|
+
* task.failed handler queues A's retry and the poller binds task B to the
|
|
288
|
+
* same step, back in `waiting`. When recovery resumes on its stale A row it
|
|
289
|
+
* must leave the step bound to B alone.
|
|
290
|
+
*/
|
|
291
|
+
async function raceStaleRecovery(maxRetries: number) {
|
|
292
|
+
const runId = await startRun({ retry: { ...RETRY_ONCE, maxRetries } });
|
|
293
|
+
const step = await synthesizeStep(runId);
|
|
294
|
+
const first = (await getTaskByWorkflowRunStepId(step.id))!;
|
|
295
|
+
|
|
296
|
+
let releaseSnapshot!: () => void;
|
|
297
|
+
const snapshotHeld = new Promise<void>((r) => {
|
|
298
|
+
releaseSnapshot = r;
|
|
299
|
+
});
|
|
300
|
+
let snapshotTaken!: () => void;
|
|
301
|
+
const snapshotReady = new Promise<void>((r) => {
|
|
302
|
+
snapshotTaken = r;
|
|
303
|
+
});
|
|
304
|
+
const realGetStuck = db.getStuckWorkflowRuns;
|
|
305
|
+
const spy = spyOn(db, "getStuckWorkflowRuns").mockImplementation(async () => {
|
|
306
|
+
const rows = await realGetStuck();
|
|
307
|
+
snapshotTaken();
|
|
308
|
+
await snapshotHeld;
|
|
309
|
+
return rows;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
let recovery: Promise<unknown> | undefined;
|
|
313
|
+
try {
|
|
314
|
+
// Fail A with the listener detached so the sweep's snapshot sees it.
|
|
315
|
+
teardownResumeListener?.();
|
|
316
|
+
try {
|
|
317
|
+
await failTask(first.id, "superseded_workflow_task");
|
|
318
|
+
recovery = recoverIncompleteRuns(registry);
|
|
319
|
+
await snapshotReady;
|
|
320
|
+
} finally {
|
|
321
|
+
teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The live event for A arrives late: retry queued, B dispatched.
|
|
325
|
+
workflowEventBus.emit("task.failed", {
|
|
326
|
+
taskId: first.id,
|
|
327
|
+
failureReason: "superseded_workflow_task",
|
|
328
|
+
workflowRunId: runId,
|
|
329
|
+
workflowRunStepId: step.id,
|
|
330
|
+
});
|
|
331
|
+
startRetryPoller(registry, 10);
|
|
332
|
+
const second = await waitForRedrive(step.id, first);
|
|
333
|
+
await waitFor(async () => {
|
|
334
|
+
const s = await synthesizeStep(runId);
|
|
335
|
+
return s.status === "waiting" ? s : null;
|
|
336
|
+
}, "step waiting on redriven task");
|
|
337
|
+
stopRetryPoller();
|
|
338
|
+
|
|
339
|
+
releaseSnapshot();
|
|
340
|
+
await recovery;
|
|
341
|
+
recovery = undefined;
|
|
342
|
+
|
|
343
|
+
const after = await synthesizeStep(runId);
|
|
344
|
+
expect(after.status).toBe("waiting");
|
|
345
|
+
expect(after.retryCount).toBe(1);
|
|
346
|
+
expect(after.nextRetryAt).toBeFalsy();
|
|
347
|
+
expect((await getWorkflowRun(runId))!.status).toBe("waiting");
|
|
348
|
+
expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
|
|
349
|
+
} finally {
|
|
350
|
+
releaseSnapshot();
|
|
351
|
+
await recovery;
|
|
352
|
+
spy.mockRestore();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
test("with retries left, the stale snapshot does not consume another retry", async () => {
|
|
357
|
+
await raceStaleRecovery(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("with retries exhausted, the stale snapshot does not route onNodeFailure", async () => {
|
|
361
|
+
await raceStaleRecovery(1);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -44,7 +44,7 @@ describe("workflow definition telemetry", () => {
|
|
|
44
44
|
await removeTestDb();
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test("emits workflow.created with definition size and known
|
|
47
|
+
test("emits workflow.created with definition size and known interface (via)", async () => {
|
|
48
48
|
const workflow = await createWorkflow(
|
|
49
49
|
{
|
|
50
50
|
name: "created telemetry",
|
|
@@ -65,7 +65,7 @@ describe("workflow definition telemetry", () => {
|
|
|
65
65
|
props: {
|
|
66
66
|
workflowId: workflow.id,
|
|
67
67
|
nodeCount: 2,
|
|
68
|
-
|
|
68
|
+
via: "api",
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
]);
|
|
@@ -91,13 +91,13 @@ describe("workflow definition telemetry", () => {
|
|
|
91
91
|
event: "deleted",
|
|
92
92
|
props: {
|
|
93
93
|
workflowId: workflow.id,
|
|
94
|
-
|
|
94
|
+
via: "mcp",
|
|
95
95
|
},
|
|
96
96
|
},
|
|
97
97
|
]);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
test("omits
|
|
100
|
+
test("omits via for direct internal workflow mutations", async () => {
|
|
101
101
|
const workflow = await createWorkflow({
|
|
102
102
|
name: "internal telemetry",
|
|
103
103
|
definition: { nodes: [] },
|
package/src/tools/poll-task.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { addMinutes } from "date-fns";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { isApiDraining } from "@/be/api-drain";
|
|
4
5
|
import {
|
|
5
6
|
getActiveTaskCount,
|
|
6
7
|
getAgentById,
|
|
@@ -132,7 +133,9 @@ export const registerPollTaskTool = (server: McpServer) => {
|
|
|
132
133
|
while (new Date() < maxTime) {
|
|
133
134
|
// Fetch and update in a single transaction to avoid race conditions
|
|
134
135
|
const outcome = await getDbClient().transaction(
|
|
135
|
-
async (): Promise<
|
|
136
|
+
async (): Promise<
|
|
137
|
+
AgentTask | "at-capacity" | "runtime-unavailable" | "draining" | null
|
|
138
|
+
> => {
|
|
136
139
|
// The entry gate only proves liveness when the long poll began;
|
|
137
140
|
// the runtime must be live at the exact moment work is acquired,
|
|
138
141
|
// so revalidate in the same transaction that can start the task.
|
|
@@ -147,6 +150,9 @@ export const registerPollTaskTool = (server: McpServer) => {
|
|
|
147
150
|
return "runtime-unavailable";
|
|
148
151
|
}
|
|
149
152
|
|
|
153
|
+
// A draining API dispatches nothing; see src/be/api-drain.ts.
|
|
154
|
+
if (isApiDraining()) return "draining";
|
|
155
|
+
|
|
150
156
|
const agentNow = (await getAgentById(agentId))!;
|
|
151
157
|
|
|
152
158
|
if (agentNow.status !== "busy") {
|
|
@@ -188,6 +194,19 @@ export const registerPollTaskTool = (server: McpServer) => {
|
|
|
188
194
|
});
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
if (outcome === "draining") {
|
|
198
|
+
// Like a runtime refusal, this is not an empty poll: skip the exit counter.
|
|
199
|
+
return toolOk("No task available.", {
|
|
200
|
+
details: "The API is draining for a restart and dispatches no new work. Retry shortly.",
|
|
201
|
+
data: {
|
|
202
|
+
yourAgentId: requestInfo.agentId,
|
|
203
|
+
offeredTasks: [],
|
|
204
|
+
availableCount: 0,
|
|
205
|
+
waitedForSeconds: Math.round((Date.now() - now.getTime()) / 1000),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
if (outcome === "at-capacity") {
|
|
192
211
|
// A capacity refusal is not an empty poll (refused ≠ empty, D-R3):
|
|
193
212
|
// return without advancing the exit counter.
|
package/src/tools/send-task.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getUserById,
|
|
16
16
|
hasCapacity,
|
|
17
17
|
isExtensionAgent,
|
|
18
|
+
isLinearTrackerContextKey,
|
|
18
19
|
} from "@/be/db";
|
|
19
20
|
import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
|
|
20
21
|
import { explicitModelErrorForAgent } from "@/be/model-validation";
|
|
@@ -467,11 +468,12 @@ export async function sendTaskHandler(
|
|
|
467
468
|
} | null> => {
|
|
468
469
|
const existingTrackerWork = await findExistingLinearTrackerContextWork(
|
|
469
470
|
effectiveParentTask?.contextKey,
|
|
471
|
+
effectiveParentTask?.id,
|
|
470
472
|
);
|
|
471
473
|
if (existingTrackerWork) {
|
|
472
474
|
const msg = `Skipped: Linear tracker contextKey ${effectiveParentTask?.contextKey} already has ${existingTrackerWork.reason === "active_task" ? "active task" : "linked open PR"} ${existingTrackerWork.task.id.slice(0, 8)}.`;
|
|
473
475
|
console.log(`[send-task] ${msg}`);
|
|
474
|
-
return { ok:
|
|
476
|
+
return { ok: false, message: msg, task: existingTrackerWork.task };
|
|
475
477
|
}
|
|
476
478
|
|
|
477
479
|
// Dedup guard: check for similar recent tasks
|
|
@@ -559,6 +561,12 @@ export async function sendTaskHandler(
|
|
|
559
561
|
const raced = await evaluateDedupGuards();
|
|
560
562
|
if (raced) return { success: raced.ok, message: raced.message, task: raced.task };
|
|
561
563
|
|
|
564
|
+
// This transaction already checked the tracker key, excluding the caller's
|
|
565
|
+
// lineage. The creation guard would otherwise match work in that lineage.
|
|
566
|
+
if (isLinearTrackerContextKey(effectiveParentTask?.contextKey)) {
|
|
567
|
+
taskOptions.bypassTrackerContextDedup = true;
|
|
568
|
+
}
|
|
569
|
+
|
|
562
570
|
// If no agentId (and no auto-routed agentId), create an unassigned task for the pool
|
|
563
571
|
const targetAgentId = taskOptions.offeredTo ?? taskOptions.agentId ?? undefined;
|
|
564
572
|
if (!targetAgentId) {
|
package/src/tools/task-action.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { isApiDraining } from "@/be/api-drain";
|
|
3
4
|
import { AssetKeyAuthorizationError, authorizeAssetKeyWrite } from "@/be/asset-key-auth";
|
|
4
5
|
import { resolveTaskAuditUserId } from "@/be/audit-user";
|
|
5
6
|
import { canClaim } from "@/be/budget-admission";
|
|
@@ -177,6 +178,14 @@ async function staleRuntimeResult(ctx: ToolCtx, agentId: string): Promise<TaskAc
|
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
/** A draining API dispatches nothing (src/be/api-drain.ts); claim and accept wait for the next API. */
|
|
182
|
+
function apiDrainingResult(action: "claim" | "accept"): TaskActionResult {
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
message: `The API is draining for a restart and dispatches no new work, so this ${action} was not made. Retry shortly.`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
180
189
|
function taskActionResult(result: TaskActionResult, agentId?: string): SwarmToolResult {
|
|
181
190
|
const { refusalSideEffects: _omit, success, message, ...rest } = result;
|
|
182
191
|
const data = {
|
|
@@ -353,6 +362,7 @@ export async function taskActionHandler(
|
|
|
353
362
|
}
|
|
354
363
|
const staleClaimRuntime = await staleRuntimeResult(ctx, agentId);
|
|
355
364
|
if (staleClaimRuntime) return staleClaimRuntime;
|
|
365
|
+
if (isApiDraining()) return apiDrainingResult("claim");
|
|
356
366
|
// Check capacity before claiming
|
|
357
367
|
if (!(await hasCapacity(agentId))) {
|
|
358
368
|
const activeCount = await getActiveTaskCount(agentId);
|
|
@@ -462,6 +472,7 @@ export async function taskActionHandler(
|
|
|
462
472
|
// budget admission below so a retired process cannot take the offer.
|
|
463
473
|
const staleAcceptRuntime = await staleRuntimeResult(ctx, agentId);
|
|
464
474
|
if (staleAcceptRuntime) return staleAcceptRuntime;
|
|
475
|
+
if (isApiDraining()) return apiDrainingResult("accept");
|
|
465
476
|
const existingTask = await getTaskById(taskId);
|
|
466
477
|
if (!existingTask) {
|
|
467
478
|
return { success: false, message: `Task "${taskId}" not found.` };
|
package/src/types.ts
CHANGED
|
@@ -364,6 +364,7 @@ export const AgentTaskSourceSchema = z.enum([
|
|
|
364
364
|
"workflow",
|
|
365
365
|
"linear",
|
|
366
366
|
"jira",
|
|
367
|
+
"comb",
|
|
367
368
|
]);
|
|
368
369
|
export type AgentTaskSource = z.infer<typeof AgentTaskSourceSchema>;
|
|
369
370
|
|
|
@@ -1045,7 +1046,7 @@ export type InboxItemState = z.infer<typeof InboxItemStateSchema>;
|
|
|
1045
1046
|
// User Favorites (principal-scoped stars for app navigation)
|
|
1046
1047
|
// ============================================================================
|
|
1047
1048
|
|
|
1048
|
-
export const FavoriteItemTypeSchema = z.enum(["page", "workflow", "schedule"]);
|
|
1049
|
+
export const FavoriteItemTypeSchema = z.enum(["page", "workflow", "schedule", "agent-fs-path"]);
|
|
1049
1050
|
export type FavoriteItemType = z.infer<typeof FavoriteItemTypeSchema>;
|
|
1050
1051
|
|
|
1051
1052
|
export const UserFavoriteSchema = z
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract for the API drain, shared by the API server and the workers.
|
|
3
|
+
* Keep this file free of DB and server imports: `src/commands/` reads it.
|
|
4
|
+
*
|
|
5
|
+
* While the API is draining (SIGTERM received, still serving) it adds
|
|
6
|
+
* `X-Swarm-Draining: 1` to every response. A worker that sees the header hands
|
|
7
|
+
* off its in-flight tasks and stops taking new ones. An API that never drains
|
|
8
|
+
* never sends the header, so older and newer sides interoperate unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const API_DRAINING_HEADER = "X-Swarm-Draining";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Upper bound for `API_DRAIN_MAX_MS`. The config validator and the resolver in
|
|
15
|
+
* `src/be/api-drain.ts` both read it, so the config API cannot accept a value
|
|
16
|
+
* the shutdown path would clamp. Keep the API's `stop_grace_period` above the
|
|
17
|
+
* cap plus the time the API needs to close.
|
|
18
|
+
*/
|
|
19
|
+
export const API_DRAIN_MAX_MS_LIMIT = 120_000;
|
|
20
|
+
|
|
21
|
+
/** True when an API response carries the drain header. */
|
|
22
|
+
export function isApiDrainingResponse(response: { headers: Headers }): boolean {
|
|
23
|
+
return response.headers.get(API_DRAINING_HEADER) === "1";
|
|
24
|
+
}
|
package/src/utils/constants.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Shared constants used across worker- and server-side code.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { isEnvFlagEnabled } from "./env-flag";
|
|
6
|
+
|
|
5
7
|
/** Maximum profile-file length in JavaScript characters (not UTF-8 bytes). */
|
|
6
8
|
export const MAX_PROFILE_FILE_LENGTH = 256 * 1024;
|
|
7
9
|
|
|
@@ -106,20 +108,24 @@ export function getAgentFsDefaultDriveId(): string | undefined {
|
|
|
106
108
|
return raw || undefined;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
* info — `path` plus a matched `orgId`/`driveId` pair. When a row supplies
|
|
112
|
-
* neither ID, the pair may come from env-var defaults. A partial row never
|
|
113
|
-
* mixes its ID with a default; callers fall back to the raw
|
|
114
|
-
* `agent-fs:<path>` display instead.
|
|
115
|
-
*
|
|
116
|
-
* Shape: ${liveHost}/file/~/<orgId>/<driveId>/<normalized-path>
|
|
117
|
-
*/
|
|
118
|
-
export function buildAgentFsLiveUrl(opts: {
|
|
111
|
+
/** An agent-fs file for a link: its path plus an optional org/drive pair. */
|
|
112
|
+
export interface AgentFsFileRef {
|
|
119
113
|
path?: string | null;
|
|
120
114
|
orgId?: string | null;
|
|
121
115
|
driveId?: string | null;
|
|
122
|
-
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A "." or ".." path segment, raw or encoded (`%2e`). */
|
|
119
|
+
const DOT_SEGMENT_RE = /^(?:\.|%2e){1,2}$/i;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `<orgId>/<driveId>/<encoded-path>` for an agent-fs file link, when we have
|
|
123
|
+
* enough info: `path` plus a matched `orgId`/`driveId` pair. When a row
|
|
124
|
+
* supplies neither ID, the pair may come from env-var defaults. A partial row
|
|
125
|
+
* never mixes its ID with a default. A "." or ".." segment returns null: the
|
|
126
|
+
* browser would resolve it to a path outside the drive.
|
|
127
|
+
*/
|
|
128
|
+
export function agentFsFileRoute(opts: AgentFsFileRef): string | null {
|
|
123
129
|
const path = opts.path?.trim();
|
|
124
130
|
if (!path) return null;
|
|
125
131
|
const rowOrgId = opts.orgId?.trim();
|
|
@@ -128,13 +134,48 @@ export function buildAgentFsLiveUrl(opts: {
|
|
|
128
134
|
const orgId = hasRowId ? rowOrgId : getAgentFsDefaultOrgId();
|
|
129
135
|
const driveId = hasRowId ? rowDriveId : getAgentFsDefaultDriveId();
|
|
130
136
|
if (!orgId || !driveId) return null;
|
|
131
|
-
const
|
|
132
|
-
|
|
137
|
+
const segments = path.replace(/^\/+/, "").split("/");
|
|
138
|
+
if (segments.some((segment) => DOT_SEGMENT_RE.test(segment))) return null;
|
|
133
139
|
// Never double-encode an already-encoded segment: preserve existing %HH
|
|
134
140
|
// escapes byte-for-byte, including in segments that also contain raw text.
|
|
135
|
-
const encodedPath =
|
|
136
|
-
.split("/")
|
|
141
|
+
const encodedPath = segments
|
|
137
142
|
.map((segment) => encodeURIComponent(segment).replace(/%25([0-9a-f]{2})/gi, "%$1"))
|
|
138
143
|
.join("/");
|
|
139
|
-
return `${
|
|
144
|
+
return `${orgId}/${driveId}/${encodedPath}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a public agent-fs live URL for an attachment (see
|
|
149
|
+
* {@link agentFsFileRoute} for the id, dot-segment, and encoding rules). Null
|
|
150
|
+
* when that route is null; callers fall back to the raw `agent-fs:<path>`
|
|
151
|
+
* display instead.
|
|
152
|
+
*
|
|
153
|
+
* Shape: ${liveHost}/file/~/<orgId>/<driveId>/<normalized-path>
|
|
154
|
+
*/
|
|
155
|
+
export function buildAgentFsLiveUrl(opts: AgentFsFileRef): string | null {
|
|
156
|
+
const route = agentFsFileRoute(opts);
|
|
157
|
+
return route ? `${getAgentFsLiveUrl()}/file/~/${route}` : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Comb (the agent-fs review space in the dashboard) is on: `COMB_ENABLED` plus
|
|
162
|
+
* an agent-fs API URL. `getCombConfig()` (`src/comb/config.ts`) reports it.
|
|
163
|
+
*/
|
|
164
|
+
export function isCombEnabled(): boolean {
|
|
165
|
+
const apiUrl = process.env.AGENT_FS_API_URL?.trim().replace(/\/+$/, "");
|
|
166
|
+
return isEnvFlagEnabled("COMB_ENABLED", false) && Boolean(apiUrl);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The dashboard (Comb) URL for an agent-fs file, same id and encoding rules as
|
|
171
|
+
* {@link buildAgentFsLiveUrl}. Null unless Comb is on AND an explicit
|
|
172
|
+
* `APP_URL` / `DASHBOARD_URL` is set: the hosted default dashboard cannot open
|
|
173
|
+
* a self-hosted swarm's files.
|
|
174
|
+
*
|
|
175
|
+
* Shape: ${appUrl}/file/~/<orgId>/<driveId>/<normalized-path>
|
|
176
|
+
*/
|
|
177
|
+
export function buildCombFileUrl(opts: AgentFsFileRef): string | null {
|
|
178
|
+
if (!isCombEnabled() || getConfiguredAppUrls().length === 0) return null;
|
|
179
|
+
const route = agentFsFileRoute(opts);
|
|
180
|
+
return route ? `${getAppUrl()}/file/~/${route}` : null;
|
|
140
181
|
}
|