@desplega.ai/agent-swarm 2.4.1 → 2.4.3

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 (153) hide show
  1. package/dist/{acp-adapter-zvzr538d.js → acp-adapter-1885dpr7.js} +4 -4
  2. package/dist/{actions-jhtqdw69.js → actions-pn76n32q.js} +18 -18
  3. package/dist/{amp-adapter-bd84rhs2.js → amp-adapter-af84rx1d.js} +3 -3
  4. package/dist/{app-kreq1gwg.js → app-6sv8mhzm.js} +3 -3
  5. package/dist/{assistant-3k22sbsq.js → assistant-sc2tf825.js} +20 -20
  6. package/dist/{boot-reembed-f6z923ny.js → boot-reembed-gvxjs2py.js} +1 -1
  7. package/dist/{boot-reembed-e53x6b9v.js → boot-reembed-y3m0ydzj.js} +1 -1
  8. package/dist/{boot-scrub-sweep-0538vsb7.js → boot-scrub-sweep-1j9mk05f.js} +3 -3
  9. package/dist/{boot-seeds-6wxqkfk2.js → boot-seeds-37sp626b.js} +4 -4
  10. package/dist/{claude-adapter-gfts5044.js → claude-adapter-rp0jtnwr.js} +4 -4
  11. package/dist/{claude-managed-setup-h00hsgtz.js → claude-managed-setup-rdt248k7.js} +1 -1
  12. package/dist/{claude-sdk-session-56besmfz.js → claude-sdk-session-53rcw5we.js} +9822 -9749
  13. package/dist/{cli-3tnpj2bh.js → cli-038fga5k.js} +2 -2
  14. package/dist/{cli-kmqgmcnb.js → cli-0dxxwfkt.js} +8 -8
  15. package/dist/{cli-471qk6s2.js → cli-1012xb7j.js} +28 -28
  16. package/dist/{cli-sv5gb0yb.js → cli-1eszr343.js} +1 -1
  17. package/dist/{cli-7225gwj7.js → cli-1m17dc9d.js} +1 -1
  18. package/dist/{cli-xftrvxag.js → cli-1ma335fz.js} +3 -3
  19. package/dist/{cli-s48yvzb8.js → cli-1n9gdg56.js} +4 -4
  20. package/dist/{cli-ash6xxk7.js → cli-1xsraagq.js} +3 -3
  21. package/dist/{cli-xbzvet96.js → cli-36ckw0wx.js} +5 -4
  22. package/dist/{cli-mhywgrrz.js → cli-4sydckyj.js} +1 -1
  23. package/dist/{cli-3r448dqn.js → cli-4yef8ghv.js} +3 -3
  24. package/dist/{cli-g0hgtz4p.js → cli-58vwn100.js} +3 -3
  25. package/dist/{cli-pw7v69vp.js → cli-625tb7aq.js} +1 -1
  26. package/dist/{cli-m2qgh5sv.js → cli-6aen3ezb.js} +1 -1
  27. package/dist/{cli-z071fb95.js → cli-6eyncg0y.js} +6 -6
  28. package/dist/{cli-0bh477zn.js → cli-73ezfhha.js} +120 -12
  29. package/dist/{cli-x5mq1fyq.js → cli-74c1wpm0.js} +3 -3
  30. package/dist/{cli-b1dfr1m4.js → cli-7a6p4e65.js} +1 -1
  31. package/dist/{cli-fpmsqq59.js → cli-836ehxmt.js} +1 -1
  32. package/dist/{cli-1pa3rz69.js → cli-8vverzf7.js} +1 -1
  33. package/dist/{cli-rdq7tzwm.js → cli-8y99kfey.js} +11 -11
  34. package/dist/{cli-z1ew8vbq.js → cli-aa3tb29r.js} +10 -5
  35. package/dist/{cli-6ncjvx55.js → cli-aypbg9r2.js} +1 -1
  36. package/dist/{cli-3kh4kpqy.js → cli-bxrqvpg5.js} +6 -6
  37. package/dist/{cli-0hyhdkdk.js → cli-cebvbc9t.js} +4 -1
  38. package/dist/{cli-p6argxh9.js → cli-dm8p2dpf.js} +7 -7
  39. package/dist/{cli-jh0qcrga.js → cli-e4d78xsv.js} +1 -1
  40. package/dist/{cli-7a91w22y.js → cli-e8xyjvfc.js} +1 -1
  41. package/dist/{cli-axh29tyr.js → cli-f0p0f6ja.js} +10 -10
  42. package/dist/{cli-w9g4sz81.js → cli-gf2850q7.js} +1 -1
  43. package/dist/{cli-bn5kevnf.js → cli-gx0j2khs.js} +4 -4
  44. package/dist/{cli-gf0kfg81.js → cli-h0mgxann.js} +5 -5
  45. package/dist/{cli-kr2pmfqx.js → cli-kgnkyabk.js} +85 -71
  46. package/dist/{cli-hqhzg1qq.js → cli-mzkwkbdy.js} +1 -1
  47. package/dist/{cli-k9z4zd4p.js → cli-npfe9t1s.js} +2 -2
  48. package/dist/{cli-syhq378v.js → cli-p0qerd2r.js} +1 -1
  49. package/dist/{cli-sjyetj21.js → cli-pygdsgh8.js} +1 -1
  50. package/dist/{cli-2wag8973.js → cli-qeaq3j6t.js} +1 -1
  51. package/dist/{cli-nx462e4v.js → cli-qp2m4btv.js} +3 -3
  52. package/dist/{cli-tbhwrxpt.js → cli-s9rr4gr2.js} +5 -5
  53. package/dist/{cli-fz9kewbh.js → cli-sds5zzs8.js} +1 -1
  54. package/dist/{cli-vy0fay6k.js → cli-tkf4x6ha.js} +15 -15
  55. package/dist/{cli-mfe7f7b5.js → cli-v87dr6e3.js} +2 -2
  56. package/dist/{cli-rqqsfz30.js → cli-vcgscaan.js} +91 -21
  57. package/dist/{cli-zfg9enh9.js → cli-vfha21c9.js} +6 -1
  58. package/dist/{cli-3p8fqcnh.js → cli-vq9gqarq.js} +1 -1
  59. package/dist/{cli-a4gvwttf.js → cli-wk28hzqn.js} +2 -2
  60. package/dist/{cli-p0rakbrk.js → cli-zvbb4n0e.js} +6 -5
  61. package/dist/cli.js +17 -17
  62. package/dist/{codex-adapter-p6rmm7vv.js → codex-adapter-2zrmab9b.js} +2 -2
  63. package/dist/{codex-hook-x7nejyrr.js → codex-hook-e2he7aca.js} +2 -2
  64. package/dist/{codex-session-runner-8vd5t9t0.js → codex-session-runner-kbkz62vc.js} +2 -2
  65. package/dist/{commands-7ems2p13.js → commands-hd574th6.js} +3 -3
  66. package/dist/{controller-m263fg43.js → controller-pz5mcfg4.js} +3 -3
  67. package/dist/{controllers-connect-bg33ae53.js → controllers-connect-5rgq2hfk.js} +19 -19
  68. package/dist/{cursor-adapter-cz6nm6mj.js → cursor-adapter-mgaprq0j.js} +2 -2
  69. package/dist/{db-w505dwa8.js → db-6nf1207q.js} +6 -2
  70. package/dist/{db-ys2rbdft.js → db-h06dxp05.js} +3 -3
  71. package/dist/{dispatcher-0s0mckqr.js → dispatcher-zthpy4m1.js} +17 -17
  72. package/dist/{dsh-adapter-3n3net33.js → dsh-adapter-64dhw0j7.js} +2 -2
  73. package/dist/{e2b-thdcxtjd.js → e2b-16d6ch39.js} +1 -1
  74. package/dist/{e2b-7am6tmq6.js → e2b-zn0b4w5w.js} +1 -1
  75. package/dist/{external-channel-5b23fv8z.js → external-channel-x01t7ke6.js} +2 -2
  76. package/dist/{grok-adapter-q9fghe5q.js → grok-adapter-h21jv594.js} +5 -5
  77. package/dist/handlers-2qjw434w.js +39 -0
  78. package/dist/{heartbeat-tww6yx1w.js → heartbeat-zzfksckz.js} +20 -20
  79. package/dist/{hook-pvmh9b6m.js → hook-3xfqhh2s.js} +2 -2
  80. package/dist/{hook-dx0pkvar.js → hook-z4zhzj1f.js} +10 -5
  81. package/dist/{http-k2ejbt05.js → http-q813tmya.js} +121 -75
  82. package/dist/{index-2z710v4x.js → index-220tk9bw.js} +9 -9
  83. package/dist/index-d0g18bax.js +41 -0
  84. package/dist/{index-dgz61nff.js → index-m0a4ydqj.js} +17 -17
  85. package/dist/{index-h2ek08d6.js → index-wbbs14c5.js} +17 -17
  86. package/dist/{index-x5aktmp8.js → index-x8xbyn1t.js} +1 -1
  87. package/dist/{keepalive-nhvpw10z.js → keepalive-y5t1my7m.js} +3 -3
  88. package/dist/{lead-jb6sf69z.js → lead-2xwtq66b.js} +30 -30
  89. package/dist/{maintenance-agakc519.js → maintenance-ybfsaedv.js} +1 -1
  90. package/dist/{oauth-refresh-sweep-4dcbhtn0.js → oauth-refresh-sweep-909jmn12.js} +2 -2
  91. package/dist/{onboard-4n2kwa4v.js → onboard-xn6fkkys.js} +3 -3
  92. package/dist/{opencode-adapter-3s7ax02e.js → opencode-adapter-x4tqzhkh.js} +3 -3
  93. package/dist/{otel-impl-tfa12080.js → otel-impl-e6mn0z13.js} +1 -1
  94. package/dist/{pi-mono-adapter-ytp7gz8b.js → pi-mono-adapter-mcfkn9vf.js} +3 -3
  95. package/dist/{pricing-refresh-3cqx3f3x.js → pricing-refresh-ygd362ep.js} +3 -3
  96. package/dist/{rbac-roles-9fae5rnv.js → rbac-roles-c9axvfnz.js} +3 -3
  97. package/dist/{rbac-roles-y6zbfzve.js → rbac-roles-mb470p4e.js} +2 -2
  98. package/dist/{render-v2-emawjvc4.js → render-v2-g0306nh1.js} +3 -3
  99. package/dist/{resume-zhgts7h7.js → resume-v315hnq6.js} +5 -5
  100. package/dist/{session-access-cm823e06.js → session-access-2zg285a1.js} +4 -4
  101. package/dist/{setup-tkp99w1e.js → setup-a4mtf02k.js} +1 -1
  102. package/dist/{work-objects-f722h263.js → work-objects-9fr0fgtn.js} +21 -21
  103. package/dist/{worker-p7f336rq.js → worker-qae22jeb.js} +30 -30
  104. package/openapi.json +393 -1
  105. package/package.json +3 -3
  106. package/src/apps/catalog.generated.json +3 -2
  107. package/src/apps/definition.ts +152 -4
  108. package/src/apps/row-store.ts +19 -7
  109. package/src/apps/schema-migrate.ts +14 -6
  110. package/src/apps/script-types.ts +1 -0
  111. package/src/apps/sync.ts +20 -19
  112. package/src/be/db/tasks/read.ts +112 -0
  113. package/src/be/db.ts +9 -4
  114. package/src/be/migrations/231_slack_tasks_last_updated_index.sql +5 -0
  115. package/src/be/swarm-config-guard.ts +15 -0
  116. package/src/github/handlers.ts +6 -5
  117. package/src/hooks/hook.ts +2 -1
  118. package/src/http/apps.ts +15 -2
  119. package/src/http/status.ts +49 -43
  120. package/src/http/tasks.ts +47 -0
  121. package/src/providers/acp-targets.ts +1 -1
  122. package/src/providers/amp-adapter.ts +1 -1
  123. package/src/providers/codex-adapter.ts +4 -4
  124. package/src/providers/opencode-adapter.ts +2 -2
  125. package/src/providers/pi-mono-extension.ts +2 -1
  126. package/src/telemetry.ts +15 -3
  127. package/src/tests/app-row-tools.test.ts +25 -0
  128. package/src/tests/apps-json-column.test.ts +611 -0
  129. package/src/tests/apps-script-types.test.ts +2 -0
  130. package/src/tests/apps-sync-engine.test.ts +42 -0
  131. package/src/tests/claude-stop-hook.test.ts +47 -0
  132. package/src/tests/codex-adapter.test.ts +42 -0
  133. package/src/tests/db-task-read-contract.test.ts +4 -0
  134. package/src/tests/db-task-write-contract.test.ts +2 -0
  135. package/src/tests/docs-index-footprint.test.js +2 -2
  136. package/src/tests/env-flag.test.ts +15 -1
  137. package/src/tests/github-handlers-cancel-config.test.ts +29 -8
  138. package/src/tests/github-handlers-inline-comments.test.ts +9 -2
  139. package/src/tests/runner-harness-telemetry-env.test.ts +6 -0
  140. package/src/tests/script-connections.test.ts +2 -0
  141. package/src/tests/skill-script-refs.test.ts +63 -0
  142. package/src/tests/slack-render-v2-query-plans.test.ts +147 -0
  143. package/src/tests/status.test.ts +158 -9
  144. package/src/tests/task-lineage.test.ts +180 -0
  145. package/src/tests/telemetry-init.test.ts +62 -5
  146. package/src/tests/user-token-rest-auth.test.ts +38 -0
  147. package/src/tools/app-rows.ts +5 -0
  148. package/src/utils/env-flag.ts +9 -0
  149. package/templates/skills/apps/SKILL.md +4 -2
  150. package/templates/skills/apps/content.md +4 -2
  151. package/templates/skills/code-quality/content.md +9 -14
  152. package/dist/handlers-mqrym347.js +0 -39
  153. package/dist/index-pqfwh9nn.js +0 -41
