@desplega.ai/agent-swarm 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-v85a2bqy.js} +1 -1
  2. package/dist/{actions-w4pnb8vz.js → actions-zgcxfspj.js} +15 -15
  3. package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-jw9xfz42.js} +2 -2
  4. package/dist/{app-zneem7g9.js → app-4e1s4675.js} +3 -3
  5. package/dist/{assistant-wbzd2rnr.js → assistant-v238n9dk.js} +16 -16
  6. package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-p666cw5n.js} +1 -1
  7. package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-w6pm64j4.js} +1 -1
  8. package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-jcyjagn3.js} +2 -2
  9. package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-gbedmamp.js} +4 -4
  10. package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-q7wspzt8.js} +2 -2
  11. package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-wza784fb.js} +1 -1
  12. package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-yf400ptr.js} +2 -2
  13. package/dist/{cli-p46xdr1m.js → cli-0rh20bp3.js} +1 -1
  14. package/dist/{cli-e5y285rg.js → cli-1v8w782f.js} +1 -1
  15. package/dist/{cli-8c1m9mhy.js → cli-30m5q8mc.js} +5 -5
  16. package/dist/{cli-xnp2vkx7.js → cli-4hmaheb6.js} +6 -6
  17. package/dist/{cli-xhgtay7w.js → cli-4s1kqrqg.js} +1 -1
  18. package/dist/{cli-2jm992rm.js → cli-5d1m1qca.js} +2 -2
  19. package/dist/{cli-mvsfbbgq.js → cli-65q70v2v.js} +3 -3
  20. package/dist/{cli-th8krqbf.js → cli-6p7ddg72.js} +2 -2
  21. package/dist/{cli-6q7pyd3k.js → cli-6vcyw6v7.js} +1 -1
  22. package/dist/{cli-bddxamg7.js → cli-8qsx817b.js} +23 -22
  23. package/dist/{cli-ztcxv5nv.js → cli-9e9kjqsj.js} +1 -1
  24. package/dist/{cli-jrk8mj0t.js → cli-a17tzmn0.js} +1 -1
  25. package/dist/{cli-raejy0mp.js → cli-d43xqzbe.js} +1 -1
  26. package/dist/{cli-tba8fxf9.js → cli-dxwh4j32.js} +11 -11
  27. package/dist/{cli-c9vrwcpb.js → cli-ea7chd26.js} +19 -19
  28. package/dist/{cli-vmbakbq0.js → cli-fhvqt2fp.js} +1 -1
  29. package/dist/{cli-z3dk189k.js → cli-fp4xbw07.js} +6 -6
  30. package/dist/{cli-xx13gqf3.js → cli-g6p7c2my.js} +1 -1
  31. package/dist/{cli-c0dpzwnx.js → cli-hn3pftj2.js} +2 -2
  32. package/dist/{cli-h23mr2s6.js → cli-hz64bcnj.js} +2 -2
  33. package/dist/{cli-dw0njd9q.js → cli-jhmjwqcg.js} +5 -5
  34. package/dist/{cli-0k9rcwfy.js → cli-ka01m5rb.js} +28 -12
  35. package/dist/{cli-jhhb2v9n.js → cli-kqypty34.js} +2 -2
  36. package/dist/{cli-hwc4a9xm.js → cli-q1812jbw.js} +4 -4
  37. package/dist/{cli-8w65vwee.js → cli-qrwp40st.js} +1 -1
  38. package/dist/{cli-4c7k75rq.js → cli-re4wkbzx.js} +54 -41
  39. package/dist/{cli-z1hh99j0.js → cli-tr24eeny.js} +2 -2
  40. package/dist/{cli-q6gw45wj.js → cli-wtg6edvd.js} +1 -1
  41. package/dist/{cli-mnry16kh.js → cli-xgn4rwyv.js} +4 -4
  42. package/dist/{cli-5ks6x36q.js → cli-y3dvz6ge.js} +3 -3
  43. package/dist/{cli-ksrphveb.js → cli-yv0vgscc.js} +1 -0
  44. package/dist/{cli-ak4c12mh.js → cli-yv3n83x8.js} +1 -1
  45. package/dist/{cli-9ac0mkg5.js → cli-zes4tsbb.js} +1 -1
  46. package/dist/cli.js +14 -14
  47. package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-nhjj2veb.js} +1 -1
  48. package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-6hggrjbx.js} +1 -1
  49. package/dist/{commands-ktnqwrv4.js → commands-mrw1ph2w.js} +2 -2
  50. package/dist/{controller-nex2he8v.js → controller-pt1zam5w.js} +2 -2
  51. package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-ymw5541d.js} +16 -16
  52. package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-cf4zh5x0.js} +1 -1
  53. package/dist/{db-19tw9r5s.js → db-hhpy6m4x.js} +2 -2
  54. package/dist/{db-jvr86v7f.js → db-hy80219q.js} +1 -1
  55. package/dist/{dispatcher-d3mnsg2y.js → dispatcher-6pdjtkr1.js} +14 -14
  56. package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-fbx06re2.js} +1 -1
  57. package/dist/{external-channel-44cwer32.js → external-channel-j6vb6ya1.js} +2 -2
  58. package/dist/{grok-adapter-ysp907f6.js → grok-adapter-1193sprp.js} +2 -2
  59. package/dist/{handlers-t5pbabwf.js → handlers-tb3xgjnm.js} +17 -17
  60. package/dist/{heartbeat-2etacsqq.js → heartbeat-h1w1ktce.js} +17 -17
  61. package/dist/{hook-3kvmax6d.js → hook-ejja4bpe.js} +1 -1
  62. package/dist/{hook-mzxxs5n2.js → hook-skrbja18.js} +1 -1
  63. package/dist/{http-fn978m0g.js → http-mzh41mfj.js} +108 -52
  64. package/dist/{index-039x2sag.js → index-6rmnn89d.js} +40 -12
  65. package/dist/{index-w2wr89xd.js → index-epr2gnbw.js} +1 -1
  66. package/dist/{index-qyjbb7br.js → index-nyfcdpky.js} +17 -17
  67. package/dist/{index-e4tsa1h7.js → index-vjn3wbzf.js} +14 -14
  68. package/dist/{index-1r8hw4vd.js → index-xmmkyx82.js} +14 -14
  69. package/dist/{keepalive-d4nzs76x.js → keepalive-4djvbm2j.js} +3 -3
  70. package/dist/{lead-8h5n9k5f.js → lead-61kqp604.js} +23 -23
  71. package/dist/{maintenance-ea6jfw42.js → maintenance-f3xhnjsm.js} +1 -1
  72. package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-fpd6x6rw.js} +2 -2
  73. package/dist/{onboard-pvk7hdb2.js → onboard-196tjgyn.js} +2 -2
  74. package/dist/{otel-impl-qb7nhgdp.js → otel-impl-g8fh4c21.js} +1 -1
  75. package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-sdcf5yaz.js} +3 -3
  76. package/dist/{rbac-roles-9648nh00.js → rbac-roles-2x4g1wb5.js} +2 -2
  77. package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-bsp5657v.js} +1 -1
  78. package/dist/{render-v2-xkze1wef.js → render-v2-0pyb39bb.js} +3 -3
  79. package/dist/{resume-tq0bbvt5.js → resume-a86tkj6r.js} +5 -5
  80. package/dist/{setup-rnrazxvd.js → setup-ypbzcapd.js} +1 -1
  81. package/dist/{work-objects-e75q2rqs.js → work-objects-yjp8de12.js} +17 -17
  82. package/dist/{worker-4ww4hjef.js → worker-4ptnn1ge.js} +23 -23
  83. package/openapi.json +168 -6
  84. package/package.json +2 -2
  85. package/src/be/asset-key-audit.ts +4 -13
  86. package/src/be/db.ts +57 -33
  87. package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
  88. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
  89. package/src/heartbeat/heartbeat.ts +25 -7
  90. package/src/http/assets.ts +26 -4
  91. package/src/http/inbox-state.ts +58 -8
  92. package/src/kv-reserved-namespaces.ts +1 -0
  93. package/src/providers/amp-adapter.ts +1 -1
  94. package/src/tests/asset-key-api.test.ts +128 -0
  95. package/src/tests/asset-key-invariants.test.ts +179 -3
  96. package/src/tests/asset-key-migration.test.ts +35 -0
  97. package/src/tests/docs-index-footprint.test.js +2 -2
  98. package/src/tests/heartbeat-checklist.test.ts +88 -0
  99. package/src/tests/heartbeat-jev-gate.test.ts +394 -10
  100. package/src/tests/inbox-state.test.ts +167 -0
  101. package/src/tests/kv-tool.test.ts +15 -0
  102. package/src/tests/script-connections.test.ts +2 -0
  103. package/src/types.ts +2 -0
  104. package/templates/skills/code-quality/content.md +2 -2
  105. package/templates/skills/pages/content.md +4 -0
  106. package/templates/skills/swarm-scripts/SKILL.md +6 -0
  107. package/templates/skills/swarm-scripts/content.md +6 -0
