@desplega.ai/agent-swarm 2.4.1 → 2.4.2

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 (109) hide show
  1. package/dist/{acp-adapter-zvzr538d.js → acp-adapter-dv7hmx4r.js} +1 -1
  2. package/dist/{actions-jhtqdw69.js → actions-51nc1gny.js} +16 -16
  3. package/dist/{amp-adapter-bd84rhs2.js → amp-adapter-abp5tdx8.js} +1 -1
  4. package/dist/{app-kreq1gwg.js → app-9h14qvr6.js} +3 -3
  5. package/dist/{assistant-3k22sbsq.js → assistant-wv1gz3mt.js} +17 -17
  6. package/dist/{boot-reembed-e53x6b9v.js → boot-reembed-6yn53ms9.js} +1 -1
  7. package/dist/{boot-reembed-f6z923ny.js → boot-reembed-7fcka4z5.js} +1 -1
  8. package/dist/{boot-scrub-sweep-0538vsb7.js → boot-scrub-sweep-pm1580ax.js} +2 -2
  9. package/dist/{boot-seeds-6wxqkfk2.js → boot-seeds-g5hfc1x8.js} +4 -4
  10. package/dist/{claude-adapter-gfts5044.js → claude-adapter-gf3kxhf0.js} +2 -2
  11. package/dist/{claude-sdk-session-56besmfz.js → claude-sdk-session-s2k1qd8y.js} +2 -2
  12. package/dist/{cli-tbhwrxpt.js → cli-02qm7src.js} +3 -3
  13. package/dist/{cli-3kh4kpqy.js → cli-0qvtffe9.js} +5 -5
  14. package/dist/{cli-p6argxh9.js → cli-10hhg0km.js} +6 -6
  15. package/dist/{cli-gf0kfg81.js → cli-239yjk27.js} +2 -2
  16. package/dist/{cli-kr2pmfqx.js → cli-5rk36sjf.js} +72 -59
  17. package/dist/{cli-nx462e4v.js → cli-7awf1fp8.js} +1 -1
  18. package/dist/{cli-jh0qcrga.js → cli-8sxqsbbq.js} +1 -1
  19. package/dist/{cli-axh29tyr.js → cli-8zwe9mpk.js} +5 -5
  20. package/dist/{cli-z1ew8vbq.js → cli-ahtpy9kf.js} +1 -1
  21. package/dist/{cli-3r448dqn.js → cli-bcj753sq.js} +3 -3
  22. package/dist/{cli-rqqsfz30.js → cli-d3qqdy4c.js} +82 -12
  23. package/dist/{cli-z071fb95.js → cli-efaqv1c7.js} +4 -4
  24. package/dist/{cli-s48yvzb8.js → cli-faypxz7q.js} +3 -3
  25. package/dist/{cli-m2qgh5sv.js → cli-ftyypbg5.js} +1 -1
  26. package/dist/{cli-6ncjvx55.js → cli-fyzjmseb.js} +1 -1
  27. package/dist/{cli-0bh477zn.js → cli-h38rjv87.js} +117 -9
  28. package/dist/{cli-3p8fqcnh.js → cli-h93c9vb3.js} +1 -1
  29. package/dist/{cli-rdq7tzwm.js → cli-hyfw2j1e.js} +8 -8
  30. package/dist/{cli-bn5kevnf.js → cli-n85dqg6d.js} +2 -2
  31. package/dist/{cli-fz9kewbh.js → cli-nmqdc2t3.js} +1 -1
  32. package/dist/{cli-vy0fay6k.js → cli-nn47d7e8.js} +10 -10
  33. package/dist/{cli-mfe7f7b5.js → cli-pkht5hap.js} +1 -1
  34. package/dist/{cli-xftrvxag.js → cli-r99kc9yq.js} +1 -1
  35. package/dist/{cli-p0rakbrk.js → cli-rdyw3mct.js} +1 -1
  36. package/dist/{cli-sjyetj21.js → cli-s6vr7r2d.js} +1 -1
  37. package/dist/{cli-k9z4zd4p.js → cli-saw9a6f6.js} +2 -2
  38. package/dist/{cli-ash6xxk7.js → cli-shvfedvm.js} +2 -2
  39. package/dist/{cli-syhq378v.js → cli-t30yj95q.js} +1 -1
  40. package/dist/{cli-kmqgmcnb.js → cli-y2x52gs2.js} +4 -4
  41. package/dist/{cli-471qk6s2.js → cli-y8n5k515.js} +14 -14
  42. package/dist/{cli-3tnpj2bh.js → cli-z4wd2ftv.js} +1 -1
  43. package/dist/cli.js +12 -12
  44. package/dist/{codex-adapter-p6rmm7vv.js → codex-adapter-ttygw382.js} +1 -1
  45. package/dist/{codex-session-runner-8vd5t9t0.js → codex-session-runner-60b58vv3.js} +1 -1
  46. package/dist/{commands-7ems2p13.js → commands-edmdwa4x.js} +2 -2
  47. package/dist/{controllers-connect-bg33ae53.js → controllers-connect-5fym8sw1.js} +16 -16
  48. package/dist/{db-ys2rbdft.js → db-2y0mgstc.js} +3 -3
  49. package/dist/{db-w505dwa8.js → db-nn0hcmx5.js} +5 -1
  50. package/dist/{dispatcher-0s0mckqr.js → dispatcher-0a9wwjcx.js} +15 -15
  51. package/dist/{external-channel-5b23fv8z.js → external-channel-zhvt95n3.js} +2 -2
  52. package/dist/{grok-adapter-q9fghe5q.js → grok-adapter-qnr1hp2y.js} +2 -2
  53. package/dist/{handlers-mqrym347.js → handlers-dkcarkd0.js} +18 -18
  54. package/dist/{heartbeat-tww6yx1w.js → heartbeat-gz97r7ac.js} +17 -17
  55. package/dist/{hook-dx0pkvar.js → hook-1mmdgjqs.js} +1 -1
  56. package/dist/{hook-pvmh9b6m.js → hook-x8rthca4.js} +1 -1
  57. package/dist/{http-k2ejbt05.js → http-r8f85xr9.js} +111 -65
  58. package/dist/{index-pqfwh9nn.js → index-5jecgp3n.js} +17 -17
  59. package/dist/{index-h2ek08d6.js → index-aex0eba9.js} +15 -15
  60. package/dist/{index-x5aktmp8.js → index-smxyk04s.js} +1 -1
  61. package/dist/{index-dgz61nff.js → index-vzvfqzfj.js} +15 -15
  62. package/dist/{index-2z710v4x.js → index-y7rbcrc1.js} +8 -8
  63. package/dist/{keepalive-nhvpw10z.js → keepalive-64c0f34c.js} +3 -3
  64. package/dist/{lead-jb6sf69z.js → lead-b1da0f97.js} +23 -23
  65. package/dist/{maintenance-agakc519.js → maintenance-mnxpar12.js} +1 -1
  66. package/dist/{oauth-refresh-sweep-4dcbhtn0.js → oauth-refresh-sweep-36hs2v4h.js} +2 -2
  67. package/dist/{onboard-4n2kwa4v.js → onboard-xes8de61.js} +2 -2
  68. package/dist/{otel-impl-tfa12080.js → otel-impl-fzc57kcq.js} +1 -1
  69. package/dist/{pricing-refresh-3cqx3f3x.js → pricing-refresh-1nek2w7b.js} +3 -3
  70. package/dist/{rbac-roles-y6zbfzve.js → rbac-roles-sqefb5hw.js} +2 -2
  71. package/dist/{rbac-roles-9fae5rnv.js → rbac-roles-ttyfaep2.js} +1 -1
  72. package/dist/{render-v2-emawjvc4.js → render-v2-vb527jwn.js} +3 -3
  73. package/dist/{resume-zhgts7h7.js → resume-jcdzg1bn.js} +5 -5
  74. package/dist/{session-access-cm823e06.js → session-access-zsp4wsp0.js} +4 -4
  75. package/dist/{setup-tkp99w1e.js → setup-1nb2zqd3.js} +1 -1
  76. package/dist/{work-objects-f722h263.js → work-objects-61w08fwr.js} +18 -18
  77. package/dist/{worker-p7f336rq.js → worker-c1e68hpa.js} +23 -23
  78. package/openapi.json +393 -1
  79. package/package.json +2 -2
  80. package/src/apps/catalog.generated.json +3 -2
  81. package/src/apps/definition.ts +152 -4
  82. package/src/apps/row-store.ts +19 -7
  83. package/src/apps/schema-migrate.ts +14 -6
  84. package/src/apps/script-types.ts +1 -0
  85. package/src/apps/sync.ts +20 -19
  86. package/src/be/db/tasks/read.ts +112 -0
  87. package/src/be/db.ts +2 -0
  88. package/src/be/swarm-config-guard.ts +15 -0
  89. package/src/http/apps.ts +15 -2
  90. package/src/http/status.ts +49 -43
  91. package/src/http/tasks.ts +47 -0
  92. package/src/providers/acp-targets.ts +1 -1
  93. package/src/providers/amp-adapter.ts +1 -1
  94. package/src/telemetry.ts +15 -3
  95. package/src/tests/app-row-tools.test.ts +25 -0
  96. package/src/tests/apps-json-column.test.ts +611 -0
  97. package/src/tests/apps-script-types.test.ts +2 -0
  98. package/src/tests/apps-sync-engine.test.ts +42 -0
  99. package/src/tests/db-task-read-contract.test.ts +4 -0
  100. package/src/tests/db-task-write-contract.test.ts +2 -0
  101. package/src/tests/docs-index-footprint.test.js +2 -2
  102. package/src/tests/runner-harness-telemetry-env.test.ts +6 -0
  103. package/src/tests/status.test.ts +158 -9
  104. package/src/tests/task-lineage.test.ts +180 -0
  105. package/src/tests/telemetry-init.test.ts +62 -5
  106. package/src/tests/user-token-rest-auth.test.ts +38 -0
  107. package/src/tools/app-rows.ts +5 -0
  108. package/templates/skills/apps/SKILL.md +3 -2
  109. package/templates/skills/apps/content.md +3 -2