@@ -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." },
@@ -53,3 +53,12 @@ export function isEnvFlagEnabled(
53
53
  ): boolean {
54
54
  return parseEnvFlag(env[key], defaultValue);
55
55
  }
56
+
57
+ /**
58
+ * Whether the session-end summary is turned off via `SKIP_SESSION_SUMMARY`.
59
+ * Every harness (claude Stop hook, pi, opencode, codex) gates on this, so
60
+ * `1`/`true` skip and unset/empty/`0`/`false` keep the summary everywhere.
61
+ */
62
+ export function isSessionSummarySkipped(env: NodeJS.ProcessEnv = process.env): boolean {
63
+ return isEnvFlagEnabled("SKIP_SESSION_SUMMARY", false, env);
64
+ }
@@ -225,6 +225,7 @@ Each column is `{ "kind": ..., "required"?: boolean, "default"?: ..., "index"?:
225
225
  | `boolean` | Boolean; set `index: true` only when equality lookups need it. |
226
226
  | `date` | ISO-8601 string; never indexed. |
227
227
  | `enum` | String from the non-empty unique `enum` list; always indexed, without `index: true`. |
228
+ | `json` | Any JSON value (object, array, nested) up to 256 KB serialized by default (`APPS_JSON_COLUMN_MAX_BYTES`); never indexed, and a named query cannot `filter` or `sort` on it. Not allowed for `userConfig` fields or element props. |
228
229
 
229
230
  `default` must match the column kind (and be one of the enum values). `required: true` rejects missing/null values unless a default supplies the value. `id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` — and the sync envelope `source`, `syncedAt`, `stale` — are system columns and reserved names. An app created before the sync envelope that declared ordinary columns with these names surfaces `definitionError` after upgrade until they are renamed: patch in a fresh column with a `{from}` migration directive, then purge the old one. Rows always expose `id`/`createdAt`/`updatedAt`; `createdBy`/`updatedBy` record the acting principal (`user:<id>`, `agent:<id>`, `operator`, or `sync:<source>` for engine writes) and are system-managed on every row write.
230
231
 
@@ -321,7 +322,7 @@ A named query is:
321
322
  }
322
323
  ```
