@desplega.ai/agent-swarm 1.90.0 → 1.92.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.
Files changed (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -7
@@ -0,0 +1,555 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ countActiveScriptRuns,
5
+ countScriptRunJournalAgentTaskSteps,
6
+ countScriptRunJournalSteps,
7
+ countScriptRuns,
8
+ createScriptRun,
9
+ createTaskExtended,
10
+ getAgentById,
11
+ getLatestTaskByContextKey,
12
+ getScriptRun,
13
+ getScriptRunByIdempotencyKey,
14
+ getScriptRunJournalStep,
15
+ listScriptRunJournalSteps,
16
+ listScriptRuns,
17
+ updateScriptRun,
18
+ upsertScriptRunJournalStep,
19
+ } from "../be/db";
20
+ import { lintWorkflowLabels } from "../script-workflows/label-lint";
21
+ import { scriptRunMaxAgentTasks, scriptRunMaxSteps } from "../script-workflows/limits";
22
+ import {
23
+ abortScriptRunLimit,
24
+ startScriptRunProcess,
25
+ terminateScriptRunProcess,
26
+ } from "../script-workflows/supervisor";
27
+ import { ScriptRunStatusSchema, TERMINAL_SCRIPT_RUN_STATUSES } from "../types";
28
+ import { getAppUrl } from "../utils/constants";
29
+ import { executeRawLlm, RawLlmConfigSchema } from "../workflows/executors/raw-llm";
30
+ import { route } from "./route-def";
31
+ import { deriveApiBaseUrl, json, jsonError } from "./utils";
32
+
33
+ const DEFAULT_SCRIPT_RUN_CONCURRENCY_CAP = 10;
34
+
35
+ const runIdParamsSchema = z.object({ runId: z.string().uuid() });
36
+ const idParamsSchema = z.object({ id: z.string().uuid() });
37
+ const stepParamsSchema = z.object({
38
+ runId: z.string().uuid(),
39
+ stepKey: z.string().min(1),
40
+ });
41
+
42
+ const createScriptRunBodySchema = z.object({
43
+ source: z.string().min(1),
44
+ args: z.unknown().optional(),
45
+ background: z.boolean().default(true),
46
+ idempotencyKey: z.string().min(1).max(200).optional(),
47
+ scriptName: z.string().min(1).max(200).optional(),
48
+ requestedByUserId: z.string().optional(),
49
+ });
50
+
51
+ const listScriptRunsQuerySchema = z.object({
52
+ status: ScriptRunStatusSchema.optional(),
53
+ agentId: z.string().optional(),
54
+ limit: z.coerce.number().int().min(1).max(500).optional(),
55
+ offset: z.coerce.number().int().min(0).optional(),
56
+ });
57
+
58
+ const journalStepBodySchema = z.object({
59
+ stepKey: z.string().min(1),
60
+ stepType: z.string().min(1),
61
+ config: z.unknown().optional(),
62
+ status: z.enum(["completed", "failed"]),
63
+ result: z.unknown().optional(),
64
+ error: z.string().optional(),
65
+ });
66
+
67
+ const statusBodySchema = z.discriminatedUnion("status", [
68
+ z.object({ status: z.literal("completed"), output: z.unknown().optional() }),
69
+ z.object({ status: z.literal("failed"), error: z.string().optional() }),
70
+ z.object({ status: z.literal("paused") }),
71
+ ]);
72
+
73
+ const agentTaskBodySchema = z.object({
74
+ stepKey: z.string().min(1),
75
+ template: z.string().optional(),
76
+ task: z.string().optional(),
77
+ agentId: z.string().uuid().optional(),
78
+ tags: z.array(z.string()).optional(),
79
+ priority: z.number().int().min(0).max(100).optional(),
80
+ offerMode: z.boolean().optional(),
81
+ dir: z.string().min(1).optional(),
82
+ vcsRepo: z.string().min(1).optional(),
83
+ model: z.string().min(1).optional(),
84
+ parentTaskId: z.string().uuid().optional(),
85
+ requestedByUserId: z.string().optional(),
86
+ outputSchema: z.record(z.string(), z.unknown()).optional(),
87
+ });
88
+
89
+ const createScriptRunRoute = route({
90
+ method: "post",
91
+ path: "/api/script-runs",
92
+ pattern: ["api", "script-runs"],
93
+ operationId: "script_runs_create",
94
+ summary: "Launch a durable script workflow run",
95
+ description:
96
+ "Foundation endpoint for Script Workflows v1. In PR 1 it persists the run and returns its dashboard URL; spawning is added by the supervisor PR.",
97
+ tags: ["Script Runs"],
98
+ body: createScriptRunBodySchema,
99
+ responses: {
100
+ 201: { description: "Script run created" },
101
+ 400: { description: "Validation or label-lint failure" },
102
+ 409: { description: "Existing idempotent run returned" },
103
+ 429: { description: "Script run concurrency cap reached" },
104
+ },
105
+ });
106
+
107
+ const listScriptRunsRoute = route({
108
+ method: "get",
109
+ path: "/api/script-runs",
110
+ pattern: ["api", "script-runs"],
111
+ operationId: "script_runs_list",
112
+ summary: "List script workflow runs",
113
+ tags: ["Script Runs"],
114
+ query: listScriptRunsQuerySchema,
115
+ responses: {
116
+ 200: { description: "Paginated script run list" },
117
+ },
118
+ });
119
+
120
+ const getScriptRunRoute = route({
121
+ method: "get",
122
+ path: "/api/script-runs/{id}",
123
+ pattern: ["api", "script-runs", null],
124
+ operationId: "script_runs_get",
125
+ summary: "Get a script workflow run with journal",
126
+ tags: ["Script Runs"],
127
+ params: idParamsSchema,
128
+ responses: {
129
+ 200: { description: "Script run detail" },
130
+ 404: { description: "Script run not found" },
131
+ },
132
+ });
133
+
134
+ const deleteScriptRunRoute = route({
135
+ method: "delete",
136
+ path: "/api/script-runs/{id}",
137
+ pattern: ["api", "script-runs", null],
138
+ operationId: "script_runs_cancel",
139
+ summary: "Cancel a script workflow run",
140
+ tags: ["Script Runs"],
141
+ params: idParamsSchema,
142
+ responses: {
143
+ 204: { description: "Script run cancelled, or already terminal" },
144
+ 404: { description: "Script run not found" },
145
+ },
146
+ });
147
+
148
+ const getInternalStepRoute = route({
149
+ method: "get",
150
+ path: "/api/internal/script-runs/{runId}/steps/{stepKey}",
151
+ pattern: ["api", "internal", "script-runs", null, "steps", null],
152
+ operationId: "script_runs_internal_step_get",
153
+ summary: "Get a script run journal step",
154
+ tags: ["Script Runs"],
155
+ params: stepParamsSchema,
156
+ responses: {
157
+ 200: { description: "Journal step found" },
158
+ 404: { description: "Journal step not found" },
159
+ },
160
+ });
161
+
162
+ const postInternalStepRoute = route({
163
+ method: "post",
164
+ path: "/api/internal/script-runs/{runId}/steps",
165
+ pattern: ["api", "internal", "script-runs", null, "steps"],
166
+ operationId: "script_runs_internal_step_create",
167
+ summary: "Write a script run journal step",
168
+ tags: ["Script Runs"],
169
+ params: runIdParamsSchema,
170
+ body: journalStepBodySchema,
171
+ responses: {
172
+ 201: { description: "Journal step written" },
173
+ 404: { description: "Script run not found" },
174
+ },
175
+ });
176
+
177
+ const heartbeatRoute = route({
178
+ method: "post",
179
+ path: "/api/internal/script-runs/{runId}/heartbeat",
180
+ pattern: ["api", "internal", "script-runs", null, "heartbeat"],
181
+ operationId: "script_runs_internal_heartbeat",
182
+ summary: "Record a script run heartbeat",
183
+ tags: ["Script Runs"],
184
+ params: runIdParamsSchema,
185
+ responses: {
186
+ 204: { description: "Heartbeat recorded" },
187
+ 404: { description: "Script run not found" },
188
+ },
189
+ });
190
+
191
+ const statusRoute = route({
192
+ method: "post",
193
+ path: "/api/internal/script-runs/{runId}/status",
194
+ pattern: ["api", "internal", "script-runs", null, "status"],
195
+ operationId: "script_runs_internal_status",
196
+ summary: "Update script run status from subprocess",
197
+ tags: ["Script Runs"],
198
+ params: runIdParamsSchema,
199
+ body: statusBodySchema,
200
+ responses: {
201
+ 204: { description: "Status updated" },
202
+ 404: { description: "Script run not found" },
203
+ },
204
+ });
205
+
206
+ const rawLlmRoute = route({
207
+ method: "post",
208
+ path: "/api/internal/raw-llm",
209
+ pattern: ["api", "internal", "raw-llm"],
210
+ operationId: "script_runs_internal_raw_llm",
211
+ summary: "Execute a raw LLM call for a script workflow",
212
+ tags: ["Script Runs"],
213
+ body: RawLlmConfigSchema,
214
+ responses: {
215
+ 200: { description: "LLM call completed" },
216
+ 500: { description: "LLM call failed" },
217
+ },
218
+ });
219
+
220
+ const agentTaskRoute = route({
221
+ method: "post",
222
+ path: "/api/internal/script-runs/{runId}/agent-task",
223
+ pattern: ["api", "internal", "script-runs", null, "agent-task"],
224
+ operationId: "script_runs_internal_agent_task",
225
+ summary: "Create or wait for a script workflow agent task step",
226
+ tags: ["Script Runs"],
227
+ params: runIdParamsSchema,
228
+ body: agentTaskBodySchema,
229
+ responses: {
230
+ 200: { description: "Agent task completed" },
231
+ 202: { description: "Agent task created or still running" },
232
+ 404: { description: "Script run not found" },
233
+ },
234
+ });
235
+
236
+ function requireAgent(res: ServerResponse, agentId: string | undefined) {
237
+ if (!agentId) {
238
+ jsonError(res, "X-Agent-ID required for script runs API", 400);
239
+ return null;
240
+ }
241
+ const agent = getAgentById(agentId);
242
+ if (!agent) {
243
+ jsonError(res, "Agent not found", 404);
244
+ return null;
245
+ }
246
+ return agent;
247
+ }
248
+
249
+ function scriptRunUrl(id: string): string {
250
+ return `${getAppUrl()}/script-runs/${id}`;
251
+ }
252
+
253
+ function scriptRunConcurrencyCap(): number {
254
+ const raw = process.env.SCRIPT_RUN_CONCURRENCY_CAP;
255
+ if (!raw) return DEFAULT_SCRIPT_RUN_CONCURRENCY_CAP;
256
+ const parsed = Number(raw);
257
+ return Number.isFinite(parsed) && parsed > 0
258
+ ? Math.floor(parsed)
259
+ : DEFAULT_SCRIPT_RUN_CONCURRENCY_CAP;
260
+ }
261
+
262
+ function bearerToken(req: IncomingMessage): string | undefined {
263
+ const raw = req.headers.authorization;
264
+ const header = Array.isArray(raw) ? raw[0] : raw;
265
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : undefined;
266
+ }
267
+
268
+ function sleep(ms: number): Promise<void> {
269
+ return new Promise((resolve) => setTimeout(resolve, ms));
270
+ }
271
+
272
+ function assertRunWithinLimits(runId: string): { ok: true } | { ok: false; error: string } {
273
+ const maxSteps = scriptRunMaxSteps();
274
+ const stepCount = countScriptRunJournalSteps(runId);
275
+ if (stepCount > maxSteps) {
276
+ const error = `SCRIPT_RUN_MAX_STEPS exceeded (${stepCount}/${maxSteps})`;
277
+ abortScriptRunLimit(runId, error);
278
+ return { ok: false, error };
279
+ }
280
+
281
+ const maxAgentTasks = scriptRunMaxAgentTasks();
282
+ const agentTaskCount = countScriptRunJournalAgentTaskSteps(runId);
283
+ if (agentTaskCount > maxAgentTasks) {
284
+ const error = `SCRIPT_RUN_MAX_AGENT_TASKS exceeded (${agentTaskCount}/${maxAgentTasks})`;
285
+ abortScriptRunLimit(runId, error);
286
+ return { ok: false, error };
287
+ }
288
+
289
+ return { ok: true };
290
+ }
291
+
292
+ export async function handleScriptRuns(
293
+ req: IncomingMessage,
294
+ res: ServerResponse,
295
+ pathSegments: string[],
296
+ queryParams: URLSearchParams,
297
+ agentId: string | undefined,
298
+ ): Promise<boolean> {
299
+ if (createScriptRunRoute.match(req.method, pathSegments)) {
300
+ const parsed = await createScriptRunRoute.parse(req, res, pathSegments, queryParams);
301
+ if (!parsed) return true;
302
+ const agent = requireAgent(res, agentId);
303
+ if (!agent) return true;
304
+
305
+ const lint = lintWorkflowLabels(parsed.body.source);
306
+ if (!lint.ok) {
307
+ json(
308
+ res,
309
+ {
310
+ error: "label_lint_violation",
311
+ message: "Launch rejected: loop step label collision detected",
312
+ violations: lint.errors,
313
+ },
314
+ 400,
315
+ );
316
+ return true;
317
+ }
318
+
319
+ if (parsed.body.idempotencyKey) {
320
+ const existingRun = getScriptRunByIdempotencyKey(parsed.body.idempotencyKey);
321
+ if (existingRun) {
322
+ json(
323
+ res,
324
+ { id: existingRun.id, status: existingRun.status, url: scriptRunUrl(existingRun.id) },
325
+ 409,
326
+ );
327
+ return true;
328
+ }
329
+ }
330
+
331
+ const cap = scriptRunConcurrencyCap();
332
+ if (countActiveScriptRuns() >= cap) {
333
+ json(res, { error: "script_run_concurrency_cap", cap }, 429);
334
+ return true;
335
+ }
336
+
337
+ const { run, existing } = createScriptRun({
338
+ id: crypto.randomUUID(),
339
+ agentId: agent.id,
340
+ source: parsed.body.source,
341
+ args: parsed.body.args ?? null,
342
+ scriptName: parsed.body.scriptName,
343
+ idempotencyKey: parsed.body.idempotencyKey,
344
+ requestedByUserId: parsed.body.requestedByUserId,
345
+ createdBy: parsed.body.requestedByUserId,
346
+ });
347
+
348
+ if (!existing && parsed.body.background) {
349
+ startScriptRunProcess(run, deriveApiBaseUrl(req), bearerToken(req)).catch((err) => {
350
+ updateScriptRun(run.id, {
351
+ status: "failed",
352
+ pid: null,
353
+ finishedAt: new Date().toISOString(),
354
+ error: err instanceof Error ? err.message : String(err),
355
+ });
356
+ });
357
+ }
358
+
359
+ json(res, { id: run.id, status: run.status, url: scriptRunUrl(run.id) }, existing ? 409 : 201);
360
+ return true;
361
+ }
362
+
363
+ if (listScriptRunsRoute.match(req.method, pathSegments)) {
364
+ const parsed = await listScriptRunsRoute.parse(req, res, pathSegments, queryParams);
365
+ if (!parsed) return true;
366
+ const opts = {
367
+ status: parsed.query.status,
368
+ agentId: parsed.query.agentId,
369
+ limit: parsed.query.limit ?? 50,
370
+ offset: parsed.query.offset ?? 0,
371
+ };
372
+ json(res, {
373
+ runs: listScriptRuns(opts),
374
+ total: countScriptRuns({ status: opts.status, agentId: opts.agentId }),
375
+ });
376
+ return true;
377
+ }
378
+
379
+ if (getScriptRunRoute.match(req.method, pathSegments)) {
380
+ const parsed = await getScriptRunRoute.parse(req, res, pathSegments, queryParams);
381
+ if (!parsed) return true;
382
+ const run = getScriptRun(parsed.params.id);
383
+ if (!run) {
384
+ jsonError(res, "Script run not found", 404);
385
+ return true;
386
+ }
387
+ json(res, { run, journal: listScriptRunJournalSteps(run.id) });
388
+ return true;
389
+ }
390
+
391
+ if (deleteScriptRunRoute.match(req.method, pathSegments)) {
392
+ const parsed = await deleteScriptRunRoute.parse(req, res, pathSegments, queryParams);
393
+ if (!parsed) return true;
394
+ const run = getScriptRun(parsed.params.id);
395
+ if (!run) {
396
+ jsonError(res, "Script run not found", 404);
397
+ return true;
398
+ }
399
+ if (TERMINAL_SCRIPT_RUN_STATUSES.some((status) => status === run.status)) {
400
+ res.writeHead(204);
401
+ res.end();
402
+ return true;
403
+ }
404
+ terminateScriptRunProcess(run.id);
405
+ updateScriptRun(run.id, {
406
+ status: "cancelled",
407
+ pid: null,
408
+ finishedAt: new Date().toISOString(),
409
+ });
410
+ res.writeHead(204);
411
+ res.end();
412
+ return true;
413
+ }
414
+
415
+ if (getInternalStepRoute.match(req.method, pathSegments)) {
416
+ const parsed = await getInternalStepRoute.parse(req, res, pathSegments, queryParams);
417
+ if (!parsed) return true;
418
+ const step = getScriptRunJournalStep(parsed.params.runId, parsed.params.stepKey);
419
+ if (!step) {
420
+ jsonError(res, "Script run journal step not found", 404);
421
+ return true;
422
+ }
423
+ json(res, { stepKey: step.stepKey, stepType: step.stepType, result: step.result });
424
+ return true;
425
+ }
426
+
427
+ if (postInternalStepRoute.match(req.method, pathSegments)) {
428
+ const parsed = await postInternalStepRoute.parse(req, res, pathSegments, queryParams);
429
+ if (!parsed) return true;
430
+ const run = getScriptRun(parsed.params.runId);
431
+ if (!run) {
432
+ jsonError(res, "Script run not found", 404);
433
+ return true;
434
+ }
435
+ upsertScriptRunJournalStep({
436
+ runId: run.id,
437
+ stepKey: parsed.body.stepKey,
438
+ stepType: parsed.body.stepType,
439
+ config: parsed.body.config ?? {},
440
+ status: parsed.body.status,
441
+ result: parsed.body.result,
442
+ error: parsed.body.error,
443
+ });
444
+ const limit = assertRunWithinLimits(run.id);
445
+ if (!limit.ok) {
446
+ json(res, { error: "script_run_limit", message: limit.error }, 429);
447
+ return true;
448
+ }
449
+ json(res, { ok: true }, 201);
450
+ return true;
451
+ }
452
+
453
+ if (heartbeatRoute.match(req.method, pathSegments)) {
454
+ const parsed = await heartbeatRoute.parse(req, res, pathSegments, queryParams);
455
+ if (!parsed) return true;
456
+ if (!getScriptRun(parsed.params.runId)) {
457
+ jsonError(res, "Script run not found", 404);
458
+ return true;
459
+ }
460
+ updateScriptRun(parsed.params.runId, { lastHeartbeatAt: new Date().toISOString() });
461
+ res.writeHead(204);
462
+ res.end();
463
+ return true;
464
+ }
465
+
466
+ if (statusRoute.match(req.method, pathSegments)) {
467
+ const parsed = await statusRoute.parse(req, res, pathSegments, queryParams);
468
+ if (!parsed) return true;
469
+ const run = getScriptRun(parsed.params.runId);
470
+ if (!run) {
471
+ jsonError(res, "Script run not found", 404);
472
+ return true;
473
+ }
474
+ if (TERMINAL_SCRIPT_RUN_STATUSES.some((status) => status === run.status)) {
475
+ res.writeHead(204);
476
+ res.end();
477
+ return true;
478
+ }
479
+ updateScriptRun(parsed.params.runId, {
480
+ status: parsed.body.status,
481
+ pid: null,
482
+ finishedAt: parsed.body.status === "paused" ? null : new Date().toISOString(),
483
+ output: "output" in parsed.body ? parsed.body.output : undefined,
484
+ error: "error" in parsed.body ? (parsed.body.error ?? null) : undefined,
485
+ });
486
+ res.writeHead(204);
487
+ res.end();
488
+ return true;
489
+ }
490
+
491
+ if (rawLlmRoute.match(req.method, pathSegments)) {
492
+ const parsed = await rawLlmRoute.parse(req, res, pathSegments, queryParams);
493
+ if (!parsed) return true;
494
+ const result = await executeRawLlm(parsed.body);
495
+ if (result.status === "failed") {
496
+ json(res, { error: result.error }, 500);
497
+ return true;
498
+ }
499
+ json(res, result.output);
500
+ return true;
501
+ }
502
+
503
+ if (agentTaskRoute.match(req.method, pathSegments)) {
504
+ const parsed = await agentTaskRoute.parse(req, res, pathSegments, queryParams);
505
+ if (!parsed) return true;
506
+ const run = getScriptRun(parsed.params.runId);
507
+ if (!run) {
508
+ jsonError(res, "Script run not found", 404);
509
+ return true;
510
+ }
511
+
512
+ const contextKey = `script-run:${run.id}:${parsed.body.stepKey}`;
513
+ let task = getLatestTaskByContextKey(contextKey);
514
+ if (!task) {
515
+ task = createTaskExtended(parsed.body.template ?? parsed.body.task ?? parsed.body.stepKey, {
516
+ agentId: parsed.body.agentId,
517
+ tags: parsed.body.tags,
518
+ priority: parsed.body.priority,
519
+ offeredTo: parsed.body.offerMode ? parsed.body.agentId : undefined,
520
+ taskType: "script-run-step",
521
+ source: "mcp",
522
+ dir: parsed.body.dir,
523
+ vcsRepo: parsed.body.vcsRepo,
524
+ model: parsed.body.model,
525
+ parentTaskId: parsed.body.parentTaskId,
526
+ requestedByUserId: parsed.body.requestedByUserId ?? run.requestedByUserId,
527
+ outputSchema: parsed.body.outputSchema,
528
+ contextKey,
529
+ });
530
+ }
531
+
532
+ const deadline = Date.now() + 30_000;
533
+ while (Date.now() < deadline) {
534
+ const latest = getLatestTaskByContextKey(contextKey) ?? task;
535
+ if (latest.status === "completed") {
536
+ json(res, { taskId: latest.id, taskOutput: latest.output ?? null });
537
+ return true;
538
+ }
539
+ if (
540
+ latest.status === "failed" ||
541
+ latest.status === "cancelled" ||
542
+ latest.status === "superseded"
543
+ ) {
544
+ json(res, { error: `Agent task ${latest.status}`, taskId: latest.id }, 409);
545
+ return true;
546
+ }
547
+ await sleep(1000);
548
+ }
549
+
550
+ json(res, { taskId: task.id, status: task.status }, 202);
551
+ return true;
552
+ }
553
+
554
+ return false;
555
+ }
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getAgentById } from "../be/db";
3
+ import { getAgentById, upsertKv } from "../be/db";
4
4
  import { createEvent } from "../be/events";