@@ -111,6 +111,7 @@ value:ReservedAgentRoleError
111
111
  value:SESSION_ACTIVITY_REFRESH_MIN_INTERVAL_MS
112
112
  value:SKILL_FILE_LIMITS
113
113
  value:SUPERSEDED_RESUME_REPAIR_WINDOW_MS
114
+ value:TASK_LINEAGE_LIMITS
114
115
  value:UNATTRIBUTED_USER_ID
115
116
  value:__resetSqliteVecExtensionPathCacheForTests
116
117
  value:abandonSlackOutcomeDelivery
@@ -412,6 +413,7 @@ value:getSystemDefaultSkills
412
413
  value:getTaskAttachments
413
414
  value:getTaskById
414
415
  value:getTaskByWorkflowRunStepId
416
+ value:getTaskLineage
415
417
  value:getTaskStats
416
418
  value:getTasksByAgentId
417
419
  value:getTasksByStatus
@@ -612,6 +614,7 @@ type:ExistingTrackerContextWorkReason
612
614
  type:PendingChatRelay
613
615
  type:TaskFilters
614
616
  value:SUPERSEDED_RESUME_REPAIR_WINDOW_MS
617
+ value:TASK_LINEAGE_LIMITS
615
618
  value:findCompletedTaskInThread