323
324
 
324
- `filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and on synced models `source`, `syncedAt`, `stale`) — filtering on `id` is the universal way to select one row for a detail view, and `{ "stale": true }` selects rows a complete pull stopped confirming. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a model column, `createdAt`, `updatedAt`, or `syncedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.
325
+ `filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and on synced models `source`, `syncedAt`, `stale`) — filtering on `id` is the universal way to select one row for a detail view, and `{ "stale": true }` selects rows a complete pull stopped confirming. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a non-`json` model column, `createdAt`, `updatedAt`, or `syncedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.
325
326
 
326
327
  A filter value may instead be exactly `{ "$param": "<name>" }`. At execution, the caller supplies that name and the server coerces its value to the filtered column's kind before equality matching:
327
328
 
@@ -520,7 +521,7 @@ Tones: Badge, Metric and Progress `tone` take `neutral`, `success`, `active`, `e
520
521
 
521
522
  Table details:
522
523
 
523
- - `columns[]`: `{ key, label?, kind?, tones?: {value: badgeTone}, width?: number, pinned?: "left" or "right", align?: "start" or "end", truncate?: "end" or "middle", icon?, tooltip?: "<row field>", max?: number, onChange?: chain }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge`, `status`, `user`, `agent`, `relative`, `duration`, `progress`, `copy`, `switch`, or `link`; `string` and `enum` render as text. Pin id/actions columns so they survive horizontal scroll on narrow viewports.
524
+ - `columns[]`: `{ key, label?, kind?, tones?: {value: badgeTone}, width?: number, pinned?: "left" or "right", align?: "start" or "end", truncate?: "end" or "middle", icon?, tooltip?: "<row field>", max?: number, onChange?: chain }`. `kind` is `text`, `string`, `enum`, `number`, `boolean`, `date`, `badge`, `status`, `user`, `agent`, `relative`, `duration`, `progress`, `copy`, `switch`, `link`, or `json`; `string` and `enum` render as text, and `json` renders a truncated single-line `JSON.stringify` preview. Pin id/actions columns so they survive horizontal scroll on narrow viewports.
524
525
  - `status`: lifecycle values (`pending`, `in_progress`, `running`, `completed`, `failed`, `cancelled`, `paused`, `reviewing`, ...) render the dashboard status chip with its icon; other values use `tones` like `badge`.
525
526
  - `user` / `agent`: avatar plus name. `relative` (and `date`): smart time, exact time on hover. `duration`: milliseconds as `1m 23s`. `progress`: a bar against `max` (default 100). `copy`: the value plus a copy button. `link`: an http(s) URL opening in a new tab (other values render as text).
526
527
  - `switch`: a toggle. `onChange` runs with `$row` = the row with this column already set to the new value, e.g. `[{ "action": "app.mutate", "params": { "model": "m", "op": "update", "rowId": { "$row": "id" }, "values": { "enabled": { "$row": "enabled" } } } }]`. Without `onChange` the switch is read-only.
@@ -789,6 +790,7 @@ The tool returns `{ appId, url: "/apps/<id>" }`. Open that URL to verify the liv
789
790
  - Logged-in browser: a fresh dashboard has no connection and redirects to /setup. Seed the localStorage key `agent-swarm-connections` with `{connections:[{id,name,apiUrl,apiKey}],activeId}`. Build the value from an env var and pipe it to `agent-browser eval --stdin`, so the key never reaches a command line. Reload, then pick a user in the "Who are you?" dialog.
790
791
  - Apps scroll inside an inner canvas, so a full-page screenshot captures only the viewport. Scroll the canvas or raise the viewport height. For mobile, set a 375px-wide viewport before the screenshot.
791
792
  - App definitions: `GET /api/apps` and `GET /api/apps/<id>` with the API key as Bearer.
793
+ - Follow-up edit to a live app: list in the brief the control numbers the edit must not move, and assert them from the backing script before publishing. Take a signed-in screenshot of each changed selector at 1440px wide, save the shots to agent-fs, critique, and repeat at least 3 times. If the backing script caches results, bump its version or force a fresh run so the shots show new data.
792
794
 
793
795
 
794
796
  ### Rows from agents and scripts