5
5
  import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
6
6
  import { searchScripts } from "../be/scripts/embeddings";
@@ -37,6 +37,7 @@ const runBodySchema = z
37
37
  intent: z.string().default(""),
38
38
  scope: ScriptScopeSchema.optional(),
39
39
  fsMode: ScriptFsModeSchema.default("none"),
40
+ idempotencyKey: z.string().max(200).optional(),
40
41
  })
41
42
  .refine((body) => Boolean(body.name) !== Boolean(body.source), {
42
43
  message: "Provide exactly one of name or source",
@@ -289,6 +290,27 @@ export async function handleScripts(
289
290
  agentId: agent.id,
290
291
  });
291
292
 
293
+ // Persist output to KV when idempotencyKey is provided and run succeeded
294
+ let kvSaved: { namespace: string; key: string } | undefined;
295
+ if (parsed.body.idempotencyKey && !output.error && output.exitCode === 0) {
296
+ const kvNamespace = `script:executions`;
297
+ const kvKey = parsed.body.idempotencyKey;
298
+ const kvValue = {
299
+ result: output.result,
300
+ durationMs: output.durationMs,
301
+ scriptName: parsed.body.name ?? null,
302
+ executedAt: new Date().toISOString(),
303
+ };
304
+ upsertKv({
305
+ namespace: kvNamespace,
306
+ key: kvKey,
307
+ value: kvValue,
308
+ valueType: "json",
309
+ expiresAt: null,
310
+ });
311
+ kvSaved = { namespace: kvNamespace, key: kvKey };
312
+ }
313
+
292
314
  let autoSaved: { slug: string; reason: string } | undefined;
293
315
  if (parsed.body.source && !output.error && output.exitCode === 0) {
294
316
  const slug = scratchSlug(parsed.body.intent, parsed.body.source);
@@ -314,6 +336,7 @@ export async function handleScripts(
314
336
  scrubObject({
315
337
  result: output.result,
316
338
  autoSaved,
339
+ kvSaved,
317
340
  truncated: output.truncated,
318
341
  durationMs: output.durationMs,
319
342
  stdout: output.stdout,
package/src/http/utils.ts CHANGED
@@ -157,14 +157,21 @@ export function triggerSchemaErrorResponse(
157
157
  * redirect URIs). Returns a URL with no trailing slash.
158
158
  *
159
159
  * Resolution order:
160
- * 1. `MCP_BASE_URL` env (canonical)
161
- * 2. Inbound request host `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
160
+ * 1. `PUBLIC_MCP_BASE_URL` env — explicit public origin. Wins so split
161
+ * deployments (Helm) can keep `MCP_BASE_URL` pointed at an internal
162
+ * cluster address while outbound URLs use the public ingress.
163
+ * 2. `MCP_BASE_URL` env — canonical when public and internal hosts coincide
164
+ * (e.g. an ngrok tunnel set as `MCP_BASE_URL` in local dev).
165
+ * 3. Inbound request host — `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
162
166
  * a proxy/tunnel (ngrok), else `Host` header. Lets the URL stay correct
163
- * when MCP_BASE_URL is unset and the API is reached via an arbitrary
167
+ * when neither env var is set and the API is reached via an arbitrary
164
168
  * external hostname.
165
- * 3. `http://localhost:<PORT>` fallback
169
+ * 4. `http://localhost:<PORT>` fallback
166
170
  */
167
171
  export function deriveApiBaseUrl(req: IncomingMessage): string {
172
+ const publicBase = process.env.PUBLIC_MCP_BASE_URL?.trim();
173
+ if (publicBase) return publicBase.replace(/\/+$/, "");
174
+
168
175
  const envBase = process.env.MCP_BASE_URL?.trim();
169
176
  if (envBase) return envBase.replace(/\/+$/, "");
170
177
 
package/src/jira/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { upsertOAuthApp } from "../be/db-queries/oauth";
2
+ import { getPublicMcpBaseUrl } from "../utils/constants";
2
3
  import { initJiraOutboundSync, teardownJiraOutboundSync } from "./outbound";
3
4
  // Side-effect import: registers all Jira event templates in the in-memory
4
5
  // registry at module load time (mirrors `src/linear/templates.ts`).
@@ -41,9 +42,7 @@ export function initJira(): boolean {
41
42
  // Atlassian. Prefer MCP_BASE_URL over the localhost dev default; in prod
42
43
  // with no JIRA_REDIRECT_URI set, this is what stops Atlassian from sending
43
44
  // the user back to localhost.
44
- const apiBaseUrl =
45
- process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "") ||
46
- `http://localhost:${process.env.PORT || "3013"}`;
45
+ const apiBaseUrl = getPublicMcpBaseUrl();
47
46
  const redirectUri = process.env.JIRA_REDIRECT_URI ?? `${apiBaseUrl}/api/trackers/jira/callback`;
48
47
 
49
48
  upsertOAuthApp("jira", {
@@ -1,3 +1,4 @@
1
+ import { getPublicMcpBaseUrl } from "../utils/constants";
1
2
  import { jiraFetch } from "./client";
2
3
  import { getJiraMetadata, updateJiraMetadata } from "./metadata";
3
4
 
@@ -23,7 +24,7 @@ let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
23
24
  // ─── URL helpers ─────────────────────────────────────────────────────────────
24
25
 
25
26
  function getWebhookBaseUrl(): string {
26
- return process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
27
+ return getPublicMcpBaseUrl();
27
28
  }
28
29
 
29
30
  function getRegisteredWebhookUrl(): string {
package/src/linear/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { upsertOAuthApp } from "../be/db-queries/oauth";
2
+ import { getPublicMcpBaseUrl } from "../utils/constants";
2
3
  import { initLinearOutboundSync, teardownLinearOutboundSync } from "./outbound";
3
4
 
4
5
  let initialized = false;
@@ -31,9 +32,7 @@ export function initLinear(): boolean {
31
32
  // verbatim by the OAuth flow. Prefer MCP_BASE_URL over the localhost default
32
33
  // so prod doesn't send users back to localhost when LINEAR_REDIRECT_URI is
33
34
  // unset.
34
- const apiBaseUrl =
35
- process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "") ||
36
- `http://localhost:${process.env.PORT || "3013"}`;
35
+ const apiBaseUrl = getPublicMcpBaseUrl();
37
36
  const redirectUri =
38
37
  process.env.LINEAR_REDIRECT_URI ?? `${apiBaseUrl}/api/trackers/linear/callback`;
39
38