616
619
  value:findExistingLinearTrackerContextWork
617
620
  value:findRecentCancelledTaskInThread
@@ -639,6 +642,7 @@ value:getSlackTasksMissingTree
639
642
  value:getSupersededTasksWithUnsettledDependents
640
643
  value:getSupersededTasksWithoutResume
641
644
  value:getTaskById
645
+ value:getTaskLineage
642
646
  value:getTaskStats
643
647
  value:getTasksByAgentId
644
648
  value:getTasksByStatus
@@ -134,6 +134,7 @@ value:ReservedAgentRoleError
134
134
  value:SESSION_ACTIVITY_REFRESH_MIN_INTERVAL_MS
135
135
  value:SKILL_FILE_LIMITS
136
136
  value:SUPERSEDED_RESUME_REPAIR_WINDOW_MS
137
+ value:TASK_LINEAGE_LIMITS
137
138
  value:UNATTRIBUTED_USER_ID
138
139
  value:__resetSqliteVecExtensionPathCacheForTests
139
140
  value:abandonSlackOutcomeDelivery
@@ -435,6 +436,7 @@ value:getSystemDefaultSkills
435
436
  value:getTaskAttachments
436
437
  value:getTaskById
437
438
  value:getTaskByWorkflowRunStepId
439
+ value:getTaskLineage
438
440
  value:getTaskStats
439
441
  value:getTasksByAgentId
440
442
  value:getTasksByStatus
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(436);
107
- expect(generatedKeys).toHaveLength(436);
106
+ expect(operationKeys).toHaveLength(437);
107
+ expect(generatedKeys).toHaveLength(437);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -11,6 +11,12 @@ const TELEMETRY_PATH = join(import.meta.dir, "..", "telemetry.ts");
11
11
  // leaves the process), and print the environment the event carries.
