@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.
- package/dist/{acp-adapter-k0vwfyvn.js → acp-adapter-v85a2bqy.js} +1 -1
- package/dist/{actions-w4pnb8vz.js → actions-zgcxfspj.js} +15 -15
- package/dist/{amp-adapter-0cmxb2t5.js → amp-adapter-jw9xfz42.js} +2 -2
- package/dist/{app-zneem7g9.js → app-4e1s4675.js} +3 -3
- package/dist/{assistant-wbzd2rnr.js → assistant-v238n9dk.js} +16 -16
- package/dist/{boot-reembed-4kx69j3g.js → boot-reembed-p666cw5n.js} +1 -1
- package/dist/{boot-reembed-db0wwgy1.js → boot-reembed-w6pm64j4.js} +1 -1
- package/dist/{boot-scrub-sweep-tfv4d3mm.js → boot-scrub-sweep-jcyjagn3.js} +2 -2
- package/dist/{boot-seeds-w2hmkxn5.js → boot-seeds-gbedmamp.js} +4 -4
- package/dist/{claude-adapter-5qrc2paq.js → claude-adapter-q7wspzt8.js} +2 -2
- package/dist/{claude-managed-setup-7ra32em4.js → claude-managed-setup-wza784fb.js} +1 -1
- package/dist/{claude-sdk-session-cd02gc76.js → claude-sdk-session-yf400ptr.js} +2 -2
- package/dist/{cli-p46xdr1m.js → cli-0rh20bp3.js} +1 -1
- package/dist/{cli-e5y285rg.js → cli-1v8w782f.js} +1 -1
- package/dist/{cli-8c1m9mhy.js → cli-30m5q8mc.js} +5 -5
- package/dist/{cli-xnp2vkx7.js → cli-4hmaheb6.js} +6 -6
- package/dist/{cli-xhgtay7w.js → cli-4s1kqrqg.js} +1 -1
- package/dist/{cli-2jm992rm.js → cli-5d1m1qca.js} +2 -2
- package/dist/{cli-mvsfbbgq.js → cli-65q70v2v.js} +3 -3
- package/dist/{cli-th8krqbf.js → cli-6p7ddg72.js} +2 -2
- package/dist/{cli-6q7pyd3k.js → cli-6vcyw6v7.js} +1 -1
- package/dist/{cli-bddxamg7.js → cli-8qsx817b.js} +23 -22
- package/dist/{cli-ztcxv5nv.js → cli-9e9kjqsj.js} +1 -1
- package/dist/{cli-jrk8mj0t.js → cli-a17tzmn0.js} +1 -1
- package/dist/{cli-raejy0mp.js → cli-d43xqzbe.js} +1 -1
- package/dist/{cli-tba8fxf9.js → cli-dxwh4j32.js} +11 -11
- package/dist/{cli-c9vrwcpb.js → cli-ea7chd26.js} +19 -19
- package/dist/{cli-vmbakbq0.js → cli-fhvqt2fp.js} +1 -1
- package/dist/{cli-z3dk189k.js → cli-fp4xbw07.js} +6 -6
- package/dist/{cli-xx13gqf3.js → cli-g6p7c2my.js} +1 -1
- package/dist/{cli-c0dpzwnx.js → cli-hn3pftj2.js} +2 -2
- package/dist/{cli-h23mr2s6.js → cli-hz64bcnj.js} +2 -2
- package/dist/{cli-dw0njd9q.js → cli-jhmjwqcg.js} +5 -5
- package/dist/{cli-0k9rcwfy.js → cli-ka01m5rb.js} +28 -12
- package/dist/{cli-jhhb2v9n.js → cli-kqypty34.js} +2 -2
- package/dist/{cli-hwc4a9xm.js → cli-q1812jbw.js} +4 -4
- package/dist/{cli-8w65vwee.js → cli-qrwp40st.js} +1 -1
- package/dist/{cli-4c7k75rq.js → cli-re4wkbzx.js} +54 -41
- package/dist/{cli-z1hh99j0.js → cli-tr24eeny.js} +2 -2
- package/dist/{cli-q6gw45wj.js → cli-wtg6edvd.js} +1 -1
- package/dist/{cli-mnry16kh.js → cli-xgn4rwyv.js} +4 -4
- package/dist/{cli-5ks6x36q.js → cli-y3dvz6ge.js} +3 -3
- package/dist/{cli-ksrphveb.js → cli-yv0vgscc.js} +1 -0
- package/dist/{cli-ak4c12mh.js → cli-yv3n83x8.js} +1 -1
- package/dist/{cli-9ac0mkg5.js → cli-zes4tsbb.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{codex-adapter-m0fx3v3s.js → codex-adapter-nhjj2veb.js} +1 -1
- package/dist/{codex-session-runner-j9m2gfyy.js → codex-session-runner-6hggrjbx.js} +1 -1
- package/dist/{commands-ktnqwrv4.js → commands-mrw1ph2w.js} +2 -2
- package/dist/{controller-nex2he8v.js → controller-pt1zam5w.js} +2 -2
- package/dist/{controllers-connect-6yhwqv9v.js → controllers-connect-ymw5541d.js} +16 -16
- package/dist/{cursor-adapter-02s5ekva.js → cursor-adapter-cf4zh5x0.js} +1 -1
- package/dist/{db-19tw9r5s.js → db-hhpy6m4x.js} +2 -2
- package/dist/{db-jvr86v7f.js → db-hy80219q.js} +1 -1
- package/dist/{dispatcher-d3mnsg2y.js → dispatcher-6pdjtkr1.js} +14 -14
- package/dist/{dsh-adapter-63rvej88.js → dsh-adapter-fbx06re2.js} +1 -1
- package/dist/{external-channel-44cwer32.js → external-channel-j6vb6ya1.js} +2 -2
- package/dist/{grok-adapter-ysp907f6.js → grok-adapter-1193sprp.js} +2 -2
- package/dist/{handlers-t5pbabwf.js → handlers-tb3xgjnm.js} +17 -17
- package/dist/{heartbeat-2etacsqq.js → heartbeat-h1w1ktce.js} +17 -17
- package/dist/{hook-3kvmax6d.js → hook-ejja4bpe.js} +1 -1
- package/dist/{hook-mzxxs5n2.js → hook-skrbja18.js} +1 -1
- package/dist/{http-fn978m0g.js → http-mzh41mfj.js} +108 -52
- package/dist/{index-039x2sag.js → index-6rmnn89d.js} +40 -12
- package/dist/{index-w2wr89xd.js → index-epr2gnbw.js} +1 -1
- package/dist/{index-qyjbb7br.js → index-nyfcdpky.js} +17 -17
- package/dist/{index-e4tsa1h7.js → index-vjn3wbzf.js} +14 -14
- package/dist/{index-1r8hw4vd.js → index-xmmkyx82.js} +14 -14
- package/dist/{keepalive-d4nzs76x.js → keepalive-4djvbm2j.js} +3 -3
- package/dist/{lead-8h5n9k5f.js → lead-61kqp604.js} +23 -23
- package/dist/{maintenance-ea6jfw42.js → maintenance-f3xhnjsm.js} +1 -1
- package/dist/{oauth-refresh-sweep-3f25djq3.js → oauth-refresh-sweep-fpd6x6rw.js} +2 -2
- package/dist/{onboard-pvk7hdb2.js → onboard-196tjgyn.js} +2 -2
- package/dist/{otel-impl-qb7nhgdp.js → otel-impl-g8fh4c21.js} +1 -1
- package/dist/{pricing-refresh-6ehwkcmp.js → pricing-refresh-sdcf5yaz.js} +3 -3
- package/dist/{rbac-roles-9648nh00.js → rbac-roles-2x4g1wb5.js} +2 -2
- package/dist/{rbac-roles-km0f9cmm.js → rbac-roles-bsp5657v.js} +1 -1
- package/dist/{render-v2-xkze1wef.js → render-v2-0pyb39bb.js} +3 -3
- package/dist/{resume-tq0bbvt5.js → resume-a86tkj6r.js} +5 -5
- package/dist/{setup-rnrazxvd.js → setup-ypbzcapd.js} +1 -1
- package/dist/{work-objects-e75q2rqs.js → work-objects-yjp8de12.js} +17 -17
- package/dist/{worker-4ww4hjef.js → worker-4ptnn1ge.js} +23 -23
- package/openapi.json +168 -6
- package/package.json +2 -2
- package/src/be/asset-key-audit.ts +4 -13
- package/src/be/db.ts +57 -33
- package/src/be/migrations/227_inbox_item_state_last_read_at.sql +1 -0
- package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +22 -4
- package/src/heartbeat/heartbeat.ts +25 -7
- package/src/http/assets.ts +26 -4
- package/src/http/inbox-state.ts +58 -8
- package/src/kv-reserved-namespaces.ts +1 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/tests/asset-key-api.test.ts +128 -0
- package/src/tests/asset-key-invariants.test.ts +179 -3
- package/src/tests/asset-key-migration.test.ts +35 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/heartbeat-checklist.test.ts +88 -0
- package/src/tests/heartbeat-jev-gate.test.ts +394 -10
- package/src/tests/inbox-state.test.ts +167 -0
- package/src/tests/kv-tool.test.ts +15 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/types.ts +2 -0
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/pages/content.md +4 -0
- package/templates/skills/swarm-scripts/SKILL.md +6 -0
- 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?: {
|
|
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
|
|
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
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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
|
|
package/src/http/assets.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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) {
|
package/src/http/inbox-state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
49
|
-
schema: z.object({
|
|
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.
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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,
|