@desplega.ai/agent-swarm 1.82.0 → 1.83.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/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db.ts +14 -1
- package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
- package/src/commands/provider-credentials.ts +11 -0
- package/src/http/tasks.ts +7 -3
- package/src/providers/pi-mono-adapter.ts +20 -3
- package/src/providers/types.ts +5 -1
- package/src/slack/blocks.ts +132 -1
- package/src/slack/responses.ts +15 -5
- package/src/slack/watcher.ts +12 -0
- package/src/tests/credential-check.test.ts +47 -0
- package/src/tests/rest-api.test.ts +51 -1
- package/src/tests/slack-attachments-block.test.ts +240 -0
- package/src/tests/slack-blocks.test.ts +162 -0
- package/src/tests/slack-watcher.test.ts +83 -0
- package/src/tests/store-progress-attachments-handler.test.ts +480 -0
- package/src/tests/store-progress-attachments.test.ts +41 -0
- package/src/tools/store-progress.ts +55 -19
- package/src/types.ts +21 -1
- package/src/utils/constants.ts +58 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression coverage for the `store-progress` MCP tool handler — specifically
|
|
3
|
+
* the path that inserts `task_attachments` rows.
|
|
4
|
+
*
|
|
5
|
+
* The Phase 1 + Phase 2a follow-up handler gated the insert behind `!isTerminal`
|
|
6
|
+
* (alongside the no-op short-circuit for status writes), which meant any call
|
|
7
|
+
* to `store-progress(taskId, attachments=[...])` against an already-completed
|
|
8
|
+
* task silently dropped every attachment while still returning `success: true`.
|
|
9
|
+
* The Lead's full smoke battery targets completed parent tasks, so the
|
|
10
|
+
* regression made Phase 1 storage look broken in production.
|
|
11
|
+
*
|
|
12
|
+
* These tests pull the handler straight out of the SDK registry (same pattern
|
|
13
|
+
* as `create-page-tool.test.ts`) and exercise:
|
|
14
|
+
* 1. attachment insert on an in-progress task (smoke baseline)
|
|
15
|
+
* 2. attachment insert on a COMPLETED task — the regression scenario
|
|
16
|
+
* 3. agent-fs attachment with optional `orgId` + `driveId` round-trips
|
|
17
|
+
* 4. agent-fs attachment without `orgId` / `driveId` still inserts (both
|
|
18
|
+
* shapes mandated by the task brief)
|
|
19
|
+
*/
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
import { unlink } from "node:fs/promises";
|
|
23
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
|
+
import {
|
|
25
|
+
closeDb,
|
|
26
|
+
completeTask,
|
|
27
|
+
createAgent,
|
|
28
|
+
createTaskExtended,
|
|
29
|
+
getDb,
|
|
30
|
+
getTaskAttachments,
|
|
31
|
+
initDb,
|
|
32
|
+
startTask,
|
|
33
|
+
upsertSwarmConfig,
|
|
34
|
+
} from "../be/db";
|
|
35
|
+
import { registerStoreProgressTool } from "../tools/store-progress";
|
|
36
|
+
|
|
37
|
+
const TEST_DB_PATH = "./test-store-progress-attachments-handler.sqlite";
|
|
38
|
+
|
|
39
|
+
type RegisteredTool = {
|
|
40
|
+
handler: (args: unknown, extra: unknown) => Promise<unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type StoreProgressResult = {
|
|
44
|
+
structuredContent: {
|
|
45
|
+
success: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
wasNoOp?: boolean;
|
|
48
|
+
yourAgentId?: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function buildServer() {
|
|
53
|
+
const server = new McpServer({
|
|
54
|
+
name: "store-progress-handler-test",
|
|
55
|
+
version: "1.0.0",
|
|
56
|
+
});
|
|
57
|
+
registerStoreProgressTool(server);
|
|
58
|
+
const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
59
|
+
._registeredTools;
|
|
60
|
+
const tool = registered["store-progress"];
|
|
61
|
+
if (!tool) throw new Error("store-progress tool not registered");
|
|
62
|
+
return tool;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("store-progress handler — attachments insert path", () => {
|
|
66
|
+
let agentId: string;
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
70
|
+
try {
|
|
71
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
initDb(TEST_DB_PATH);
|
|
75
|
+
const agent = createAgent({
|
|
76
|
+
name: "Handler Attachments Worker",
|
|
77
|
+
description: "Agent for handler-level attachment tests",
|
|
78
|
+
role: "worker",
|
|
79
|
+
isLead: false,
|
|
80
|
+
status: "busy",
|
|
81
|
+
maxTasks: 1,
|
|
82
|
+
capabilities: [],
|
|
83
|
+
});
|
|
84
|
+
agentId = agent.id;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
closeDb();
|
|
89
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
90
|
+
try {
|
|
91
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function buildMeta() {
|
|
97
|
+
return {
|
|
98
|
+
sessionId: `session-${crypto.randomUUID()}`,
|
|
99
|
+
requestInfo: { headers: { "x-agent-id": agentId } },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test("inserts attachment row on an in-progress task (baseline)", async () => {
|
|
104
|
+
const task = createTaskExtended("handler in-progress baseline", {
|
|
105
|
+
agentId,
|
|
106
|
+
source: "mcp",
|
|
107
|
+
priority: 50,
|
|
108
|
+
});
|
|
109
|
+
startTask(task.id, agentId);
|
|
110
|
+
|
|
111
|
+
const tool = buildServer();
|
|
112
|
+
const result = (await tool.handler(
|
|
113
|
+
{
|
|
114
|
+
taskId: task.id,
|
|
115
|
+
progress: "smoke",
|
|
116
|
+
attachments: [{ kind: "url", name: "example", url: "https://example.com/baseline" }],
|
|
117
|
+
},
|
|
118
|
+
buildMeta(),
|
|
119
|
+
)) as StoreProgressResult;
|
|
120
|
+
|
|
121
|
+
expect(result.structuredContent.success).toBe(true);
|
|
122
|
+
const rows = getTaskAttachments(task.id);
|
|
123
|
+
expect(rows.length).toBe(1);
|
|
124
|
+
expect(rows[0].kind).toBe("url");
|
|
125
|
+
expect(rows[0].url).toBe("https://example.com/baseline");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("inserts attachment row on an ALREADY-COMPLETED task (PR #542 regression)", async () => {
|
|
129
|
+
const task = createTaskExtended("handler post-completion attachment", {
|
|
130
|
+
agentId,
|
|
131
|
+
source: "mcp",
|
|
132
|
+
priority: 50,
|
|
133
|
+
});
|
|
134
|
+
startTask(task.id, agentId);
|
|
135
|
+
const completed = completeTask(task.id, "done");
|
|
136
|
+
expect(completed?.status).toBe("completed");
|
|
137
|
+
|
|
138
|
+
// Lead's smoke shape: just a minimal URL attachment, no status field, no
|
|
139
|
+
// progress text. Pre-fix this returned `success: true` and inserted zero
|
|
140
|
+
// rows. Post-fix the row is appended in place.
|
|
141
|
+
const tool = buildServer();
|
|
142
|
+
const result = (await tool.handler(
|
|
143
|
+
{
|
|
144
|
+
taskId: task.id,
|
|
145
|
+
attachments: [{ kind: "url", name: "post-completion link", url: "https://example.com/x" }],
|
|
146
|
+
},
|
|
147
|
+
buildMeta(),
|
|
148
|
+
)) as StoreProgressResult;
|
|
149
|
+
|
|
150
|
+
expect(result.structuredContent.success).toBe(true);
|
|
151
|
+
const rows = getTaskAttachments(task.id);
|
|
152
|
+
expect(rows.length).toBe(1);
|
|
153
|
+
expect(rows[0].kind).toBe("url");
|
|
154
|
+
expect(rows[0].name).toBe("post-completion link");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("agent-fs attachment with optional orgId + driveId round-trips through the handler", async () => {
|
|
158
|
+
const task = createTaskExtended("handler agent-fs with org/drive", {
|
|
159
|
+
agentId,
|
|
160
|
+
source: "mcp",
|
|
161
|
+
priority: 50,
|
|
162
|
+
});
|
|
163
|
+
startTask(task.id, agentId);
|
|
164
|
+
|
|
165
|
+
const tool = buildServer();
|
|
166
|
+
const result = (await tool.handler(
|
|
167
|
+
{
|
|
168
|
+
taskId: task.id,
|
|
169
|
+
attachments: [
|
|
170
|
+
{
|
|
171
|
+
kind: "agent-fs",
|
|
172
|
+
name: "doc.md",
|
|
173
|
+
path: "/thoughts/doc.md",
|
|
174
|
+
orgId: "org-abc",
|
|
175
|
+
driveId: "drive-xyz",
|
|
176
|
+
intent: "linkable artifact",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
buildMeta(),
|
|
181
|
+
)) as StoreProgressResult;
|
|
182
|
+
|
|
183
|
+
expect(result.structuredContent.success).toBe(true);
|
|
184
|
+
const rows = getTaskAttachments(task.id);
|
|
185
|
+
expect(rows.length).toBe(1);
|
|
186
|
+
expect(rows[0].kind).toBe("agent-fs");
|
|
187
|
+
expect(rows[0].path).toBe("/thoughts/doc.md");
|
|
188
|
+
expect(rows[0].orgId).toBe("org-abc");
|
|
189
|
+
expect(rows[0].driveId).toBe("drive-xyz");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("agent-fs attachment WITHOUT orgId / driveId still inserts (legacy shape)", async () => {
|
|
193
|
+
const task = createTaskExtended("handler agent-fs without org/drive", {
|
|
194
|
+
agentId,
|
|
195
|
+
source: "mcp",
|
|
196
|
+
priority: 50,
|
|
197
|
+
});
|
|
198
|
+
startTask(task.id, agentId);
|
|
199
|
+
|
|
200
|
+
const tool = buildServer();
|
|
201
|
+
const result = (await tool.handler(
|
|
202
|
+
{
|
|
203
|
+
taskId: task.id,
|
|
204
|
+
attachments: [
|
|
205
|
+
{
|
|
206
|
+
kind: "agent-fs",
|
|
207
|
+
name: "legacy.md",
|
|
208
|
+
path: "/thoughts/legacy.md",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
buildMeta(),
|
|
213
|
+
)) as StoreProgressResult;
|
|
214
|
+
|
|
215
|
+
expect(result.structuredContent.success).toBe(true);
|
|
216
|
+
const rows = getTaskAttachments(task.id);
|
|
217
|
+
expect(rows.length).toBe(1);
|
|
218
|
+
expect(rows[0].kind).toBe("agent-fs");
|
|
219
|
+
expect(rows[0].path).toBe("/thoughts/legacy.md");
|
|
220
|
+
expect(rows[0].orgId).toBeUndefined();
|
|
221
|
+
expect(rows[0].driveId).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("agent-fs orgId/driveId auto-resolve from swarm config", () => {
|
|
225
|
+
// Per-test cleanup so config rows from one case don't leak into the next.
|
|
226
|
+
function clearSwarmConfig() {
|
|
227
|
+
getDb().run("DELETE FROM swarm_config");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
test("missing orgId/driveId fills in from global swarm config", async () => {
|
|
231
|
+
clearSwarmConfig();
|
|
232
|
+
upsertSwarmConfig({
|
|
233
|
+
scope: "global",
|
|
234
|
+
key: "AGENT_FS_DEFAULT_ORG_ID",
|
|
235
|
+
value: "global-org",
|
|
236
|
+
});
|
|
237
|
+
upsertSwarmConfig({
|
|
238
|
+
scope: "global",
|
|
239
|
+
key: "AGENT_FS_DEFAULT_DRIVE_ID",
|
|
240
|
+
value: "global-drive",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const task = createTaskExtended("handler agent-fs auto-resolve global", {
|
|
244
|
+
agentId,
|
|
245
|
+
source: "mcp",
|
|
246
|
+
priority: 50,
|
|
247
|
+
});
|
|
248
|
+
startTask(task.id, agentId);
|
|
249
|
+
|
|
250
|
+
const tool = buildServer();
|
|
251
|
+
const result = (await tool.handler(
|
|
252
|
+
{
|
|
253
|
+
taskId: task.id,
|
|
254
|
+
attachments: [
|
|
255
|
+
{
|
|
256
|
+
kind: "agent-fs",
|
|
257
|
+
name: "doc.md",
|
|
258
|
+
path: "/thoughts/auto.md",
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
buildMeta(),
|
|
263
|
+
)) as StoreProgressResult;
|
|
264
|
+
|
|
265
|
+
expect(result.structuredContent.success).toBe(true);
|
|
266
|
+
const rows = getTaskAttachments(task.id);
|
|
267
|
+
expect(rows.length).toBe(1);
|
|
268
|
+
expect(rows[0].kind).toBe("agent-fs");
|
|
269
|
+
expect(rows[0].orgId).toBe("global-org");
|
|
270
|
+
expect(rows[0].driveId).toBe("global-drive");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("agent-scoped config wins over global (scope precedence)", async () => {
|
|
274
|
+
clearSwarmConfig();
|
|
275
|
+
upsertSwarmConfig({
|
|
276
|
+
scope: "global",
|
|
277
|
+
key: "AGENT_FS_DEFAULT_ORG_ID",
|
|
278
|
+
value: "global-org",
|
|
279
|
+
});
|
|
280
|
+
upsertSwarmConfig({
|
|
281
|
+
scope: "global",
|
|
282
|
+
key: "AGENT_FS_DEFAULT_DRIVE_ID",
|
|
283
|
+
value: "global-drive",
|
|
284
|
+
});
|
|
285
|
+
upsertSwarmConfig({
|
|
286
|
+
scope: "agent",
|
|
287
|
+
scopeId: agentId,
|
|
288
|
+
key: "AGENT_FS_DEFAULT_ORG_ID",
|
|
289
|
+
value: "agent-org",
|
|
290
|
+
});
|
|
291
|
+
upsertSwarmConfig({
|
|
292
|
+
scope: "agent",
|
|
293
|
+
scopeId: agentId,
|
|
294
|
+
key: "AGENT_FS_DEFAULT_DRIVE_ID",
|
|
295
|
+
value: "agent-drive",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const task = createTaskExtended("handler agent-fs auto-resolve agent-scope", {
|
|
299
|
+
agentId,
|
|
300
|
+
source: "mcp",
|
|
301
|
+
priority: 50,
|
|
302
|
+
});
|
|
303
|
+
startTask(task.id, agentId);
|
|
304
|
+
|
|
305
|
+
const tool = buildServer();
|
|
306
|
+
const result = (await tool.handler(
|
|
307
|
+
{
|
|
308
|
+
taskId: task.id,
|
|
309
|
+
attachments: [
|
|
310
|
+
{
|
|
311
|
+
kind: "agent-fs",
|
|
312
|
+
name: "scoped.md",
|
|
313
|
+
path: "/thoughts/scoped.md",
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
buildMeta(),
|
|
318
|
+
)) as StoreProgressResult;
|
|
319
|
+
|
|
320
|
+
expect(result.structuredContent.success).toBe(true);
|
|
321
|
+
const rows = getTaskAttachments(task.id);
|
|
322
|
+
expect(rows.length).toBe(1);
|
|
323
|
+
expect(rows[0].orgId).toBe("agent-org");
|
|
324
|
+
expect(rows[0].driveId).toBe("agent-drive");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("missing config + missing row IDs leaves null IDs (no throw, renderer falls back)", async () => {
|
|
328
|
+
clearSwarmConfig();
|
|
329
|
+
|
|
330
|
+
const task = createTaskExtended("handler agent-fs no config no ids", {
|
|
331
|
+
agentId,
|
|
332
|
+
source: "mcp",
|
|
333
|
+
priority: 50,
|
|
334
|
+
});
|
|
335
|
+
startTask(task.id, agentId);
|
|
336
|
+
|
|
337
|
+
const tool = buildServer();
|
|
338
|
+
const result = (await tool.handler(
|
|
339
|
+
{
|
|
340
|
+
taskId: task.id,
|
|
341
|
+
attachments: [
|
|
342
|
+
{
|
|
343
|
+
kind: "agent-fs",
|
|
344
|
+
name: "no-ids.md",
|
|
345
|
+
path: "/thoughts/no-ids.md",
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
buildMeta(),
|
|
350
|
+
)) as StoreProgressResult;
|
|
351
|
+
|
|
352
|
+
expect(result.structuredContent.success).toBe(true);
|
|
353
|
+
const rows = getTaskAttachments(task.id);
|
|
354
|
+
expect(rows.length).toBe(1);
|
|
355
|
+
expect(rows[0].orgId).toBeUndefined();
|
|
356
|
+
expect(rows[0].driveId).toBeUndefined();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("per-row IDs always win — config defaults never overwrite explicit values", async () => {
|
|
360
|
+
clearSwarmConfig();
|
|
361
|
+
upsertSwarmConfig({
|
|
362
|
+
scope: "global",
|
|
363
|
+
key: "AGENT_FS_DEFAULT_ORG_ID",
|
|
364
|
+
value: "global-org",
|
|
365
|
+
});
|
|
366
|
+
upsertSwarmConfig({
|
|
367
|
+
scope: "global",
|
|
368
|
+
key: "AGENT_FS_DEFAULT_DRIVE_ID",
|
|
369
|
+
value: "global-drive",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const task = createTaskExtended("handler agent-fs per-row wins", {
|
|
373
|
+
agentId,
|
|
374
|
+
source: "mcp",
|
|
375
|
+
priority: 50,
|
|
376
|
+
});
|
|
377
|
+
startTask(task.id, agentId);
|
|
378
|
+
|
|
379
|
+
const tool = buildServer();
|
|
380
|
+
const result = (await tool.handler(
|
|
381
|
+
{
|
|
382
|
+
taskId: task.id,
|
|
383
|
+
attachments: [
|
|
384
|
+
{
|
|
385
|
+
kind: "agent-fs",
|
|
386
|
+
name: "explicit.md",
|
|
387
|
+
path: "/thoughts/explicit.md",
|
|
388
|
+
orgId: "row-org",
|
|
389
|
+
driveId: "row-drive",
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
buildMeta(),
|
|
394
|
+
)) as StoreProgressResult;
|
|
395
|
+
|
|
396
|
+
expect(result.structuredContent.success).toBe(true);
|
|
397
|
+
const rows = getTaskAttachments(task.id);
|
|
398
|
+
expect(rows.length).toBe(1);
|
|
399
|
+
expect(rows[0].orgId).toBe("row-org");
|
|
400
|
+
expect(rows[0].driveId).toBe("row-drive");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("partial row IDs — only the missing one is filled from config", async () => {
|
|
404
|
+
clearSwarmConfig();
|
|
405
|
+
upsertSwarmConfig({
|
|
406
|
+
scope: "global",
|
|
407
|
+
key: "AGENT_FS_DEFAULT_ORG_ID",
|
|
408
|
+
value: "global-org",
|
|
409
|
+
});
|
|
410
|
+
upsertSwarmConfig({
|
|
411
|
+
scope: "global",
|
|
412
|
+
key: "AGENT_FS_DEFAULT_DRIVE_ID",
|
|
413
|
+
value: "global-drive",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const task = createTaskExtended("handler agent-fs partial fill", {
|
|
417
|
+
agentId,
|
|
418
|
+
source: "mcp",
|
|
419
|
+
priority: 50,
|
|
420
|
+
});
|
|
421
|
+
startTask(task.id, agentId);
|
|
422
|
+
|
|
423
|
+
const tool = buildServer();
|
|
424
|
+
const result = (await tool.handler(
|
|
425
|
+
{
|
|
426
|
+
taskId: task.id,
|
|
427
|
+
attachments: [
|
|
428
|
+
{
|
|
429
|
+
kind: "agent-fs",
|
|
430
|
+
name: "partial.md",
|
|
431
|
+
path: "/thoughts/partial.md",
|
|
432
|
+
orgId: "row-org",
|
|
433
|
+
// driveId omitted on purpose
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
buildMeta(),
|
|
438
|
+
)) as StoreProgressResult;
|
|
439
|
+
|
|
440
|
+
expect(result.structuredContent.success).toBe(true);
|
|
441
|
+
const rows = getTaskAttachments(task.id);
|
|
442
|
+
expect(rows.length).toBe(1);
|
|
443
|
+
expect(rows[0].orgId).toBe("row-org");
|
|
444
|
+
expect(rows[0].driveId).toBe("global-drive");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("status='completed' on a terminal task still no-ops but attachments append", async () => {
|
|
449
|
+
// Lead's other shape: re-issue completion with attachments piggy-backed.
|
|
450
|
+
// The no-op short-circuit must still fire for the status write (no
|
|
451
|
+
// duplicate completion / follow-up), but attachments are append-only and
|
|
452
|
+
// dedup-safe so they land.
|
|
453
|
+
const task = createTaskExtended("handler retry completion with attachments", {
|
|
454
|
+
agentId,
|
|
455
|
+
source: "mcp",
|
|
456
|
+
priority: 50,
|
|
457
|
+
});
|
|
458
|
+
startTask(task.id, agentId);
|
|
459
|
+
completeTask(task.id, "first");
|
|
460
|
+
|
|
461
|
+
const tool = buildServer();
|
|
462
|
+
const result = (await tool.handler(
|
|
463
|
+
{
|
|
464
|
+
taskId: task.id,
|
|
465
|
+
status: "completed",
|
|
466
|
+
output: "second (ignored)",
|
|
467
|
+
attachments: [
|
|
468
|
+
{ kind: "url", name: "after first completion", url: "https://example.com/retry" },
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
buildMeta(),
|
|
472
|
+
)) as StoreProgressResult;
|
|
473
|
+
|
|
474
|
+
expect(result.structuredContent.success).toBe(true);
|
|
475
|
+
expect(result.structuredContent.wasNoOp).toBe(true);
|
|
476
|
+
const rows = getTaskAttachments(task.id);
|
|
477
|
+
expect(rows.length).toBe(1);
|
|
478
|
+
expect(rows[0].url).toBe("https://example.com/retry");
|
|
479
|
+
});
|
|
480
|
+
});
|
|
@@ -309,4 +309,45 @@ describe("task_attachments — Phase 1 (pointer-based, append-only)", () => {
|
|
|
309
309
|
expect(rows.length).toBe(1);
|
|
310
310
|
expect(TaskAttachmentSchema.safeParse(rows[0]).success).toBe(true);
|
|
311
311
|
});
|
|
312
|
+
|
|
313
|
+
// Phase 2a follow-up: agent-fs attachments can now carry org_id / drive_id
|
|
314
|
+
// so renderers (Slack, UI) can build a public live-host URL.
|
|
315
|
+
test("agent-fs attachment persists orgId and driveId across the round-trip", () => {
|
|
316
|
+
const task = newTask("agent-fs org/drive round-trip");
|
|
317
|
+
const stored = insertTaskAttachment({
|
|
318
|
+
taskId: task.id,
|
|
319
|
+
agentId,
|
|
320
|
+
name: "doc.md",
|
|
321
|
+
kind: "agent-fs",
|
|
322
|
+
path: "/thoughts/doc.md",
|
|
323
|
+
orgId: "org-abc",
|
|
324
|
+
driveId: "drive-xyz",
|
|
325
|
+
});
|
|
326
|
+
expect(stored.orgId).toBe("org-abc");
|
|
327
|
+
expect(stored.driveId).toBe("drive-xyz");
|
|
328
|
+
|
|
329
|
+
const rows = getTaskAttachments(task.id);
|
|
330
|
+
const target = rows.find((r) => r.id === stored.id);
|
|
331
|
+
expect(target?.orgId).toBe("org-abc");
|
|
332
|
+
expect(target?.driveId).toBe("drive-xyz");
|
|
333
|
+
expect(TaskAttachmentSchema.safeParse(target).success).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("AttachmentInputSchema accepts agent-fs with optional orgId/driveId", () => {
|
|
337
|
+
const withIds = AttachmentInputSchema.safeParse({
|
|
338
|
+
kind: "agent-fs",
|
|
339
|
+
name: "doc",
|
|
340
|
+
path: "/x",
|
|
341
|
+
orgId: "o",
|
|
342
|
+
driveId: "d",
|
|
343
|
+
});
|
|
344
|
+
expect(withIds.success).toBe(true);
|
|
345
|
+
|
|
346
|
+
const withoutIds = AttachmentInputSchema.safeParse({
|
|
347
|
+
kind: "agent-fs",
|
|
348
|
+
name: "doc",
|
|
349
|
+
path: "/x",
|
|
350
|
+
});
|
|
351
|
+
expect(withoutIds.success).toBe(true);
|
|
352
|
+
});
|
|
312
353
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getAgentById,
|
|
9
9
|
getDb,
|
|
10
10
|
getLeadAgent,
|
|
11
|
+
getResolvedConfig,
|
|
11
12
|
getSessionLogsByTaskId,
|
|
12
13
|
getTaskAttachments,
|
|
13
14
|
getTaskById,
|
|
@@ -144,29 +145,46 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
144
145
|
let updatedTask = existingTask;
|
|
145
146
|
const isTerminal = ["completed", "failed", "cancelled"].includes(existingTask.status);
|
|
146
147
|
|
|
147
|
-
// Idempotency guard: short-circuit terminal-status writes (completed/failed)
|
|
148
|
-
// BEFORE any side-effects fire (event emission, memory write, follow-up task,
|
|
149
|
-
// business-use ensure). Without this, a multi-session race causes duplicate
|
|
150
|
-
// follow-up tasks to lead, vector index pollution, and spurious BU events.
|
|
151
|
-
// First-call-wins: existing output / finishedAt are preserved.
|
|
152
|
-
if (status && isTerminal) {
|
|
153
|
-
return {
|
|
154
|
-
success: true,
|
|
155
|
-
message:
|
|
156
|
-
`Task "${taskId}" is already ${existingTask.status}; treating as no-op. ` +
|
|
157
|
-
`Existing output preserved (first-call-wins).`,
|
|
158
|
-
task: existingTask,
|
|
159
|
-
wasNoOp: true,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
148
|
// Attachments — pointer-based, append-only. Insert each row inside
|
|
164
149
|
// this transaction; the helper dedups by sha256 (when present) or by
|
|
165
150
|
// (kind, pointer, name), so idempotent re-calls don't fan out
|
|
166
|
-
// duplicates.
|
|
167
|
-
//
|
|
168
|
-
|
|
151
|
+
// duplicates. Run BEFORE the terminal-status short-circuit: smoke
|
|
152
|
+
// tests and post-completion artifact uploads target already-completed
|
|
153
|
+
// tasks, and the schema explicitly documents that attachments "may be
|
|
154
|
+
// sent on any call (progress or completion) and accumulate across
|
|
155
|
+
// calls." Status writes still no-op on terminal tasks (see below);
|
|
156
|
+
// attachment writes don't change task state, so they're safe to
|
|
157
|
+
// accept on any status.
|
|
158
|
+
if (attachments && attachments.length > 0) {
|
|
159
|
+
// Resolve agent-fs default org/drive IDs from swarm config lazily —
|
|
160
|
+
// only if at least one `agent-fs` row arrives with missing IDs.
|
|
161
|
+
// Scope precedence is `getResolvedConfig`'s usual repo > agent >
|
|
162
|
+
// global; we pass the calling agent's id so agent-scoped overrides
|
|
163
|
+
// win. Per-row IDs always take precedence over the config defaults.
|
|
164
|
+
// Env-var fallback in `constants.ts` remains the secondary path for
|
|
165
|
+
// self-hosters who deploy without a config DB.
|
|
166
|
+
let agentFsDefaults: { orgId?: string; driveId?: string } | null = null;
|
|
167
|
+
const resolveAgentFsDefaults = (): { orgId?: string; driveId?: string } => {
|
|
168
|
+
if (agentFsDefaults !== null) return agentFsDefaults;
|
|
169
|
+
const configs = getResolvedConfig(requestInfo.agentId ?? undefined);
|
|
170
|
+
const orgId = configs.find((c) => c.key === "AGENT_FS_DEFAULT_ORG_ID")?.value;
|
|
171
|
+
const driveId = configs.find((c) => c.key === "AGENT_FS_DEFAULT_DRIVE_ID")?.value;
|
|
172
|
+
agentFsDefaults = {
|
|
173
|
+
orgId: orgId && orgId.length > 0 ? orgId : undefined,
|
|
174
|
+
driveId: driveId && driveId.length > 0 ? driveId : undefined,
|
|
175
|
+
};
|
|
176
|
+
return agentFsDefaults;
|
|
177
|
+
};
|
|
178
|
+
|
|
169
179
|
for (const a of attachments) {
|
|
180
|
+
let orgId = a.kind === "agent-fs" ? a.orgId : undefined;
|
|
181
|
+
let driveId = a.kind === "agent-fs" ? a.driveId : undefined;
|
|
182
|
+
if (a.kind === "agent-fs" && (!orgId || !driveId)) {
|
|
183
|
+
const defaults = resolveAgentFsDefaults();
|
|
184
|
+
orgId = orgId || defaults.orgId;
|
|
185
|
+
driveId = driveId || defaults.driveId;
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
insertTaskAttachment({
|
|
171
189
|
taskId,
|
|
172
190
|
agentId: requestInfo.agentId ?? null,
|
|
@@ -175,6 +193,8 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
175
193
|
url: a.kind === "url" ? a.url : undefined,
|
|
176
194
|
path: a.kind === "agent-fs" || a.kind === "shared-fs" ? a.path : undefined,
|
|
177
195
|
pageId: a.kind === "page" ? a.pageId : undefined,
|
|
196
|
+
orgId,
|
|
197
|
+
driveId,
|
|
178
198
|
mimeType: a.mimeType,
|
|
179
199
|
sizeBytes: a.sizeBytes,
|
|
180
200
|
sha256: a.sha256,
|
|
@@ -185,6 +205,22 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
185
205
|
}
|
|
186
206
|
}
|
|
187
207
|
|
|
208
|
+
// Idempotency guard: short-circuit terminal-status writes (completed/failed)
|
|
209
|
+
// BEFORE any side-effects fire (event emission, memory write, follow-up task,
|
|
210
|
+
// business-use ensure). Without this, a multi-session race causes duplicate
|
|
211
|
+
// follow-up tasks to lead, vector index pollution, and spurious BU events.
|
|
212
|
+
// First-call-wins: existing output / finishedAt are preserved.
|
|
213
|
+
if (status && isTerminal) {
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
message:
|
|
217
|
+
`Task "${taskId}" is already ${existingTask.status}; treating as no-op. ` +
|
|
218
|
+
`Existing output preserved (first-call-wins).`,
|
|
219
|
+
task: existingTask,
|
|
220
|
+
wasNoOp: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
188
224
|
// Update progress if provided (with deduplication)
|
|
189
225
|
// Skip for tasks already in a terminal state to prevent zombie revival
|
|
190
226
|
if (progress && !isTerminal) {
|
package/src/types.ts
CHANGED
|
@@ -243,6 +243,20 @@ export const AttachmentInputSchema = z.discriminatedUnion("kind", [
|
|
|
243
243
|
z.object({
|
|
244
244
|
kind: z.literal("agent-fs"),
|
|
245
245
|
path: z.string().min(1).describe("agent-fs path the attachment points at."),
|
|
246
|
+
orgId: z
|
|
247
|
+
.string()
|
|
248
|
+
.min(1)
|
|
249
|
+
.optional()
|
|
250
|
+
.describe(
|
|
251
|
+
"agent-fs org id — paired with `driveId` lets the renderer build a public live-host URL.",
|
|
252
|
+
),
|
|
253
|
+
driveId: z
|
|
254
|
+
.string()
|
|
255
|
+
.min(1)
|
|
256
|
+
.optional()
|
|
257
|
+
.describe(
|
|
258
|
+
"agent-fs drive id — paired with `orgId` lets the renderer build a public live-host URL.",
|
|
259
|
+
),
|
|
246
260
|
...attachmentCommonFields,
|
|
247
261
|
}),
|
|
248
262
|
z.object({
|
|
@@ -272,6 +286,9 @@ export const TaskAttachmentSchema = z.object({
|
|
|
272
286
|
url: z.string().optional(),
|
|
273
287
|
path: z.string().optional(),
|
|
274
288
|
pageId: z.string().optional(),
|
|
289
|
+
// agent-fs only — pair with `path` to build a public live-host URL.
|
|
290
|
+
orgId: z.string().optional(),
|
|
291
|
+
driveId: z.string().optional(),
|
|
275
292
|
mimeType: z.string().optional(),
|
|
276
293
|
sizeBytes: z.number().int().min(0).optional(),
|
|
277
294
|
sha256: z.string().optional(),
|
|
@@ -491,7 +508,10 @@ export type AgentLatestModel = z.infer<typeof AgentLatestModelSchema>;
|
|
|
491
508
|
export const AgentCredStatusSchema = z.object({
|
|
492
509
|
ready: z.boolean(),
|
|
493
510
|
missing: z.array(z.string()).default([]),
|
|
494
|
-
satisfiedBy: z
|
|
511
|
+
satisfiedBy: z
|
|
512
|
+
.enum(["env", "file", "side-effect-pending", "sdk-delegated"])
|
|
513
|
+
.nullable()
|
|
514
|
+
.default(null),
|
|
495
515
|
hint: z.string().nullable().default(null),
|
|
496
516
|
liveTest: AgentCredStatusLiveTestSchema.nullable().default(null),
|
|
497
517
|
latestModel: AgentLatestModelSchema.nullable().default(null),
|