12
12
  const BOOT_SCRIPT = `
13
13
  const t = await import(${JSON.stringify(TELEMETRY_PATH)});
14
+ const { mkdtempSync, rmSync } = await import("node:fs");
15
+ const { tmpdir } = await import("node:os");
16
+ const { pathToFileURL } = await import("node:url");
17
+ const root = mkdtempSync(tmpdir() + "/telemetry-package-");
18
+ t.getTelemetryEnvironment(pathToFileURL(root + "/"));
19
+ rmSync(root, { recursive: true, force: true });
14
20
  let body;
15
21
  globalThis.fetch = async (_url, init) => {
16
22
  body = JSON.parse(init.body);
@@ -115,7 +115,6 @@ const ENV_KEYS_TO_RESET = [
115
115
  "GOOGLE_APPLICATION_CREDENTIALS",
116
116
  "AGENTMAIL_API_KEY",
117
117
  "AGENTMAIL_DISABLE",
118
- "SWARM_VERIFY_TTL_MS",
119
118
  "ANONYMIZED_TELEMETRY",
120
119
  ];
121
120
 
@@ -314,7 +313,7 @@ describe("setup milestones", () => {
314
313
  expect(getMilestone(payload, "harness").state).toBe("verified");
315
314
  });
316
315
 
317
- test("provider rollup counts every agent with a fresh passing report in verifiedWorkers", async () => {
316
+ test("provider rollup counts every agent with a passing report in verifiedWorkers", async () => {
318
317
  const verified = await createAgent({
319
318
  name: "w-verified",
320
319
  isLead: false,
@@ -376,7 +375,7 @@ describe("setup milestones", () => {
376
375
 
377
376
  // ─── Multi-provider fleet rollup ─────────────────────────────────────────
378
377
  describe("harness — multi-provider fleet aggregate", () => {
379
- test("`verified` when every provider in the fleet has a fresh passing live test", async () => {
378
+ test("`verified` when every provider in the fleet has a passing live test", async () => {
380
379
  const a = await createAgent({
381
380
  name: "claude-w",
382
381
  isLead: false,
@@ -423,8 +422,30 @@ describe("setup milestones", () => {
423
422
 
424
423
  const m = getMilestone(await buildStatusPayload(), "harness");
425
424
  expect(m.state).toBe("configured");
426
- expect(m.hint).toContain("claude");
427
- expect(m.hint).toContain("codex");
425
+ // Only the provider that needs attention is listed.
426
+ expect(m.hint).toBe("codex: not live-tested");
427
+ });
428
+
429
+ test("an all-verified fleet carries no hint, however old the passing tests are", async () => {
430
+ const a = await createAgent({
431
+ name: "claude-w",
432
+ isLead: false,
433
+ status: "idle",
434
+ capabilities: [],
435
+ });
436
+ const b = await createAgent({
437
+ name: "codex-w",
438
+ isLead: false,
439
+ status: "idle",
440
+ capabilities: [],
441
+ });
442
+ const old = { ok: true, error: null, latency_ms: 12, testedAt: Date.now() - 6 * 3_600_000 };
443
+ await seedCredStatus(a.id, "claude", { ready: true, satisfiedBy: "env", liveTest: old });
444
+ await seedCredStatus(b.id, "codex", { ready: true, satisfiedBy: "file", liveTest: old });
445
+
446
+ const m = getMilestone(await buildStatusPayload(), "harness");
447
+ expect(m.state).toBe("verified");
448
+ expect(m.hint).toBeUndefined();
428
449
  });
429
450
 
430
451
  test("`unverified` when any provider in the fleet reports blocked credentials", async () => {
@@ -1062,6 +1083,135 @@ describe("computeHealth (Phase 2)", () => {
1062
1083
  expect(payload.health).toBe("degraded");
1063
1084
  });
1064
1085
 
1086
+ test("`ok` when live workers' last passing live test is hours old", async () => {
1087
+ const lead = await createAgent({
1088
+ name: "lead-old",
1089
+ isLead: true,
1090
+ status: "idle",
1091
+ capabilities: [],
1092
+ });
1093
+ const worker = await createAgent({
1094
+ name: "worker-old",
1095
+ isLead: false,
1096
+ status: "idle",
1097
+ capabilities: [],
1098
+ });
1099
+ await updateAgentActivity(lead.id);
1100
+ await updateAgentActivity(worker.id);
1101
+ const oldPass = { ok: true, error: null, latency_ms: 12, testedAt: Date.now() - 6 * 3_600_000 };
1102
+ await seedCredStatus(lead.id, "claude", { ready: true, satisfiedBy: "env", liveTest: oldPass });
1103
+ await seedCredStatus(worker.id, "claude", {
1104
+ ready: true,
1105
+ satisfiedBy: "env",
1106
+ liveTest: oldPass,
1107
+ });
1108
+
1109
+ const payload = await buildStatusPayload();
1110
+ expect(getMilestone(payload, "workers").state).toBe("verified");
1111
+ expect(getMilestone(payload, "harness").state).toBe("verified");
1112
+ expect(payload.health).toBe("ok");
1113
+ });
1114
+
1115
+ test("`degraded` when the provider's latest live test failed, even after an older pass", async () => {
1116
+ const passed = await createAgent({
1117
+ name: "worker-pass",
1118
+ isLead: false,
1119
+ status: "idle",
1120
+ capabilities: [],
1121
+ });
1122
+ const failed = await createAgent({
1123
+ name: "worker-fail",
1124
+ isLead: false,
1125
+ status: "idle",
1126
+ capabilities: [],
1127
+ });
1128
+ await updateAgentActivity(passed.id);
1129
+ await updateAgentActivity(failed.id);
1130
+ await seedCredStatus(passed.id, "claude", {
1131
+ ready: true,
1132
+ satisfiedBy: "env",
1133
+ liveTest: { ok: true, error: null, latency_ms: 12, testedAt: Date.now() - 60_000 },
1134
+ });
1135
+ await seedCredStatus(failed.id, "claude", {
1136
+ ready: true,
1137
+ satisfiedBy: "env",
1138
+ liveTest: { ok: false, error: "HTTP 401", latency_ms: 12, testedAt: Date.now() },
1139
+ });
1140
+
1141
+ const payload = await buildStatusPayload();
1142
+ const harness = getMilestone(payload, "harness");
1143
+ expect(harness.state).toBe("configured");
1144
+ expect(harness.hint).toBe("claude: live test failed");
1145
+ expect(payload.health).toBe("degraded");
1146
+ });
1147
+
1148
+ test("an offline agent's newer failed live test does not override an online pass", async () => {
1149
+ const online = await createAgent({
1150
+ name: "worker-online",
1151
+ isLead: false,
1152
+ status: "idle",
1153
+ capabilities: [],
1154
+ });
1155
+ const offline = await createAgent({
1156
+ name: "worker-offline",
1157
+ isLead: false,
1158
+ status: "offline",
1159
+ capabilities: [],
1160
+ });
1161
+ await updateAgentActivity(online.id);
1162
+ await seedCredStatus(online.id, "claude", {
1163
+ ready: true,
1164
+ satisfiedBy: "env",
1165
+ liveTest: { ok: true, error: null, latency_ms: 12, testedAt: Date.now() - 6 * 3_600_000 },
1166
+ });
1167
+ await seedCredStatus(offline.id, "claude", {
1168
+ ready: true,
1169
+ satisfiedBy: "env",
1170
+ liveTest: { ok: false, error: "HTTP 401", latency_ms: 12, testedAt: Date.now() },
1171
+ });
1172
+
1173
+ expect(await rollupCredStatusForProvider("claude")).toMatchObject({
1174
+ state: "verified",
1175
+ workers: 1,
1176
+ reports: 1,
1177
+ });
1178
+ const harness = getMilestone(await buildStatusPayload(), "harness");
1179
+ expect(harness.state).toBe("verified");
1180
+ expect(harness.hint).toBeUndefined();
1181
+ });
1182
+
1183
+ test("a provider reported only by offline agents does not degrade the harness", async () => {
1184
+ const online = await createAgent({
1185
+ name: "claude-online",
1186
+ isLead: false,
1187
+ status: "idle",
1188
+ capabilities: [],
1189
+ });
1190
+ const offline = await createAgent({
1191
+ name: "codex-offline",
1192
+ isLead: false,
1193
+ status: "offline",
1194
+ capabilities: [],
1195
+ });
1196
+ await updateAgentActivity(online.id);
1197
+ await seedCredStatus(online.id, "claude", {
1198
+ ready: true,
1199
+ satisfiedBy: "env",
1200
+ liveTest: { ok: true, error: null, latency_ms: 12, testedAt: Date.now() },
1201
+ });
1202
+ await seedCredStatus(offline.id, "codex", {
1203
+ ready: false,
1204
+ missing: ["OPENAI_API_KEY"],
1205
+ satisfiedBy: null,
1206
+ liveTest: { ok: false, error: "HTTP 401", latency_ms: 12, testedAt: Date.now() },
1207
+ });
1208
+
1209
+ const harness = getMilestone(await buildStatusPayload(), "harness");
1210
+ expect(harness.state).toBe("verified");
1211
+ expect(harness.hint).toBeUndefined();
1212
+ expect((harness.providers ?? []).map((p) => p.provider)).toEqual(["claude"]);
1213
+ });
1214
+
1065
1215
  test("`ok` when workers are `configured` (heartbeat drift is a runtime concern, not setup health)", async () => {
1066
1216
  // Workers in `configured` state means agents exist but haven't posted a
1067
1217
  // heartbeat in the last 5 minutes. This is surfaced on /agents and the
@@ -1166,9 +1316,8 @@ describe("worker-reported live test drives harness.state", () => {
1166
1316
  expect(getMilestone(await buildStatusPayload(), "harness").state).toBe("verified");
1167
1317
  });
1168
1318
 
1169
- test("a stale live test (older than SWARM_VERIFY_TTL_MS) drops to `configured`", async () => {
1319
+ test("an old passing live test keeps harness `verified`", async () => {
1170
1320
  process.env.HARNESS_PROVIDER = "claude";
1171
- process.env.SWARM_VERIFY_TTL_MS = "1000"; // 1s — anything older is stale
1172
1321
  const a = await createAgent({ name: "w-stl", isLead: false, status: "idle", capabilities: [] });
1173
1322
  await seedCredStatus(a.id, "claude", {
1174
1323
  ready: true,
@@ -1176,10 +1325,10 @@ describe("worker-reported live test drives harness.state", () => {
1176
1325
  ok: true,
1177
1326
  error: null,
1178
1327
  latency_ms: 80,
1179
- testedAt: Date.now() - 60_000, // 60s ago, well beyond TTL
1328
+ testedAt: Date.now() - 24 * 3_600_000, // a day ago: workers only re-test on change
1180
1329
  },
1181
1330
  });
1182
- expect(getMilestone(await buildStatusPayload(), "harness").state).toBe("configured");
1331
+ expect(getMilestone(await buildStatusPayload(), "harness").state).toBe("verified");
1183
1332
  });
1184
1333
 
1185
1334
  test("a failed live test still leaves harness `configured` if presence is ready", async () => {
@@ -0,0 +1,180 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createTaskExtended,
7
+ getDbClient,
8
+ getTaskLineage,
9
+ initDb,
10
+ TASK_LINEAGE_LIMITS,
11
+ } from "../be/db";
12
+
13
+ const TEST_DB_PATH = "./test-task-lineage.sqlite";
14
+
15
+ async function removeDb() {
16
+ for (const suffix of ["", "-wal", "-shm"]) {
17
+ try {
18
+ await unlink(`${TEST_DB_PATH}${suffix}`);
19
+ } catch {}
20
+ }
21
+ }
22
+
23
+ describe("getTaskLineage", () => {
24
+ let agentId: string;
25
+
26
+ beforeAll(async () => {
27
+ await removeDb();
28
+ initDb(TEST_DB_PATH);
29
+ const agent = await createAgent({
30
+ id: crypto.randomUUID(),
31
+ name: "Lineage Test Agent",
32
+ isLead: false,
33
+ status: "idle",
34
+ });
35
+ agentId = agent.id;
36
+ });
37
+
38
+ /** A straight parentTaskId chain, root first. */
39
+ async function createChain(label: string, length: number) {
40
+ const chain = [await createTaskExtended(`${label} 0`, { agentId })];
41
+ for (let i = 1; i < length; i++) {
42
+ chain.push(
43
+ await createTaskExtended(`${label} ${i}`, { agentId, parentTaskId: chain[i - 1]!.id }),
44
+ );
45
+ }
46
+ return chain;
47
+ }
48
+
49
+ afterAll(async () => {
50
+ closeDb();
51
+ await removeDb();
52
+ });
53
+
54
+ test("unknown task: null", async () => {
55
+ expect(await getTaskLineage("no-such-task")).toBeNull();
56
+ });
57
+
58
+ test("from a grandchild: walks up to the root, then the whole tree", async () => {
59
+ const root = await createTaskExtended("root", { agentId });
60
+ const childA = await createTaskExtended("child a", { agentId, parentTaskId: root.id });
61
+ const childB = await createTaskExtended("child b", { agentId, parentTaskId: root.id });
62
+ const grandchild = await createTaskExtended("grandchild", {
63
+ agentId,
64
+ parentTaskId: childA.id,
65
+ });
66
+ const greatGrandchild = await createTaskExtended("great-grandchild", {
67
+ agentId,
68
+ parentTaskId: grandchild.id,
69
+ });
70
+ const unrelated = await createTaskExtended("unrelated", { agentId });
71
+
72
+ const lineage = await getTaskLineage(grandchild.id);
73
+ expect(lineage).not.toBeNull();
74
+ expect(lineage!.rootTaskId).toBe(root.id);
75
+ expect(lineage!.truncated).toBe(false);
76
+ expect(lineage!.tasks[0]!.id).toBe(root.id);
77
+ const ids = new Set(lineage!.tasks.map((t) => t.id));
78
+ expect(ids).toEqual(
79
+ new Set([root.id, childA.id, childB.id, grandchild.id, greatGrandchild.id]),
80
+ );
81
+ expect(ids.has(unrelated.id)).toBe(false);
82
+ expect(lineage!.tasks.find((t) => t.id === childA.id)!.parentTaskId).toBe(root.id);
83
+ });
84
+
85
+ test("a node cap keeps the path to the task and its own subtree, and says it cut", async () => {
86
+ const root = await createTaskExtended("wide root", { agentId });
87
+ const children = [];
88
+ for (let i = 0; i < 6; i++) {
89
+ children.push(await createTaskExtended(`fan-out ${i}`, { agentId, parentTaskId: root.id }));
90
+ }
91
+ const last = children[children.length - 1]!;
92
+ const leaf = await createTaskExtended("leaf under the last child", {
93
+ agentId,
94
+ parentTaskId: last.id,
95
+ });
96
+
97
+ const lineage = await getTaskLineage(last.id, { maxNodes: 3 });
98
+ expect(lineage!.truncated).toBe(true);
99
+ const ids = new Set(lineage!.tasks.map((t) => t.id));
100
+ expect(ids.has(root.id)).toBe(true);
101
+ expect(ids.has(last.id)).toBe(true);
102
+ expect(ids.has(leaf.id)).toBe(true);
103
+ expect(lineage!.tasks.length).toBeLessThanOrEqual(2 * 3 + 2);
104
+ });
105
+
106
+ test("a depth cap stops the walk down", async () => {
107
+ const root = await createTaskExtended("deep root", { agentId });
108
+ let parent = root;
109
+ for (let i = 0; i < 5; i++) {
110
+ parent = await createTaskExtended(`level ${i + 1}`, { agentId, parentTaskId: parent.id });
111
+ }
112
+ const lineage = await getTaskLineage(root.id, { maxDepth: 2 });
113
+ expect(lineage!.tasks).toHaveLength(3);
114
+ expect(lineage!.truncated).toBe(true);
115
+ });
116
+
117
+ test("a chain past the default depth cap says it cut", async () => {
118
+ const chain = await createChain("default-depth", 32);
119
+ const lineage = await getTaskLineage(chain[0]!.id);
120
+ expect(lineage!.tasks).toHaveLength(TASK_LINEAGE_LIMITS.maxDepth + 1);
121
+ expect(lineage!.truncated).toBe(true);
122
+ });
123
+
124
+ test("a chain that ends exactly at the depth cap is complete", async () => {
125
+ const chain = await createChain("exact-depth", 3);
126
+ const lineage = await getTaskLineage(chain[0]!.id, { maxDepth: 2 });
127
+ expect(lineage!.tasks).toHaveLength(3);
128
+ expect(lineage!.truncated).toBe(false);
129
+ });
130
+
131
+ test("every ancestor of the task survives a node cap of 1", async () => {
132
+ const chain = await createChain("path-node-cap", 5);
133
+ const leaf = chain[chain.length - 1]!;
134
+ const lineage = await getTaskLineage(leaf.id, { maxNodes: 1 });
135
+ expect(new Set(lineage!.tasks.map((t) => t.id))).toEqual(new Set(chain.map((t) => t.id)));
136
+ // Nothing in the tree is left out, so the result is complete.
137
+ expect(lineage!.truncated).toBe(false);
138
+ const ids = new Set(lineage!.tasks.map((t) => t.id));
139
+ for (const task of lineage!.tasks) {
140
+ if (task.id !== lineage!.rootTaskId) expect(ids.has(task.parentTaskId!)).toBe(true);
141
+ }
142
+ });
143
+
144
+ test("a deep focused path survives both caps, and a cut sibling still flags", async () => {
145
+ const chain = await createChain("deep-focus", 8);
146
+ const sibling = await createTaskExtended("off-path sibling", {
147
+ agentId,
148
+ parentTaskId: chain[1]!.id,
149
+ });
150
+ const leaf = chain[chain.length - 1]!;
151
+ const lineage = await getTaskLineage(leaf.id, { maxNodes: 1, maxDepth: 1 });
152
+ const ids = new Set(lineage!.tasks.map((t) => t.id));
153
+ for (const task of chain) expect(ids.has(task.id)).toBe(true);
154
+ expect(ids.has(sibling.id)).toBe(false);
155
+ expect(lineage!.truncated).toBe(true);
156
+ });
157
+
158
+ test("a fan-out exactly at the node cap is complete; one more child cuts", async () => {
159
+ const root = await createTaskExtended("cap root", { agentId });
160
+ for (let i = 0; i < 3; i++) {
161
+ await createTaskExtended(`cap child ${i}`, { agentId, parentTaskId: root.id });
162
+ }
163
+ const atCap = await getTaskLineage(root.id, { maxNodes: 4 });
164
+ expect(atCap!.tasks).toHaveLength(4);
165
+ expect(atCap!.truncated).toBe(false);
166
+ const overCap = await getTaskLineage(root.id, { maxNodes: 3 });
167
+ expect(overCap!.tasks).toHaveLength(3);
168
+ expect(overCap!.truncated).toBe(true);
169
+ });
170
+
171
+ test("a parentTaskId cycle ends at the ancestor cap instead of looping", async () => {
172
+ const a = await createTaskExtended("cycle a", { agentId });
173
+ const b = await createTaskExtended("cycle b", { agentId, parentTaskId: a.id });
174
+ await getDbClient().run("UPDATE agent_tasks SET parentTaskId = ? WHERE id = ?", [b.id, a.id]);
175
+
176
+ const lineage = await getTaskLineage(a.id, { maxAncestors: 5, maxDepth: 4 });
177
+ expect(lineage!.truncated).toBe(true);
178
+ expect(new Set(lineage!.tasks.map((t) => t.id))).toEqual(new Set([a.id, b.id]));
179
+ });
180
+ });
@@ -1,4 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
2
6
  import pkg from "../../package.json";
3
7
  import {
4
8
  _getInstallationIdForTests,
@@ -14,6 +18,7 @@ import {
14
18
  _resolveIntegrationProvider,
15
19
  _resolveIntegrationType,
16
20
  emitIntegrationConnected,
21
+ getTelemetryEnvironment,
17
22
  initTelemetry,
18
23
  telemetry,
19
24
  track,
@@ -686,8 +691,11 @@ describe("initTelemetry", () => {
686
691
  const originalFetch = globalThis.fetch;
687
692
  const originalNodeEnv = process.env.NODE_ENV;
688
693
  let captured: Record<string, unknown> | null = null;
694
+ let packageRoot: URL;
689
695
 
690
696
  beforeEach(() => {
697
+ packageRoot = pathToFileURL(`${mkdtempSync(join(tmpdir(), "telemetry-env-"))}/`);
698
+ process.env.NODE_ENV = "production";
691
699
  captured = null;
692
700
  globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
693
701
  captured = init?.body ? JSON.parse(init.body) : null;
@@ -697,14 +705,59 @@ describe("initTelemetry", () => {
697
705
  });
698
706
 
699
707
  afterEach(() => {
708
+ rmSync(packageRoot, { recursive: true, force: true });
700
709
  globalThis.fetch = originalFetch;
701
710
  delete process.env.DESPLEGA_TELEMETRY_ENV;
702
711
  if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
703
712
  else process.env.NODE_ENV = originalNodeEnv;
704
713
  });
705
714
 
715
+ test("track detects the real checkout through the default package root with an unprimed cache", async () => {
716
+ await initTelemetry(
717
+ "api-server",
718
+ async (key) => (key === "telemetry_installation_id" ? "install_checkout_env" : undefined),
719
+ async () => {},
720
+ { generateIfMissing: true },
721
+ );
722
+
723
+ track({ event: "test.event", properties: {} });
724
+ await new Promise((r) => setTimeout(r, 0));
725
+
726
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
727
+ expect(metadata.environment).toBe("development");
728
+ });
729
+
730
+ test("a .git directory marks a checkout as development", () => {
731
+ mkdirSync(new URL(".git", packageRoot));
732
+ expect(getTelemetryEnvironment(packageRoot)).toBe("development");
733
+ rmSync(new URL(".git", packageRoot), { recursive: true });
734
+ expect(getTelemetryEnvironment(packageRoot)).toBe("development");
735
+ });
736
+
737
+ test("a .git file marks a worktree as development", async () => {
738
+ await Bun.write(new URL(".git", packageRoot), "gitdir: /some/worktree");
739
+ expect(getTelemetryEnvironment(packageRoot)).toBe("development");
740
+ });
741
+
742
+ test("caches the checkout check across events", () => {
743
+ expect(getTelemetryEnvironment(packageRoot)).toBe("production");
744
+ mkdirSync(new URL(".git", packageRoot));
745
+ expect(getTelemetryEnvironment(packageRoot)).toBe("production");
746
+ process.env.DESPLEGA_TELEMETRY_ENV = "staging";
747
+ expect(getTelemetryEnvironment(packageRoot)).toBe("staging");
748
+ });
749
+
750
+ test("an unusable package root falls back to production without throwing", () => {
751
+ expect(getTelemetryEnvironment(new URL("data:text/plain,unavailable"))).toBe("production");
752
+ });
753
+
754
+ test("compiled binary paths default to production without throwing", () => {
755
+ expect(getTelemetryEnvironment(new URL("file:///$bunfs/root/"))).toBe("production");
756
+ });
757
+
706
758
  test("defaults to production even when NODE_ENV is development", async () => {
707
759
  process.env.NODE_ENV = "development";
760
+ expect(getTelemetryEnvironment(packageRoot)).toBe("production");
708
761
  await initTelemetry(
709
762
  "api-server",
710
763
  async (key) => (key === "telemetry_installation_id" ? "install_default_env" : undefined),
@@ -719,9 +772,11 @@ describe("initTelemetry", () => {
719
772
  expect(metadata.environment).toBe("production");
720
773
  });
721
774
 
722
- test("uses DESPLEGA_TELEMETRY_ENV when set", async () => {
723
- process.env.NODE_ENV = "production";
724
- process.env.DESPLEGA_TELEMETRY_ENV = "development";
775
+ test("uses DESPLEGA_TELEMETRY_ENV over a git checkout and NODE_ENV=test", async () => {
776
+ mkdirSync(new URL(".git", packageRoot));
777
+ process.env.NODE_ENV = "test";
778
+ process.env.DESPLEGA_TELEMETRY_ENV = "staging";
779
+ expect(getTelemetryEnvironment(packageRoot)).toBe("staging");
725
780
  await initTelemetry(
726
781
  "api-server",
727
782
  async (key) => (key === "telemetry_installation_id" ? "install_explicit_env" : undefined),
@@ -733,11 +788,13 @@ describe("initTelemetry", () => {
733
788
  await new Promise((r) => setTimeout(r, 0));
734
789
 
735
790
  const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
736
- expect(metadata.environment).toBe("development");
791
+ expect(metadata.environment).toBe("staging");
737
792
  });
738
793
 
739
- test("preserves NODE_ENV=test when telemetry env is unset", async () => {
794
+ test("preserves NODE_ENV=test over a git checkout when telemetry env is unset", async () => {
795
+ mkdirSync(new URL(".git", packageRoot));
740
796
  process.env.NODE_ENV = "test";
797
+ expect(getTelemetryEnvironment(packageRoot)).toBe("test");
741
798
  await initTelemetry(
742
799
  "api-server",
743
800
  async (key) => (key === "telemetry_installation_id" ? "install_test_env" : undefined),
@@ -110,6 +110,44 @@ describe("normal REST API user-bound token auth", () => {
110
110
  expect(body.requestedByUserId).toBeUndefined();
111
111
  });
112
112
 
113
+ for (const caller of ["user", "operator", "agent"] as const) {
114
+ for (const explicit of [false, true]) {
115
+ test(`REST ${caller} follow-up ${explicit ? "keeps an explicit schema" : caller === "agent" ? "inherits the parent schema" : "does not inherit the parent schema"}`, async () => {
116
+ const user = await createUser({ name: "Schema follow-up user" });
117
+ const agent = await createAgent({ name: "Schema worker", status: "idle" });
118
+ const parentSchema = { type: "object", properties: { options: { type: "array" } } };
119
+ const explicitSchema = { type: "object", properties: { answer: { type: "string" } } };
120
+ const parent = await createTaskExtended("schema-carrying worker task", {
121
+ agentId: agent.id,
122
+ requestedByUserId: user.id,
123
+ outputSchema: parentSchema,
124
+ });
125
+ const token =
126
+ caller === "user" ? (await mintToken(user.id, "rest", ACTOR)).plaintext : API_KEY;
127
+ const res = await fetch(`http://localhost:${port}/api/tasks`, {
128
+ method: "POST",
129
+ headers: {
130
+ Authorization: `Bearer ${token}`,
131
+ "Content-Type": "application/json",
132
+ ...(caller === "agent" ? { "X-Agent-ID": agent.id } : {}),
133
+ },
134
+ body: JSON.stringify({
135
+ task: "Continue the session",
136
+ parentTaskId: parent.id,
137
+ source: "ui",
138
+ ...(explicit ? { outputSchema: explicitSchema } : {}),
139
+ }),
140
+ });
141
+
142
+ expect(res.status).toBe(201);
143
+ const body = (await res.json()) as { id: string; outputSchema?: unknown };
144
+ expect(body.outputSchema).toEqual(
145
+ explicit ? explicitSchema : caller === "agent" ? parentSchema : undefined,
146
+ );
147
+ });
148
+ }
149
+ }
150
+
113
151
  test("global API key caller cannot spoof requestedByUserId via body — falls back to owned task context", async () => {
114
152
  const legitRequester = await createUser({ name: "Legit Requester" });
115
153
  const attacker = await createUser({ name: "Attacker" });
@@ -53,6 +53,11 @@ function rowFilters(model: ModelDef, equals: Record<string, string | number | bo
53
53
  { path: `equals.${column}`, message: "Unknown or hidden column." },
54
54
  ]);
55
55
  }
56
+ if (declared?.kind === "json") {
57
+ throw new AppRowValidationError([
58
+ { path: `equals.${column}`, message: "json columns cannot be filtered" },
59
+ ]);
60
+ }
56
61
  if (!validValue(declared ?? { kind: SYSTEM_COLUMN_KINDS[column]! }, value)) {
57
62
  throw new AppRowValidationError([
58
63
  { path: `equals.${column}`, message: "Filter value must match the column kind." },