@desplega.ai/agent-swarm 1.91.0 → 1.92.1

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 (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  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/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. package/templates/skills/scheduled-task-resilience/content.md +0 -95
@@ -27,17 +27,19 @@ Goal: Review system status and your standing orders, take action if needed.
27
27
 
28
28
  ## Instructions
29
29
  1. **Read your HEARTBEAT.md** — run \`read /workspace/HEARTBEAT.md\` to get the latest standing orders (the snapshot above may be slightly stale).
30
- 2. Review the system status above for anything that needs attention (stalled tasks, idle workers with available work, anomalies).
31
- 3. **CRITICAL Reboot failure triage:** Failures with reason "worker session not found" or "worker session heartbeat is stale" indicate tasks that were INTERRUPTED by a server restart. These are NOT "expected auto-cleanup" they represent work that was lost mid-execution. For each such failure:
30
+ 2. **Prune tracked HEARTBEAT items FIRST.** Active Blockers + Watch Items + Open Discussion combined must stay at ≤10 items; 20 is the absolute max only when genuinely super busy. This cap does not apply to evergreen Standing Orders / Governance / Playbook-index reference sections. Before adding anything, re-check every tracked item against its lift trigger and remove anything resolved, stale, or past its trigger date. Lift incident detail to memory; never keep it inline in HEARTBEAT.md. Every new tracked item must include an explicit lift trigger + date; if it has no removal condition, do not add it. If the tracked list is already at/over cap, prune before (or instead of) adding — the cap is binding.
31
+ 3. **Run seeded heartbeat data-gathering.** Use \`script-run\` with global script \`Heartbeat Audit\` and pass the current HEARTBEAT.md text as \`heartbeatMarkdown\`. It covers Rules #10/#13/#15/#16/#17: resolved stale PRs, pool-target risk schedules, schedule/provider failure clusters, and whether daily-blocker-digest ran today. The Slack thread-reply check (Rule #11) stays Lead-side; the script runtime has no Slack token.
32
+ 4. Review the system status above plus the \`Heartbeat Audit\` result for anything that needs attention (stalled tasks, idle workers with available work, anomalies).
33
+ 5. **CRITICAL — Reboot failure triage:** Failures with reason "worker session not found" or "worker session heartbeat is stale" indicate tasks that were INTERRUPTED by a server restart. These are NOT "expected auto-cleanup" — they represent work that was lost mid-execution. For each such failure:
32
34
  - Check what the task was (via \`get-task-details\` with the task ID from the failure)
33
35
  - If a retry task was auto-created (tagged \`reboot-retry\`), verify it is progressing
34
36
  - If no retry exists and the work is still needed, re-create the task
35
37
  - Do NOT dismiss these as "expected" or "auto-cleanup"
36
- 4. Review your standing orders for any periodic checks or actions.
37
- 5. If something needs attention — take action now using your available tools (create tasks, post to Slack, cancel stuck tasks, etc.).
38
- 6. If everything looks healthy and no standing orders are actionable — complete this task with a brief "All clear" summary. You may NOT say "All clear" if reboot-related failures exist that haven't been triaged.
39
- 7. Do NOT create another heartbeat-checklist task — the system handles scheduling.
40
- 8. **Update your standing orders** After every heartbeat check, edit \`/workspace/HEARTBEAT.md\` directly. Add new patterns you noticed (recurring failures, workers needing attention), remove resolved items. This is your live operational runbook keep it current.`,
38
+ 6. Review your standing orders for any periodic checks or actions.
39
+ 7. If something needs attention — take action now using your available tools (create tasks, post to Slack, cancel stuck tasks, etc.).
40
+ 8. If everything looks healthy and no standing orders are actionable — complete this task with a brief "All clear" summary. You may NOT say "All clear" if reboot-related failures exist that haven't been triaged.
41
+ 9. Do NOT create another heartbeat-checklist task — the system handles scheduling.
42
+ 10. **Update HEARTBEAT.md only after pruning.** Keep it current, but keep tracked items capped: remove resolved items, add only dated lift-triggered items, and lift detail to memory instead of growing the file.`,
41
43
  variables: [
42
44
  {
43
45
  name: "system_status",
@@ -73,22 +75,24 @@ The API server has just restarted (deployment, pod rotation, or crash). An aggre
73
75
  {{heartbeat_content}}
74
76
 
75
77
  ## Instructions
76
- 1. **Triage reboot-interrupted work FIRST.** If the "Reboot-Interrupted Work" section above lists tasks:
78
+ 1. **Prune tracked HEARTBEAT items FIRST.** Active Blockers + Watch Items + Open Discussion combined must stay at ≤10 items; 20 is the absolute max only when genuinely super busy. This cap does not apply to evergreen Standing Orders / Governance / Playbook-index reference sections. Before adding anything, re-check every tracked item against its lift trigger and remove anything resolved, stale, or past its trigger date. Lift incident detail to memory; never keep it inline in HEARTBEAT.md. Every new tracked item must include an explicit lift trigger + date; if it has no removal condition, do not add it. If the tracked list is already at/over cap, prune before (or instead of) adding — the cap is binding.
79
+ 2. **Run seeded boot triage.** Use \`script-run\` with global script \`boot-triage\` to gather deploy-restart PR context, recent real failures, stuck offline-agent work, orphaned pending/offered tasks, and superseded tasks missing resume children in one read-only call.
80
+ 3. **Triage reboot-interrupted work FIRST.** If the "Reboot-Interrupted Work" section above or the \`boot-triage\` result lists tasks:
77
81
  - For each task: verify the retry is progressing via \`get-task-details\` with the retry task ID
78
82
  - If a retry failed or is stuck, re-create the task manually
79
83
  - If the work is no longer needed, cancel the retry task
80
84
  - You MUST address every item — do NOT skip this section
81
- 2. **Verify supersede + resume worked end-to-end.** Worker crashes / OOMs are recovered via supersede (parent → \`superseded\`) + a fresh \`taskType=resume\` child created by the heartbeat sweep (DES-523). Sanity check:
85
+ 4. **Verify supersede + resume worked end-to-end.** Worker crashes / OOMs are recovered via supersede (parent → \`superseded\`) + a fresh \`taskType=resume\` child created by the heartbeat sweep (DES-523). Sanity check:
82
86
  - List recent \`superseded\` tasks: \`list-tasks status=superseded\` (last ~hour).
83
87
  - For each, confirm a child task with \`taskType=resume\` and a non-terminal status exists. If a superseded task is missing its resume child, the work is silently dropped — recreate the task manually.
84
88
  - Look for \`in_progress\` tasks older than 5 min on agents that show as offline — the sweep should have caught them. If any remain, recreate as needed.
85
- 3. **Check orphaned tasks.** If the "Orphaned Tasks" section lists pending/offered tasks assigned to offline workers, re-assign or cancel them.
86
- 4. Review agent status — are all expected workers online? If not, note which are missing.
87
- 5. Review your standing orders for any post-reboot checks.
88
- 6. Take action using your available tools.
89
- 7. Complete this task with a summary of what you found and what actions you took. Include the status of each reboot-interrupted task.
90
- 8. Do NOT create another boot-triage task — this is a one-off event.
91
- 9. **Update your standing orders** If the reboot revealed a pattern worth monitoring (e.g., frequent restarts, specific tasks that keep failing), add a standing order to HEARTBEAT.md via \`update-profile\` with \`heartbeatMd\`.`,
89
+ 5. **Check orphaned tasks.** If the "Orphaned Tasks" section or \`boot-triage\` result lists pending/offered tasks assigned to offline workers, re-assign or cancel them.
90
+ 6. Review agent status — are all expected workers online? If not, note which are missing.
91
+ 7. Review your standing orders for any post-reboot checks.
92
+ 8. Take action using your available tools.
93
+ 9. Complete this task with a summary of what you found and what actions you took. Include the status of each reboot-interrupted task.
94
+ 10. Do NOT create another boot-triage task — this is a one-off event.
95
+ 11. **Update HEARTBEAT.md only after pruning.** If the reboot revealed a pattern worth monitoring, add it only as a dated lift-triggered tracked item and only if the cap still holds after pruning. Lift incident detail to memory instead of growing HEARTBEAT.md.`,
92
96
  variables: [
93
97
  {
94
98
  name: "system_status",
@@ -11,6 +11,24 @@ export interface DbQueryResult {
11
11
  total: number;
12
12
  }
13
13
 
14
+ export const DbQueryInputShape = {
15
+ sql: z.string().min(1).max(10_000).optional(),
16
+ query: z.string().min(1).max(10_000).optional().describe("Deprecated runtime alias for sql."),
17
+ params: z.array(z.any()).optional().default([]),
18
+ };
19
+
20
+ export const DbQueryInputSchema = z
21
+ .object(DbQueryInputShape)
22
+ .refine((body) => body.sql !== undefined || body.query !== undefined, {
23
+ message: "Either sql or query is required",
24
+ });
25
+
26
+ export type DbQueryInput = z.infer<typeof DbQueryInputSchema>;
27
+
28
+ export function resolveDbQuerySql(input: Pick<DbQueryInput, "sql" | "query">): string {
29
+ return input.sql ?? input.query ?? "";
30
+ }
31
+
14
32
  function stripTrailingSemicolon(sql: string): string {
15
33
  return sql.trim().replace(/;\s*$/, "").trim();
16
34
  }
@@ -67,10 +85,7 @@ const dbQueryRoute = route({
67
85
  pattern: ["api", "db-query"],
68
86
  summary: "Execute a read-only SQL query",
69
87
  tags: ["Debug"],
70
- body: z.object({
71
- sql: z.string().min(1).max(10_000),
72
- params: z.array(z.any()).optional().default([]),
73
- }),
88
+ body: DbQueryInputSchema,
74
89
  responses: {
75
90
  200: {
76
91
  description: "Query results",
@@ -100,7 +115,7 @@ export async function handleDbQuery(
100
115
  if (!parsed) return true;
101
116
 
102
117
  try {
103
- const result = executeReadOnlyQuery(parsed.body.sql, parsed.body.params);
118
+ const result = executeReadOnlyQuery(resolveDbQuerySql(parsed.body), parsed.body.params);
104
119
  json(res, result);
105
120
  } catch (err: unknown) {
106
121
  const message = err instanceof Error ? err.message : String(err);
package/src/http/index.ts CHANGED
@@ -21,9 +21,11 @@ import {
21
21
  withRemoteContext,
22
22
  withSpanContext,
23
23
  } from "../otel";
24
+ import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-workflows/supervisor";
24
25
  import { startSlackApp, stopSlackApp } from "../slack";
25
26
  import { initTelemetry, telemetry } from "../telemetry";
26
27
  import { getApiKey } from "../utils/api-key";
28
+ import { getMcpBaseUrl } from "../utils/constants";
27
29
  import { scrubSecrets } from "../utils/secret-scrubber";
28
30
  import { initWorkflows } from "../workflows";
29
31
  import { handleActiveSessions } from "./active-sessions";
@@ -41,12 +43,17 @@ import { handleHeartbeat } from "./heartbeat";
41
43
  import { handleInboxState } from "./inbox-state";
42
44
  import { handleIntegrations } from "./integrations";
43
45
  import { handleKv } from "./kv";
44
- import { handleMcp } from "./mcp";
46
+ import {
47
+ closeIdleMcpTransports,
48
+ DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS,
49
+ handleMcp,
50
+ type McpTransportActivity,
51
+ } from "./mcp";
45
52
  import { handleMcpBridge } from "./mcp-bridge";
46
53
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
47
54
  import { handleMcpServers } from "./mcp-servers";
48
- import { handleMcpUser } from "./mcp-user";
49
- import { handleMemory } from "./memory";
55
+ import { closeIdleMcpUserTransports, handleMcpUser } from "./mcp-user";
56
+ import { handleMemory, startMemoryGc, stopMemoryGc } from "./memory";
50
57
  import { handleMetrics } from "./metrics";
51
58
  import { handlePageProxy } from "./page-proxy";
52
59
  import { handlePages } from "./pages";
@@ -57,6 +64,7 @@ import { handlePromptTemplates } from "./prompt-templates";
57
64
  import { handleRepos } from "./repos";
58
65
  import { describeRequestRoute } from "./route-def";
59
66
  import { handleSchedules } from "./schedules";
67
+ import { handleScriptRuns } from "./script-runs";
60
68
  import { handleScripts } from "./scripts";
61
69
  import { handleSessionData } from "./session-data";
62
70
  import { handleSessions } from "./sessions";
@@ -96,12 +104,15 @@ const globalState = globalThis as typeof globalThis & {
96
104
  __transports?: Record<string, StreamableHTTPServerTransport>;
97
105
  __transportsUser?: Record<string, StreamableHTTPServerTransport>;
98
106
  __sessionUsers?: Record<string, string>;
107
+ __transportActivity?: McpTransportActivity;
108
+ __transportActivityUser?: McpTransportActivity;
99
109
  __sigintRegistered?: boolean;
100
110
  __apiGcInterval?: ReturnType<typeof setInterval>;
101
111
  __runId?: string;
102
112
  };
103
113
 
104
114
  const API_GC_INTERVAL_MS = 5 * 60 * 1000;
115
+ const MCP_TRANSPORT_IDLE_TIMEOUT_MS = DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS;
105
116
 
106
117
  type GcCapableGlobal = typeof globalThis & { gc?: () => void };
107
118
 
@@ -127,11 +138,25 @@ function startApiGcInterval() {
127
138
 
128
139
  const gc = (globalThis as GcCapableGlobal).gc;
129
140
  if (typeof gc !== "function") {
130
- console.log("[HTTP] Explicit GC unavailable; start API with --expose-gc to enable sweeps");
131
- return;
141
+ console.log("[HTTP] Explicit GC unavailable; idle MCP transport sweeps remain enabled");
132
142
  }
133
143
 
134
144
  const interval = setInterval(() => {
145
+ const closedOwnerTransports = closeIdleMcpTransports(transports, transportActivity, {
146
+ idleTimeoutMs: MCP_TRANSPORT_IDLE_TIMEOUT_MS,
147
+ label: "MCP",
148
+ });
149
+ const closedUserTransports = closeIdleMcpUserTransports(
150
+ transportsUser,
151
+ sessionUsers,
152
+ transportActivityUser,
153
+ { idleTimeoutMs: MCP_TRANSPORT_IDLE_TIMEOUT_MS },
154
+ );
155
+ if (closedOwnerTransports > 0 || closedUserTransports > 0) {
156
+ console.log(
157
+ `[HTTP] Closed ${closedOwnerTransports} owner MCP and ${closedUserTransports} user MCP idle transport(s)`,
158
+ );
159
+ }
135
160
  scheduleApiGc("periodic API sweep");
136
161
  }, API_GC_INTERVAL_MS);
137
162
  interval.unref?.();
@@ -148,6 +173,8 @@ const transports: Record<string, StreamableHTTPServerTransport> = globalState.__
148
173
  const transportsUser: Record<string, StreamableHTTPServerTransport> =
149
174
  globalState.__transportsUser ?? {};
150
175
  const sessionUsers: Record<string, string> = globalState.__sessionUsers ?? {};
176
+ const transportActivity: McpTransportActivity = globalState.__transportActivity ?? {};
177
+ const transportActivityUser: McpTransportActivity = globalState.__transportActivityUser ?? {};
151
178
 
152
179
  const httpServer = createHttpServer(async (req, res) => {
153
180
  const startTime = performance.now();
@@ -272,6 +299,7 @@ const httpServer = createHttpServer(async (req, res) => {
272
299
  () => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
273
300
  () => handleRepos(req, res, pathSegments, queryParams),
274
301
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
302
+ () => handleScriptRuns(req, res, pathSegments, queryParams, myAgentId),
275
303
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),
276
304
  () => handleMcpBridge(req, res, pathSegments, queryParams, myAgentId),
277
305
  () => handleMcpServers(req, res, pathSegments, queryParams),
@@ -287,8 +315,8 @@ const httpServer = createHttpServer(async (req, res) => {
287
315
  () => handleSessions(req, res, pathSegments, queryParams),
288
316
  () => handleInboxState(req, res, pathSegments, queryParams),
289
317
  () => handleTaskTemplates(req, res, pathSegments, queryParams),
290
- () => handleMcp(req, res, transports),
291
- () => handleMcpUser(req, res, transportsUser, sessionUsers),
318
+ () => handleMcp(req, res, transports, transportActivity),
319
+ () => handleMcpUser(req, res, transportsUser, sessionUsers, transportActivityUser),
292
320
  ];
293
321
 
294
322
  try {
@@ -330,6 +358,8 @@ globalState.__httpServer = httpServer;
330
358
  globalState.__transports = transports;
331
359
  globalState.__transportsUser = transportsUser;
332
360
  globalState.__sessionUsers = sessionUsers;
361
+ globalState.__transportActivity = transportActivity;
362
+ globalState.__transportActivityUser = transportActivityUser;
333
363
 
334
364
  async function shutdown() {
335
365
  console.log("Shutting down HTTP server...");
@@ -343,6 +373,9 @@ async function shutdown() {
343
373
  // Stop heartbeat triage
344
374
  stopHeartbeat();
345
375
 
376
+ // Stop durable script workflow subprocesses
377
+ stopScriptRunSupervisor();
378
+
346
379
  // Stop Slack bot
347
380
  await stopSlackApp();
348
381
 
@@ -355,6 +388,9 @@ async function shutdown() {
355
388
  // Stop MCP OAuth pending-session garbage collector
356
389
  stopMcpOAuthPendingGc();
357
390
 
391
+ // Stop memory expired-row garbage collector
392
+ stopMemoryGc();
393
+
358
394
  if (globalState.__apiGcInterval) {
359
395
  clearInterval(globalState.__apiGcInterval);
360
396
  delete globalState.__apiGcInterval;
@@ -365,6 +401,7 @@ async function shutdown() {
365
401
  console.log(`[HTTP] Closing transport ${id}`);
366
402
  transport.close();
367
403
  delete transports[id];
404
+ delete transportActivity[id];
368
405
  }
369
406
 
370
407
  for (const [id, transport] of Object.entries(transportsUser)) {
@@ -372,6 +409,7 @@ async function shutdown() {
372
409
  transport.close();
373
410
  delete transportsUser[id];
374
411
  delete sessionUsers[id];
412
+ delete transportActivityUser[id];
375
413
  }
376
414
 
377
415
  // Close all active connections forcefully
@@ -487,6 +525,9 @@ httpServer
487
525
  // Initialize workflow engine (trigger subscriptions + resume listener)
488
526
  initWorkflows();
489
527
 
528
+ // Reconcile durable script workflow subprocesses
529
+ startScriptRunSupervisor(getMcpBaseUrl());
530
+
490
531
  // Start scheduler (if enabled)
491
532
  if (hasCapability("scheduling")) {
492
533
  const { startScheduler } = await import("../scheduler");
@@ -512,6 +553,9 @@ httpServer
512
553
 
513
554
  // Start MCP OAuth pending-session garbage collector (5-min tick)
514
555
  startMcpOAuthPendingGc();
556
+
557
+ // Start expired-memory garbage collector (1-hour tick, immediate first run)
558
+ startMemoryGc();
515
559
  })
516
560
  .on("error", (err) => {
517
561
  console.error("HTTP Server Error:", err);
@@ -5,6 +5,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { resolveUserByToken } from "@/be/users";
6
6
  import { createUserServer } from "@/server-user";
7
7
  import type { User } from "@/types";
8
+ import { closeIdleMcpTransports, type McpTransportActivity, markMcpTransportActivity } from "./mcp";
8
9
 
9
10
  function unauthorized(res: ServerResponse): true {
10
11
  res.writeHead(401, { "Content-Type": "application/json" });
@@ -32,6 +33,7 @@ export async function handleMcpUser(
32
33
  res: ServerResponse,
33
34
  transports: Record<string, StreamableHTTPServerTransport>,
34
35
  sessionUsers: Record<string, string>,
36
+ sessionActivity: McpTransportActivity = {},
35
37
  ): Promise<boolean> {
36
38
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
37
39
 
@@ -57,16 +59,19 @@ export async function handleMcpUser(
57
59
 
58
60
  if (sessionId && transports[sessionId]) {
59
61
  transport = transports[sessionId];
62
+ markMcpTransportActivity(sessionActivity, sessionId);
60
63
  } else if (!sessionId && isInitializeRequest(body)) {
61
64
  transport = new StreamableHTTPServerTransport({
62
65
  sessionIdGenerator: () => randomUUID(),
63
66
  onsessioninitialized: (id) => {
64
67
  transports[id] = transport;
65
68
  sessionUsers[id] = user.id;
69
+ markMcpTransportActivity(sessionActivity, id);
66
70
  },
67
71
  onsessionclosed: (id) => {
68
72
  delete transports[id];
69
73
  delete sessionUsers[id];
74
+ delete sessionActivity[id];
70
75
  },
71
76
  });
72
77
 
@@ -74,6 +79,7 @@ export async function handleMcpUser(
74
79
  if (transport.sessionId) {
75
80
  delete transports[transport.sessionId];
76
81
  delete sessionUsers[transport.sessionId];
82
+ delete sessionActivity[transport.sessionId];
77
83
  }
78
84
  };
79
85
 
@@ -92,11 +98,13 @@ export async function handleMcpUser(
92
98
  }
93
99
 
94
100
  await transport.handleRequest(req, res, body);
101
+ markMcpTransportActivity(sessionActivity, transport.sessionId);
95
102
  return true;
96
103
  }
97
104
 
98
105
  if (req.method === "GET" || req.method === "DELETE") {
99
106
  if (sessionId && transports[sessionId]) {
107
+ markMcpTransportActivity(sessionActivity, sessionId);
100
108
  await transports[sessionId].handleRequest(req, res);
101
109
  return true;
102
110
  }
@@ -109,3 +117,18 @@ export async function handleMcpUser(
109
117
  res.end("Method not allowed");
110
118
  return true;
111
119
  }
120
+
121
+ export function closeIdleMcpUserTransports(
122
+ transports: Record<string, StreamableHTTPServerTransport>,
123
+ sessionUsers: Record<string, string>,
124
+ sessionActivity: McpTransportActivity,
125
+ options: { now?: number; idleTimeoutMs?: number } = {},
126
+ ): number {
127
+ return closeIdleMcpTransports(transports, sessionActivity, {
128
+ ...options,
129
+ label: "user MCP",
130
+ onClose: (id) => {
131
+ delete sessionUsers[id];
132
+ },
133
+ });
134
+ }
package/src/http/mcp.ts CHANGED
@@ -4,10 +4,62 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { createServer } from "@/server";
6
6
 
7
+ export type McpTransportActivity = Record<string, number>;
8
+
9
+ export const DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
10
+
11
+ export function markMcpTransportActivity(
12
+ sessionActivity: McpTransportActivity,
13
+ sessionId: string | undefined,
14
+ now = Date.now(),
15
+ ): void {
16
+ if (sessionId) {
17
+ sessionActivity[sessionId] = now;
18
+ }
19
+ }
20
+
21
+ export function closeIdleMcpTransports(
22
+ transports: Record<string, StreamableHTTPServerTransport>,
23
+ sessionActivity: McpTransportActivity,
24
+ options: {
25
+ now?: number;
26
+ idleTimeoutMs?: number;
27
+ label?: string;
28
+ onClose?: (id: string) => void;
29
+ } = {},
30
+ ): number {
31
+ const now = options.now ?? Date.now();
32
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS;
33
+ let closed = 0;
34
+
35
+ for (const [id, transport] of Object.entries(transports)) {
36
+ const lastActivity = sessionActivity[id];
37
+ if (lastActivity === undefined) {
38
+ sessionActivity[id] = now;
39
+ continue;
40
+ }
41
+ if (now - lastActivity < idleTimeoutMs) continue;
42
+
43
+ try {
44
+ transport.close();
45
+ } catch (err) {
46
+ console.warn(`[HTTP] Failed to close idle ${options.label ?? "MCP"} transport ${id}: ${err}`);
47
+ } finally {
48
+ delete transports[id];
49
+ delete sessionActivity[id];
50
+ options.onClose?.(id);
51
+ closed++;
52
+ }
53
+ }
54
+
55
+ return closed;
56
+ }
57
+
7
58
  export async function handleMcp(
8
59
  req: IncomingMessage,
9
60
  res: ServerResponse,
10
61
  transports: Record<string, StreamableHTTPServerTransport>,
62
+ sessionActivity: McpTransportActivity = {},
11
63
  ): Promise<boolean> {
12
64
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
13
65
 
@@ -26,20 +78,24 @@ export async function handleMcp(
26
78
 
27
79
  if (sessionId && transports[sessionId]) {
28
80
  transport = transports[sessionId];
81
+ markMcpTransportActivity(sessionActivity, sessionId);
29
82
  } else if (!sessionId && isInitializeRequest(body)) {
30
83
  transport = new StreamableHTTPServerTransport({
31
84
  sessionIdGenerator: () => randomUUID(),
32
85
  onsessioninitialized: (id) => {
33
86
  transports[id] = transport;
87
+ markMcpTransportActivity(sessionActivity, id);
34
88
  },
35
89
  onsessionclosed: (id) => {
36
90
  delete transports[id];
91
+ delete sessionActivity[id];
37
92
  },
38
93
  });
39
94
 
40
95
  transport.onclose = () => {
41
96
  if (transport.sessionId) {
42
97
  delete transports[transport.sessionId];
98
+ delete sessionActivity[transport.sessionId];
43
99
  }
44
100
  };
45
101
 
@@ -58,11 +114,13 @@ export async function handleMcp(
58
114
  }
59
115
 
60
116
  await transport.handleRequest(req, res, body);
117
+ markMcpTransportActivity(sessionActivity, transport.sessionId);
61
118
  return true;
62
119
  }
63
120
 
64
121
  if (req.method === "GET" || req.method === "DELETE") {
65
122
  if (sessionId && transports[sessionId]) {
123
+ markMcpTransportActivity(sessionActivity, sessionId);
66
124
  await transports[sessionId].handleRequest(req, res);
67
125
  return true;
68
126
  }
@@ -115,6 +115,18 @@ const listMemory = route({
115
115
  },
116
116
  });
117
117
 
118
+ const memoryHealth = route({
119
+ method: "get",
120
+ path: "/api/memory/health",
121
+ pattern: ["api", "memory", "health"],
122
+ summary: "Report memory vector index health and retrieval mode",
123
+ tags: ["Memory"],
124
+ auth: { apiKey: true },
125
+ responses: {
126
+ 200: { description: "Memory vector index health" },
127
+ },
128
+ });
129
+
118
130
  const deleteMemoryById = route({
119
131
  method: "delete",
120
132
  path: "/api/memory/{id}",
@@ -498,6 +510,14 @@ export async function handleMemory(
498
510
  return true;
499
511
  }
500
512
 
513
+ if (memoryHealth.match(req.method, pathSegments)) {
514
+ const parsed = await memoryHealth.parse(req, res, pathSegments, new URLSearchParams());
515
+ if (!parsed) return true;
516
+
517
+ json(res, getMemoryStore().getHealth());
518
+ return true;
519
+ }
520
+
501
521
  if (deleteMemoryById.match(req.method, pathSegments)) {
502
522
  const parsed = await deleteMemoryById.parse(req, res, pathSegments, new URLSearchParams());
503
523
  if (!parsed) return true;
@@ -663,3 +683,41 @@ export async function handleMemory(
663
683
 
664
684
  return false;
665
685
  }
686
+
687
+ // ─── Expired Memory GC ──────────────────────────────────────────────────────
688
+
689
+ const MEMORY_GC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
690
+ let memoryGcTimer: ReturnType<typeof setInterval> | null = null;
691
+
692
+ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
693
+ if (memoryGcTimer) return;
694
+
695
+ // Run immediately on startup to clear any backlog
696
+ try {
697
+ const purged = getMemoryStore().purgeExpired();
698
+ if (purged > 0) {
699
+ console.log(`[memory-gc] Initial purge removed ${purged} expired memory row(s)`);
700
+ }
701
+ } catch (err) {
702
+ console.error("[memory-gc] Initial purge failed:", err);
703
+ }
704
+
705
+ memoryGcTimer = setInterval(() => {
706
+ try {
707
+ const purged = getMemoryStore().purgeExpired();
708
+ if (purged > 0) {
709
+ console.log(`[memory-gc] Periodic purge removed ${purged} expired memory row(s)`);
710
+ }
711
+ } catch (err) {
712
+ console.error("[memory-gc] Periodic purge failed:", err);
713
+ }
714
+ }, intervalMs);
715
+ if (typeof memoryGcTimer?.unref === "function") memoryGcTimer.unref();
716
+ }
717
+
718
+ export function stopMemoryGc(): void {
719
+ if (memoryGcTimer) {
720
+ clearInterval(memoryGcTimer);
721
+ memoryGcTimer = null;
722
+ }
723
+ }
package/src/http/pages.ts CHANGED
@@ -58,7 +58,7 @@ const createPageRoute = route({
58
58
  title: z.string().min(1),
59
59
  description: z.string().optional(),
60
60
  contentType: PageContentTypeSchema,
61
- authMode: PageAuthModeSchema,
61
+ authMode: PageAuthModeSchema.default("authed"),
62
62
  password: z.string().min(1).optional(),
63
63
  body: z.string(),
64
64
  needsCredentials: z.array(z.string()).optional(),