@@ -48,6 +48,7 @@ import {
48
48
  settleSupersededTaskDependents,
49
49
  supersedeTask,
50
50
  updateAgentStatus,
51
+ upsertKv,
51
52
  } from "../be/db";
52
53
  import { repointTrackerSyncBySwarmId } from "../be/db-queries/tracker";
53
54
  import { poolTaskRunsOnHarness } from "../be/model-validation";
@@ -1461,7 +1462,10 @@ export function isEffectivelyEmpty(content: string): boolean {
1461
1462
  /**
1462
1463
  * Gather current system status as a markdown string for the lead's checklist task.
1463
1464
  */
1464
- export async function gatherSystemStatus(options?: { isBootTriage?: boolean }): Promise<string> {
1465
+ export async function gatherSystemStatus(options?: {
1466
+ isBootTriage?: boolean;
1467
+ reportedFailures?: Array<{ id: string; line: string }>;
1468
+ }): Promise<string> {
1465
1469
  const stats = await getTaskStats();
1466
1470
  const stalledTasks = await getStalledInProgressTasks(stallThresholdMinutes());
1467
1471
  const agents = await getAllAgents();
@@ -1494,6 +1498,7 @@ export async function gatherSystemStatus(options?: { isBootTriage?: boolean }):
1494
1498
 
1495
1499
  // Recent failures with reasons and pattern detection (last 6 hours)
1496
1500
  const recentFailures = await getRecentFailedTasks(6);
1501
+ const renderedFailures = recentFailures.slice(0, 10);
1497
1502
  if (recentFailures.length > 0) {
1498
1503
  sections.push("");
1499
1504
  sections.push("## Recent Failures (last 6h) [auto-generated]");
@@ -1520,12 +1525,12 @@ export async function gatherSystemStatus(options?: { isBootTriage?: boolean }):
1520
1525
 
1521
1526
  // List individual failures (max 10)
1522
1527
  sections.push("");
1523
- for (const task of recentFailures.slice(0, 10)) {
1528
+ for (const task of renderedFailures) {
1524
1529
  const agentSlice = task.agentId?.slice(0, 8) ?? "unassigned";
1525
1530
  const reason = task.failureReason?.slice(0, 100) ?? "no reason";
1526
- sections.push(
1527
- `- [${task.id.slice(0, 8)}] "${task.task.slice(0, 50)}" — agent: ${agentSlice}, reason: ${reason}, at: ${task.finishedAt}`,
1528
- );
1531
+ const line = `- [${task.id.slice(0, 8)}] "${task.task.slice(0, 50)}" — agent: ${agentSlice}, reason: ${reason}, at: ${task.finishedAt}`;
1532
+ sections.push(line);
1533
+ options?.reportedFailures?.push({ id: task.id, line });
1529
1534
  }
1530
1535
  if (recentFailures.length > 10) {
1531
1536
  sections.push(`- ... and ${recentFailures.length - 10} more`);
@@ -1786,7 +1791,8 @@ export async function checkHeartbeatChecklist(): Promise<void> {
1786
1791
  return;
1787
1792
  }
1788
1793
 
1789
- let systemStatus = await gatherSystemStatus();
1794
+ const reportedFailures: Array<{ id: string; line: string }> = [];
1795
+ let systemStatus = await gatherSystemStatus({ reportedFailures });
1790
1796
  // Shadow mode leaves the prompt untouched so the Lead's own quiet/non-quiet
1791
1797
  // outcome stays an independent check on the gate's verdict.
1792
1798
  if (gateMode === "enforce" && gate?.verdict === "wake" && gate.summary) {
@@ -1803,7 +1809,7 @@ export async function checkHeartbeatChecklist(): Promise<void> {
1803
1809
 
1804
1810
  if (result.skipped) return;
1805
1811
 
1806
- await createTaskExtended(result.text, {
1812
+ const task = await createTaskExtended(result.text, {
1807
1813
  agentId: lead.id,
1808
1814
  routingReason: "skill",
1809
1815
  routingSource: "engine_default",
@@ -1815,6 +1821,18 @@ export async function checkHeartbeatChecklist(): Promise<void> {
1815
1821
  priority: 60,
1816
1822
  });
1817
1823
 
1824
+ try {
1825
+ await upsertKv({
1826
+ namespace: "heartbeat-checklist:reported-failures",
1827
+ key: task.id,
1828
+ value: reportedFailures.filter(({ line }) => result.text.includes(line)).map(({ id }) => id),
1829
+ valueType: "json",
1830
+ expiresAt: Date.now() + 7 * 24 * 3600e3,
1831
+ });
1832
+ } catch (err) {
1833
+ console.error(`[Heartbeat] Failed to save reported failures for checklist ${task.id}:`, err);
1834
+ }
1835
+
1818
1836
  console.log(`[Heartbeat] Checklist task created for lead ${lead.name}`);
1819
1837
  }
1820
1838
 
@@ -7,6 +7,7 @@ import { resolveHttpAuditUserId } from "../be/audit-user";
7
7
  import {
8
8
  getDb,
9
9
  getDbClient,
10
+ getRootTaskChain,
10
11
  getTaskById,
11
12
  listAssetSummaries,
12
13
  moveAssetKey,
@@ -120,10 +121,17 @@ function ensureOperator(req: IncomingMessage, res: ServerResponse): boolean {
120
121
  }
121
122
 
122
123
  const moveAssetBodySchema = z.object({ key: AssetKeySchema });
124
+ const moveGenericAssetBodySchema = moveAssetBodySchema.extend({
125
+ cascade: z
126
+ .boolean()
127
+ .optional()
128
+ .describe("For tasks only, also move every descendant task recursively through parentTaskId."),
129
+ });
123
130
  const moveAssetSuccessSchema = z.object({
124
131
  entityType: AssetEntityTypeSchema,
125
132
  id: z.string(),
126
133
  key: AssetKeySchema,
134
+ movedTaskIds: z.array(z.string()).optional(),
127
135
  });
128
136
  const moveAssetResponses = {
129
137
  200: { description: "Asset namespace updated", schema: moveAssetSuccessSchema },
@@ -168,7 +176,7 @@ const moveAssetRoute = route({
168
176
  "Updates namespace metadata only. Provider-backed files keep the same provider key, org, and drive; no remote move occurs. Personal keys are labels, not a privacy guarantee.",
169
177
  tags: ["Assets"],
170
178
  params: z.object({ entityType: AssetEntityTypeSchema, id: z.string().min(1) }),
171
- body: moveAssetBodySchema,
179
+ body: moveGenericAssetBodySchema,
172
180
  responses: moveAssetResponses,
173
181
  rbac: {
174
182
  ungated:
@@ -302,17 +310,29 @@ export async function handleAssets(
302
310
  ) {
303
311
  const parsed = await moveAssetRoute.parse(req, res, pathSegments, queryParams);
304
312
  if (!parsed) return true;
313
+ if (parsed.body.cascade && parsed.params.entityType !== "task") {
314
+ jsonError(res, "Cascade is only supported for task namespace moves", 400);
315
+ return true;
316
+ }
305
317
  if (parsed.params.entityType === "file" && !ensureOperator(req, res)) return true;
318
+ let descendantTaskIds: string[] | undefined;
306
319
  if (parsed.params.entityType === "task") {
307
320
  const task = await getTaskById(parsed.params.id);
308
321
  if (!task) {
309
322
  jsonError(res, "Asset not found", 404);
310
323
  return true;
311
324
  }
312
- if (!(await canMutateTaskNamespace(task, myAgentId, req))) {
313
- jsonError(res, "Not authorized to move this task namespace", 403);
314
- return true;
325
+ // Only the tasks checked here move: a subtask created after this read stays put.
326
+ const tasks = parsed.body.cascade ? await getRootTaskChain(task.id) : [task];
327
+ for (const target of tasks) {
328
+ if (!(await canMutateTaskNamespace(target, myAgentId, req))) {
329
+ jsonError(res, "Not authorized to move this task namespace", 403);
330
+ return true;
331
+ }
315
332
  }
333
+ descendantTaskIds = tasks
334
+ .filter((target) => target.id !== task.id)
335
+ .map((target) => target.id);
316
336
  }
317
337
  if (parsed.params.entityType === "app") {
318
338
  if (!(await getApp(parsed.params.id))) {
@@ -344,6 +364,7 @@ export async function handleAssets(
344
364
  id: parsed.params.id,
345
365
  key,
346
366
  changedBy: actor ?? undefined,
367
+ descendantTaskIds,
347
368
  });
348
369
  if (!moved) {
349
370
  jsonError(res, "Asset not found", 404);
@@ -353,6 +374,7 @@ export async function handleAssets(
353
374
  entityType: parsed.params.entityType,
354
375
  id: parsed.params.id,
355
376
  key,
377
+ ...moved,
356
378
  });
357
379
  } catch (error) {
358
380
  if (error instanceof AssetKeyAuthorizationError) {
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { listInboxState, upsertInboxState } from "../be/db";
3
+ import { getDbClient, listInboxState, upsertInboxState } from "../be/db";
4
4
  import { InboxItemStateSchema, InboxItemStatusSchema, InboxItemTypeSchema } from "../types";
5
5
  import { route } from "./route-def";
6
6
  import { jsonError } from "./utils";
@@ -29,29 +29,60 @@ const listState = route({
29
29
  auth: { apiKey: true },
30
30
  });
31
31
 
32
+ const inboxStateItemSchema = z.object({
33
+ itemType: InboxItemTypeSchema,
34
+ itemId: z.string().min(1),
35
+ status: InboxItemStatusSchema,
36
+ snoozeUntil: z.string().datetime().optional(),
37
+ readAt: z.string().datetime().optional(),
38
+ lastReadAt: z
39
+ .string()
40
+ .datetime()
41
+ .nullable()
42
+ .optional()
43
+ .describe(
44
+ "Omit to preserve the read cursor, send null to clear it, or send a timestamp to set it, including an earlier time.",
45
+ ),
46
+ });
47
+
32
48
  const upsertState = route({
33
49
  method: "patch",
34
50
  path: "/api/inbox-state",
35
51
  pattern: ["api", "inbox-state"],
36
52
  summary: "Upsert per-user dismiss/snooze/done state for an inbox item",
37
53
  tags: ["Inbox State"],
54
+ body: inboxStateItemSchema.extend({ userId: z.string() }),
55
+ responses: {
56
+ 200: {
57
+ description: "Upserted inbox state row",
58
+ schema: z.object({ item: InboxItemStateSchema }),
59
+ },
60
+ 400: { description: "Validation error" },
61
+ 401: { description: "Unauthorized" },
62
+ },
63
+ auth: { apiKey: true },
64
+ });
65
+
66
+ const bulkUpsertState = route({
67
+ method: "patch",
68
+ path: "/api/inbox-state/bulk",
69
+ pattern: ["api", "inbox-state", "bulk"],
70
+ summary: "Upsert inbox-item state in one transaction",
71
+ tags: ["Inbox State"],
38
72
  body: z.object({
39
73
  userId: z.string(),
40
- itemType: InboxItemTypeSchema,
41
- itemId: z.string().min(1),
42
- status: InboxItemStatusSchema,
43
- snoozeUntil: z.string().datetime().optional(),
44
- readAt: z.string().datetime().optional(),
74
+ items: z.array(inboxStateItemSchema).min(1).max(200),
45
75
  }),
46
76
  responses: {
47
77
  200: {
48
- description: "Upserted inbox state row",
49
- schema: z.object({ item: InboxItemStateSchema }),
78
+ description: "Upserted inbox state rows in request order",
79
+ schema: z.object({ items: z.array(InboxItemStateSchema) }),
50
80
  },
51
81
  400: { description: "Validation error" },
52
82
  401: { description: "Unauthorized" },
53
83
  },
54
84
  auth: { apiKey: true },
85
+ rbac: { ungated: "per-user inbox UI state, same posture as PATCH /api/inbox-state" },
55
86
  });
56
87
 
57
88
  // ─── Handler ─────────────────────────────────────────────────────────────────
@@ -74,6 +105,24 @@ export async function handleInboxState(
74
105
  return true;
75
106
  }
76
107
 
108
+ if (bulkUpsertState.match(req.method, pathSegments)) {
109
+ const parsed = await bulkUpsertState.parse(req, res, pathSegments, queryParams);
110
+ if (!parsed) return true;
111
+ try {
112
+ const items = await getDbClient().transaction(async () => {
113
+ const rows = [];
114
+ for (const item of parsed.body.items) {
115
+ rows.push(await upsertInboxState({ ...item, userId: parsed.body.userId }));
116
+ }
117
+ return rows;
118
+ });
119
+ bulkUpsertState.respond(res, 200, { items });
120
+ } catch (err) {
121
+ jsonError(res, err instanceof Error ? err.message : "Failed to upsert inbox state", 500);
122
+ }
123
+ return true;
124
+ }
125
+
77
126
  if (upsertState.match(req.method, pathSegments)) {
78
127
  const parsed = await upsertState.parse(req, res, pathSegments, queryParams);
79
128
  if (!parsed) return true;
@@ -85,6 +134,7 @@ export async function handleInboxState(
85
134
  status: parsed.body.status,
86
135
  snoozeUntil: parsed.body.snoozeUntil,
87
136
  readAt: parsed.body.readAt,
137
+ lastReadAt: parsed.body.lastReadAt,
88
138
  });
89
139
  upsertState.respond(res, 200, { item });
90
140
  } catch (err) {
@@ -8,6 +8,7 @@ const RESERVED_FAMILIES = new Map([
8
8
  ["apps", "namespace is reserved for swarm apps; use the app row endpoints"],
9
9
  // step-9: Comb's "Send to swarm" claims (`comb:sent`).
10
10
  ["comb", "namespace is reserved for Comb; use POST /api/comb/review-batches"],
11
+ ["heartbeat-checklist", "namespace is reserved for heartbeat checklist receipts"],
11
12
  ["worker", "namespace is reserved for worker nudges"],
12
13
  // Durable dedupe for SLACK_BLOCK_EXTERNAL_CHANNELS alerts (src/slack/external-channel.ts).
13
14
  ["slack-external-alert", "namespace is reserved for Slack external channel alerts"],
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792238457-g6ec988";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792296040-g5ce754";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -11,8 +11,11 @@ import {
11
11
  createAgent,
12
12
  createTaskExtended,
13
13
  createUser,
14
+ getAssetKeyMappingByProvider,
14
15
  getDbClient,
16
+ getTaskById,
15
17
  initDb,
18
+ insertTaskAttachment,
16
19
  } from "../be/db";
17
20
  import { insertScript } from "../be/scripts/db";
18
21
  import { handleAssets } from "../http/assets";
@@ -343,4 +346,129 @@ describe("asset namespace REST contract", () => {
343
346
  expect(audit.body.structuralValid).toBe(true);
344
347
  expect(audit.body.warningCount).toBe(0);
345
348
  });
349
+
350
+ test("cascade moves descendants and sourced mappings without stamping task activity", async () => {
351
+ const root = await createTaskExtended("cascade root", { agentId, key: "shared/cascade/" });
352
+ const child = await createTaskExtended("cascade child", { agentId, parentTaskId: root.id });
353
+ const leaf = await createTaskExtended("cascade leaf", { agentId, parentTaskId: child.id });
354
+ const unchanged = await createTaskExtended("already moved", {
355
+ agentId,
356
+ parentTaskId: root.id,
357
+ key: "shared/cascade-target/",
358
+ });
359
+ const provider = { providerId: "agent-fs", providerKey: "cascade-file.md" };
360
+ await insertTaskAttachment({
361
+ taskId: leaf.id,
362
+ name: "cascade-file.md",
363
+ kind: "agent-fs",
364
+ ...provider,
365
+ });
366
+ const mapping = (await getAssetKeyMappingByProvider(provider))!;
367
+ const lastUpdatedAt = "2026-01-01T00:00:00.000Z";
368
+ for (const task of [root, child, leaf, unchanged]) {
369
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
370
+ lastUpdatedAt,
371
+ task.id,
372
+ ]);
373
+ }
374
+ const move = await api(
375
+ "PATCH",
376
+ `/api/assets/task/${root.id}/key`,
377
+ {
378
+ key: "shared/cascade-target/",
379
+ cascade: true,
380
+ },
381
+ { agentId },
382
+ );
383
+ expect(move.status).toBe(200);
384
+ expect(move.body.movedTaskIds).toEqual([root.id, child.id, leaf.id]);
385
+ for (const task of [root, child, leaf, unchanged]) {
386
+ const moved = (await getTaskById(task.id))!;
387
+ expect(moved.key).toBe("shared/cascade-target/");
388
+ expect(moved.lastUpdatedAt).toBe(lastUpdatedAt);
389
+ }
390
+ expect((await getAssetKeyMappingByProvider(provider))?.key).toBe("shared/cascade-target/");
391
+ const history = await getDbClient().query<{ entity_id: string }>(
392
+ "SELECT entity_id FROM asset_key_history WHERE entity_id IN (?, ?, ?, ?, ?) AND new_key = ?",
393
+ [root.id, child.id, leaf.id, unchanged.id, mapping.id, "shared/cascade-target/"],
394
+ );
395
+ expect(history.map((row) => row.entity_id).sort()).toEqual(
396
+ [root.id, child.id, leaf.id, mapping.id].sort(),
397
+ );
398
+ expect(
399
+ (
400
+ await api(
401
+ "PATCH",
402
+ `/api/assets/task/${root.id}/key`,
403
+ {
404
+ key: "shared/cascade-target/",
405
+ cascade: true,
406
+ },
407
+ { agentId },
408
+ )
409
+ ).body.movedTaskIds,
410
+ ).toEqual([]);
411
+
412
+ // The root can already have the destination key while descendants still need a move.
413
+ await getDbClient().run('UPDATE agent_tasks SET "key" = ? WHERE id = ?', [
414
+ "shared/other-child/",
415
+ child.id,
416
+ ]);
417
+ expect(
418
+ (
419
+ await api(
420
+ "PATCH",
421
+ `/api/assets/task/${root.id}/key`,
422
+ {
423
+ key: "shared/cascade-target/",
424
+ cascade: true,
425
+ },
426
+ { agentId },
427
+ )
428
+ ).body.movedTaskIds,
429
+ ).toEqual([child.id]);
430
+ });
431
+
432
+ test("cascade denies the whole move when a descendant is not authorized", async () => {
433
+ const root = await createTaskExtended("allowed root", { agentId });
434
+ const child = await createTaskExtended("denied child", {
435
+ agentId: secondAgentId,
436
+ creatorAgentId: secondAgentId,
437
+ parentTaskId: root.id,
438
+ });
439
+ const move = await api(
440
+ "PATCH",
441
+ `/api/assets/task/${root.id}/key`,
442
+ {
443
+ key: "shared/must-not-move/",
444
+ cascade: true,
445
+ },
446
+ { agentId },
447
+ );
448
+ expect(move.status).toBe(403);
449
+ expect((await getTaskById(root.id))?.key).toBe(root.key);
450
+ expect((await getTaskById(child.id))?.key).toBe(child.key);
451
+ expect(
452
+ await getDbClient().query("SELECT id FROM asset_key_history WHERE entity_id IN (?, ?)", [
453
+ root.id,
454
+ child.id,
455
+ ]),
456
+ ).toEqual([]);
457
+ });
458
+
459
+ test("cascade is rejected for non-task entities", async () => {
460
+ for (const entityType of ["workflow", "schedule", "page", "file", "app", "script"]) {
461
+ const move = await api(
462
+ "PATCH",
463
+ `/api/assets/${entityType}/${crypto.randomUUID()}/key`,
464
+ {
465
+ key: "shared/invalid-cascade/",
466
+ cascade: true,
467
+ },
468
+ { operator: true },
469
+ );
470
+ expect(move.status).toBe(400);
471
+ expect(move.body.error).toContain("only supported for task");
472
+ }
473
+ });
346
474
  });
@@ -9,6 +9,8 @@ import {
9
9
  createTaskExtended,
10
10
  createUser,
11
11
  createWorkflow,
12
+ deleteTask,
13
+ deleteTaskAttachment,
12
14
  getAssetKeyMappingByProvider,
13
15
  getDb,
14
16
  getDbClient,
@@ -166,6 +168,11 @@ describe("cross-entity asset namespace invariants", () => {
166
168
  ).sourceEntityType,
167
169
  ).toBe("task-attachment");
168
170
 
171
+ const lastUpdatedAt = "2026-01-01T00:00:00.000Z";
172
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
173
+ lastUpdatedAt,
174
+ task.id,
175
+ ]);
169
176
  expect(
170
177
  await moveAssetKey({
171
178
  entityType: "task",
@@ -173,7 +180,8 @@ describe("cross-entity asset namespace invariants", () => {
173
180
  key: "shared/archive/",
174
181
  changedBy: userId,
175
182
  }),
176
- ).toBe(true);
183
+ ).toEqual({ movedTaskIds: [task.id] });
184
+ expect((await getTaskById(task.id))?.lastUpdatedAt).toBe(lastUpdatedAt);
177
185
  expect((await getTaskById(task.id))?.key).toBe("shared/archive/");
178
186
  const after = await getAssetKeyMappingByProvider({
179
187
  providerId: "agent-fs",
@@ -251,7 +259,7 @@ describe("cross-entity asset namespace invariants", () => {
251
259
  key: "shared/products/",
252
260
  changedBy: userId,
253
261
  }),
254
- ).toBe(true);
262
+ ).toEqual({});
255
263
  expect(
256
264
  await moveAssetKey({
257
265
  entityType: "script",
@@ -259,7 +267,7 @@ describe("cross-entity asset namespace invariants", () => {
259
267
  key: "shared/automation/",
260
268
  changedBy: userId,
261
269
  }),
262
- ).toBe(true);
270
+ ).toEqual({});
263
271
 
264
272
  expect(
265
273
  await getDbClient().get<{ key: string }>('SELECT "key" AS key FROM apps WHERE id = ?', [
@@ -330,6 +338,174 @@ describe("cross-entity asset namespace invariants", () => {
330
338
  expect(auditAssetKeys(getDb()).warningCount).toBe(0);
331
339
  });
332
340
 
341
+ test("shared provider files allow moves and follow only their source task", async () => {
342
+ const first = await createTaskExtended("first attachment owner", {
343
+ agentId,
344
+ key: "shared/first/",
345
+ });
346
+ const source = await createTaskExtended("source attachment owner", {
347
+ agentId,
348
+ key: "shared/source/",
349
+ });
350
+ const provider = {
351
+ providerId: "agent-fs",
352
+ providerOrgId: "shared-org",
353
+ providerDriveId: "shared-drive",
354
+ providerKey: "shared-file.md",
355
+ };
356
+ for (const task of [first, source]) {
357
+ await insertTaskAttachment({
358
+ taskId: task.id,
359
+ name: "shared-file.md",
360
+ kind: "agent-fs",
361
+ providerId: provider.providerId,
362
+ providerKey: provider.providerKey,
363
+ orgId: provider.providerOrgId,
364
+ driveId: provider.providerDriveId,
365
+ });
366
+ }
367
+ const mapping = (await getAssetKeyMappingByProvider(provider))!;
368
+ expect(mapping.key).toBe(source.key);
369
+ const historyBefore = await getDbClient().query(
370
+ "SELECT id FROM asset_key_history WHERE entity_id = ? ORDER BY id",
371
+ [mapping.id],
372
+ );
373
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
374
+
375
+ await moveAssetKey({ entityType: "task", id: first.id, key: "shared/first-moved/" });
376
+ expect(await getAssetKeyMappingByProvider(provider)).toEqual(mapping);
377
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
378
+ expect(
379
+ await getDbClient().query(
380
+ "SELECT id FROM asset_key_history WHERE entity_id = ? ORDER BY id",
381
+ [mapping.id],
382
+ ),
383
+ ).toEqual(historyBefore);
384
+
385
+ await moveAssetKey({ entityType: "task", id: source.id, key: "shared/source-moved/" });
386
+ expect((await getAssetKeyMappingByProvider(provider))?.key).toBe("shared/source-moved/");
387
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
388
+ expect(
389
+ await getDbClient().query("SELECT entity_type FROM asset_key_history WHERE entity_id = ?", [
390
+ mapping.id,
391
+ ]),
392
+ ).toHaveLength(historyBefore.length + 1);
393
+
394
+ await getDbClient().run(
395
+ `UPDATE asset_key_mappings SET "key" = 'shared/no-attaching-task/' WHERE id = ?`,
396
+ [mapping.id],
397
+ );
398
+ const drift = auditAssetKeys(getDb()).issues.filter(
399
+ (issue) => issue.code === "provider-mapping-drift",
400
+ );
401
+ expect(drift).toHaveLength(1);
402
+ expect(drift[0]?.entityId).toBe(mapping.id);
403
+ await expect(
404
+ moveAssetKey({ entityType: "task", id: first.id, key: "shared/blocked/" }),
405
+ ).rejects.toThrow("blocked until");
406
+ await getDbClient().run(
407
+ `UPDATE asset_key_mappings SET "key" = ?, source_entity_type = 'task-attachment', source_entity_id = ? WHERE id = ?`,
408
+ ["shared/source-moved/", mapping.sourceEntityId!, mapping.id],
409
+ );
410
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
411
+ });
412
+
413
+ test("a task move takes only the descendants the caller passes", async () => {
414
+ const root = await createTaskExtended("partial root", { agentId, key: "shared/partial/" });
415
+ const checked = await createTaskExtended("checked child", { agentId, parentTaskId: root.id });
416
+ const unchecked = await createTaskExtended("unchecked child", {
417
+ agentId,
418
+ parentTaskId: root.id,
419
+ });
420
+
421
+ expect(
422
+ await moveAssetKey({
423
+ entityType: "task",
424
+ id: root.id,
425
+ key: "shared/partial-moved/",
426
+ descendantTaskIds: [checked.id],
427
+ }),
428
+ ).toEqual({ movedTaskIds: [root.id, checked.id] });
429
+ expect((await getTaskById(checked.id))?.key).toBe("shared/partial-moved/");
430
+ expect((await getTaskById(unchecked.id))?.key).toBe("shared/partial/");
431
+ });
432
+
433
+ test("deleting the source attachment of a shared file leaves moves open", async () => {
434
+ const [kept, deleted, removed] = await Promise.all(
435
+ ["shared/detach-a/", "shared/detach-b/", "shared/detach-c/"].map((key) =>
436
+ createTaskExtended(`detach ${key}`, { agentId, key }),
437
+ ),
438
+ );
439
+ const other = await createTaskExtended("unrelated move", { agentId, key: "shared/other/" });
440
+ const provider = {
441
+ providerId: "agent-fs",
442
+ providerOrgId: "shared-org",
443
+ providerDriveId: "shared-drive",
444
+ providerKey: "detach.md",
445
+ };
446
+ const attach = (taskId: string) =>
447
+ insertTaskAttachment({
448
+ taskId,
449
+ name: "detach.md",
450
+ kind: "agent-fs",
451
+ providerId: provider.providerId,
452
+ providerKey: provider.providerKey,
453
+ orgId: provider.providerOrgId,
454
+ driveId: provider.providerDriveId,
455
+ });
456
+ await attach(kept!.id);
457
+ const sourceAttachment = await attach(deleted!.id);
458
+ expect((await getAssetKeyMappingByProvider(provider))?.key).toBe("shared/detach-b/");
459
+
460
+ expect(await deleteTaskAttachment(sourceAttachment.id)).toBe(true);
461
+ expect(await getAssetKeyMappingByProvider(provider)).toMatchObject({
462
+ key: "shared/detach-b/",
463
+ sourceEntityType: "external",
464
+ });
465
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
466
+ await moveAssetKey({ entityType: "task", id: other.id, key: "shared/other-moved/" });
467
+
468
+ await attach(removed!.id);
469
+ expect(await deleteTask(removed!.id)).toBe(true);
470
+ expect(await getAssetKeyMappingByProvider(provider)).toMatchObject({
471
+ key: "shared/detach-c/",
472
+ sourceEntityType: "external",
473
+ });
474
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
475
+
476
+ await moveAssetKey({ entityType: "task", id: kept!.id, key: "shared/detach-moved/" });
477
+ expect((await getAssetKeyMappingByProvider(provider))?.key).toBe("shared/detach-moved/");
478
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
479
+ });
480
+
481
+ test("an external mapping that a task still attaches follows that task's move", async () => {
482
+ const task = await createTaskExtended("external owner", { agentId, key: "shared/external/" });
483
+ const provider = {
484
+ providerId: "agent-fs",
485
+ providerOrgId: "shared-org",
486
+ providerDriveId: "shared-drive",
487
+ providerKey: "external.md",
488
+ };
489
+ await insertTaskAttachment({
490
+ taskId: task.id,
491
+ name: "external.md",
492
+ kind: "agent-fs",
493
+ providerId: provider.providerId,
494
+ providerKey: provider.providerKey,
495
+ orgId: provider.providerOrgId,
496
+ driveId: provider.providerDriveId,
497
+ });
498
+ const mapping = (await getAssetKeyMappingByProvider(provider))!;
499
+ await getDbClient().run(
500
+ "UPDATE asset_key_mappings SET source_entity_type = 'external', source_entity_id = NULL WHERE id = ?",
501
+ [mapping.id],
502
+ );
503
+
504
+ await moveAssetKey({ entityType: "task", id: task.id, key: "shared/external-moved/" });
505
+ expect((await getAssetKeyMappingByProvider(provider))?.key).toBe("shared/external-moved/");
506
+ expect(auditAssetKeys(getDb()).ok).toBe(true);
507
+ });
508
+
333
509
  test("personal namespace users are audited and missing users are repairable warnings", async () => {
334
510
  const task = await createTaskExtended("personal", {
335
511
  agentId,