agent-dealer 1.2.13 → 1.2.15

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 (49) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +15 -0
  2. package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
  3. package/bundle/server/dist/adapters/linear-inbox.js +97 -0
  4. package/bundle/server/dist/adapters/linear-inbox.test.js +299 -1
  5. package/bundle/server/dist/adapters/linear-sync.js +33 -10
  6. package/bundle/server/dist/capacity/claude-events.js +25 -5
  7. package/bundle/server/dist/capacity/claude-local-cache.js +159 -13
  8. package/bundle/server/dist/capacity/claude-local-cache.test.js +191 -3
  9. package/bundle/server/dist/coordinator/auto-merge.js +10 -0
  10. package/bundle/server/dist/coordinator/branch-progress.js +5 -4
  11. package/bundle/server/dist/coordinator/commands.js +37 -3
  12. package/bundle/server/dist/coordinator/delete-issue.js +473 -0
  13. package/bundle/server/dist/coordinator/developer-effect.js +33 -6
  14. package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
  15. package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
  16. package/bundle/server/dist/coordinator/prompts.js +9 -1
  17. package/bundle/server/dist/coordinator/prompts.test.js +93 -0
  18. package/bundle/server/dist/coordinator/source-attachments.js +496 -0
  19. package/bundle/server/dist/coordinator/source-attachments.test.js +396 -0
  20. package/bundle/server/dist/coordinator/source-reload.js +178 -0
  21. package/bundle/server/dist/db/index.js +4 -0
  22. package/bundle/server/dist/db/schema.sql +32 -0
  23. package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
  24. package/bundle/server/dist/paths.js +12 -0
  25. package/bundle/server/dist/repository/runtime-capacity.js +19 -3
  26. package/bundle/server/dist/repository/source-attachments.js +56 -0
  27. package/bundle/server/dist/routes/human-actions.js +17 -0
  28. package/bundle/server/dist/routes/issues-delete.test.js +666 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +447 -0
  31. package/bundle/server/dist/routes/issues.js +109 -1
  32. package/bundle/server/dist/routes/runtime-capacity.test.js +35 -0
  33. package/bundle/server/package.json +2 -2
  34. package/bundle/server/static-ui/assets/index-DVyqblie.js +64 -0
  35. package/bundle/server/static-ui/index.html +1 -1
  36. package/bundle/shared/dist/index.d.ts +108 -2
  37. package/bundle/shared/dist/index.js +12 -1
  38. package/bundle/shared/dist/issues.d.ts +43 -2
  39. package/bundle/shared/dist/issues.js +10 -0
  40. package/bundle/shared/dist/linear-intake.d.ts +85 -0
  41. package/bundle/shared/dist/linear-intake.js +56 -0
  42. package/bundle/shared/dist/linear-intake.test.js +55 -1
  43. package/bundle/shared/dist/runtime-capacity.d.ts +147 -0
  44. package/bundle/shared/dist/runtime-capacity.js +15 -0
  45. package/bundle/shared/dist/workflow.d.ts +4 -4
  46. package/bundle/shared/dist/workflow.js +7 -0
  47. package/bundle/shared/package.json +1 -1
  48. package/package.json +1 -1
  49. package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
@@ -0,0 +1,447 @@
1
+ // NOT-364: atomic Linear attachment import and reload reconciliation.
2
+ import { test, before, after, beforeEach } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { createHash } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import Fastify from "fastify";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-src-att-"));
10
+ const { migrate, getDb } = await import("../db/index.js");
11
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
12
+ const { registerIssueRoutes } = await import("./issues.js");
13
+ const { listSourceAttachments } = await import("../repository/source-attachments.js");
14
+ const { listQueuedEntries } = await import("../repository/queue-entries.js");
15
+ const { listIssues } = await import("../repository/issues.js");
16
+ const FILE_URL = "https://uploads.linear.app/abc/repro.tar.gz";
17
+ const LINK_URL = "https://docs.example.com/spec";
18
+ const TARBALL_V1 = Buffer.from("repro-tarball-version-one-bytes");
19
+ const TARBALL_V2 = Buffer.from("repro-tarball-version-two-bytes!!");
20
+ /** Current download bytes per URL; flip to simulate expiry/removal. */
21
+ const downloadBytes = new Map();
22
+ /** Linear issue node answered to GraphQL (reload path). */
23
+ let linearNode = null;
24
+ /** Authorization headers observed on hosted-file downloads (NOT-367). */
25
+ const seenDownloadAuth = [];
26
+ const realFetch = globalThis.fetch;
27
+ before(() => {
28
+ migrate();
29
+ process.env.LINEAR_API_KEY = "test-key";
30
+ globalThis.fetch = (async (url, init) => {
31
+ const target = String(url);
32
+ if (target.includes("api.linear.app")) {
33
+ // Single-issue fetch answers the reload; relation walks (admission
34
+ // blocker checks behind the queue wait reason) answer no blockers.
35
+ const body = JSON.parse(String(init?.body ?? "{}"));
36
+ const payload = typeof body.variables?.id === "string"
37
+ ? { data: { issue: linearNode } }
38
+ : { data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } };
39
+ return new Response(JSON.stringify(payload), {
40
+ status: 200,
41
+ headers: { "Content-Type": "application/json" },
42
+ });
43
+ }
44
+ const headers = init?.headers;
45
+ const auth = headers instanceof Headers
46
+ ? headers.get("authorization")
47
+ : (headers?.Authorization ??
48
+ headers?.authorization ??
49
+ null);
50
+ seenDownloadAuth.push(auth);
51
+ const fixture = downloadBytes.get(target);
52
+ if (fixture === "http401") {
53
+ return new Response("unauthorized", { status: 401 });
54
+ }
55
+ if (fixture === "http500" || fixture === undefined) {
56
+ return new Response("boom", { status: 500 });
57
+ }
58
+ return new Response(new Uint8Array(fixture), {
59
+ status: 200,
60
+ headers: { "Content-Type": "application/gzip", "Content-Length": String(fixture.byteLength) },
61
+ });
62
+ });
63
+ });
64
+ after(() => {
65
+ globalThis.fetch = realFetch;
66
+ delete process.env.LINEAR_API_KEY;
67
+ });
68
+ beforeEach(() => {
69
+ downloadBytes.clear();
70
+ linearNode = null;
71
+ seenDownloadAuth.length = 0;
72
+ getDb().exec(`
73
+ DELETE FROM issue_source_attachments;
74
+ DELETE FROM work_items;
75
+ DELETE FROM human_actions;
76
+ DELETE FROM workflow_events;
77
+ DELETE FROM worker_sessions;
78
+ DELETE FROM artifacts;
79
+ DELETE FROM workflow_instances;
80
+ DELETE FROM queue_entries;
81
+ DELETE FROM issues;
82
+ `);
83
+ });
84
+ async function buildApp() {
85
+ const app = Fastify();
86
+ await registerIssueRoutes(app);
87
+ return app;
88
+ }
89
+ const BASE_LINEAR = {
90
+ title: "NOT-1: import me",
91
+ description: "Do the thing.",
92
+ repo: "acme/app",
93
+ baseBranch: "main",
94
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
95
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
96
+ source: "linear",
97
+ externalId: "linear-uuid-1",
98
+ externalLabel: "NOT-1",
99
+ externalUrl: "https://linear.app/not-so-fat/issue/NOT-1/x",
100
+ };
101
+ const FILE_ATT = { id: "att-file-1", title: "repro.tar.gz", url: FILE_URL };
102
+ const LINK_ATT = { id: "att-link-1", title: "Design doc", url: LINK_URL, subtitle: "Spec" };
103
+ test("import snapshots a hosted .tar.gz and keeps the link as metadata", async () => {
104
+ downloadBytes.set(FILE_URL, TARBALL_V1);
105
+ const app = await buildApp();
106
+ const res = await app.inject({
107
+ method: "POST",
108
+ url: "/api/issues",
109
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT, LINK_ATT] },
110
+ });
111
+ assert.equal(res.statusCode, 200, res.body);
112
+ const created = res.json();
113
+ assert.equal(res.json() && res.json().queue, "enqueued");
114
+ const rows = listSourceAttachments(created.id);
115
+ assert.equal(rows.length, 2);
116
+ const file = rows.find((r) => r.kind === "file");
117
+ assert.equal(file.linearAttachmentId, "att-file-1");
118
+ assert.equal(file.safeFileName, "repro.tar.gz");
119
+ assert.equal(file.sizeBytes, TARBALL_V1.byteLength);
120
+ assert.equal(file.sha256, createHash("sha256").update(TARBALL_V1).digest("hex"));
121
+ assert.ok(file.blobPath && fs.existsSync(file.blobPath));
122
+ assert.deepEqual(fs.readFileSync(file.blobPath), TARBALL_V1);
123
+ const link = rows.find((r) => r.kind === "link");
124
+ assert.equal(link.url, LINK_URL);
125
+ assert.equal(link.blobPath, undefined);
126
+ // NOT-367: the hosted download carried the configured Linear credential.
127
+ assert.deepEqual(seenDownloadAuth, ["test-key"]);
128
+ // The detail carries the same manifest for the Source attachments summary.
129
+ const detail = await app.inject({ method: "GET", url: `/api/issues/${created.id}` });
130
+ assert.equal(detail.statusCode, 200);
131
+ const body = detail.json();
132
+ assert.equal(body.sourceAttachments.length, 2);
133
+ await app.close();
134
+ });
135
+ test("import fails atomically when a hosted file download fails", async () => {
136
+ downloadBytes.set(FILE_URL, "http500");
137
+ const app = await buildApp();
138
+ const res = await app.inject({
139
+ method: "POST",
140
+ url: "/api/issues",
141
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT, LINK_ATT] },
142
+ });
143
+ assert.equal(res.statusCode, 502, res.body);
144
+ assert.match(res.json().error, /HTTP 500/);
145
+ // Zero partial state: no issue, no queue entry, no attachment rows.
146
+ assert.equal(listIssues().length, 0);
147
+ assert.equal(listQueuedEntries().length, 0);
148
+ assert.equal(getDb().prepare("SELECT COUNT(*) AS n FROM issue_source_attachments").get().n, 0);
149
+ await app.close();
150
+ });
151
+ test("import maps an unauthorized (401) download to an auth error with zero rows", async () => {
152
+ downloadBytes.set(FILE_URL, "http401");
153
+ const app = await buildApp();
154
+ const res = await app.inject({
155
+ method: "POST",
156
+ url: "/api/issues",
157
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT, LINK_ATT] },
158
+ });
159
+ assert.equal(res.statusCode, 502, res.body);
160
+ const message = res.json().error;
161
+ assert.match(message, /HTTP 401/);
162
+ assert.match(message, /Unauthorized/);
163
+ assert.match(message, /rejected the download credentials/);
164
+ assert.match(message, /LINEAR_API_KEY/);
165
+ assert.ok(!/expired/i.test(message), "a 401 must not blame URL expiry");
166
+ // Zero partial state: no issue, no queue entry, no attachment rows.
167
+ assert.equal(listIssues().length, 0);
168
+ assert.equal(listQueuedEntries().length, 0);
169
+ assert.equal(getDb().prepare("SELECT COUNT(*) AS n FROM issue_source_attachments").get().n, 0);
170
+ await app.close();
171
+ });
172
+ test("import refuses an oversized hosted file with zero rows", async () => {
173
+ const app = await buildApp();
174
+ const huge = { id: "att-big", title: "huge.bin", url: "https://uploads.linear.app/abc/huge.bin" };
175
+ downloadBytes.set(huge.url, Buffer.from("small-body"));
176
+ // Lie about the size the way an over-limit Linear upload would declare it.
177
+ const realMock = globalThis.fetch;
178
+ globalThis.fetch = (async (url, init) => {
179
+ if (String(url).includes("api.linear.app")) {
180
+ const parsed = JSON.parse(String(init?.body ?? "{}"));
181
+ const payload = typeof parsed.variables?.id === "string"
182
+ ? { data: { issue: null } }
183
+ : { data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } };
184
+ return new Response(JSON.stringify(payload), { status: 200 });
185
+ }
186
+ return new Response(new Uint8Array(Buffer.from("small-body")), {
187
+ status: 200,
188
+ headers: { "Content-Type": "application/octet-stream", "Content-Length": String(300 * 1024 * 1024) },
189
+ });
190
+ });
191
+ try {
192
+ const res = await app.inject({
193
+ method: "POST",
194
+ url: "/api/issues",
195
+ payload: { ...BASE_LINEAR, linearAttachments: [huge] },
196
+ });
197
+ assert.equal(res.statusCode, 400, res.body);
198
+ assert.match(res.json().error, /too large to snapshot/);
199
+ assert.equal(listIssues().length, 0);
200
+ assert.equal(getDb().prepare("SELECT COUNT(*) AS n FROM issue_source_attachments").get().n, 0);
201
+ }
202
+ finally {
203
+ globalThis.fetch = realMock;
204
+ }
205
+ await app.close();
206
+ });
207
+ test("link-only import stores metadata without fetching", async () => {
208
+ let downloads = 0;
209
+ const realMock = globalThis.fetch;
210
+ globalThis.fetch = (async (url, init) => {
211
+ if (String(url).includes("api.linear.app")) {
212
+ const parsed = JSON.parse(String(init?.body ?? "{}"));
213
+ const payload = typeof parsed.variables?.id === "string"
214
+ ? { data: { issue: null } }
215
+ : { data: { issues: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } };
216
+ return new Response(JSON.stringify(payload), { status: 200 });
217
+ }
218
+ downloads += 1;
219
+ return new Response("nope", { status: 500 });
220
+ });
221
+ try {
222
+ const app = await buildApp();
223
+ const res = await app.inject({
224
+ method: "POST",
225
+ url: "/api/issues",
226
+ payload: { ...BASE_LINEAR, linearAttachments: [LINK_ATT] },
227
+ });
228
+ assert.equal(res.statusCode, 200, res.body);
229
+ const rows = listSourceAttachments(res.json().id);
230
+ assert.equal(rows.length, 1);
231
+ assert.equal(rows[0].kind, "link");
232
+ assert.equal(downloads, 0, "external-link targets are never fetched");
233
+ await app.close();
234
+ }
235
+ finally {
236
+ globalThis.fetch = realMock;
237
+ }
238
+ });
239
+ function linearNodeWith(attachments) {
240
+ return {
241
+ id: "linear-uuid-1",
242
+ identifier: "NOT-1",
243
+ title: "Reloaded title",
244
+ description: "Do the thing, reloaded.",
245
+ url: "https://linear.app/not-so-fat/issue/NOT-1/x",
246
+ state: { name: "Todo" },
247
+ team: { id: "team-1" },
248
+ labels: { nodes: [] },
249
+ attachments: { nodes: attachments },
250
+ };
251
+ }
252
+ test("reload reconciles added, changed, and removed attachments atomically", async () => {
253
+ downloadBytes.set(FILE_URL, TARBALL_V1);
254
+ const app = await buildApp();
255
+ const created = (await (await app.inject({
256
+ method: "POST",
257
+ url: "/api/issues",
258
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT, LINK_ATT] },
259
+ })).json());
260
+ const beforeRows = listSourceAttachments(created.id);
261
+ const oldBlob = beforeRows.find((r) => r.kind === "file").blobPath;
262
+ assert.ok(fs.existsSync(oldBlob));
263
+ // Linear now: same file id with new bytes, link kept, second file removed
264
+ // (it is simply absent), brand-new file added.
265
+ const FILE_B_URL = "https://uploads.linear.app/abc/b.bin";
266
+ downloadBytes.set(FILE_URL, TARBALL_V2);
267
+ downloadBytes.set(FILE_B_URL, Buffer.from("brand-new-bytes"));
268
+ linearNode = linearNodeWith([
269
+ { id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
270
+ { id: "att-link-1", title: "Design doc", url: LINK_URL, subtitle: "Spec" },
271
+ { id: "att-file-3", title: "b.bin", url: FILE_B_URL },
272
+ ]);
273
+ const res = await app.inject({ method: "POST", url: `/api/issues/${created.id}/reload-source` });
274
+ assert.equal(res.statusCode, 200, res.body);
275
+ const rows = listSourceAttachments(created.id);
276
+ assert.equal(rows.length, 3);
277
+ const changed = rows.find((r) => r.linearAttachmentId === "att-file-1");
278
+ assert.equal(changed.sha256, createHash("sha256").update(TARBALL_V2).digest("hex"));
279
+ assert.deepEqual(fs.readFileSync(changed.blobPath), TARBALL_V2);
280
+ assert.ok(rows.some((r) => r.linearAttachmentId === "att-file-3"));
281
+ // Removed files disappear from the pre-execution snapshot. Blob paths are
282
+ // content-addressed, so the changed file lands on a new path with the new
283
+ // bytes and the old blob is pruned.
284
+ assert.deepEqual(fs.readFileSync(changed.blobPath), TARBALL_V2);
285
+ assert.ok(!rows.some((r) => r.linearAttachmentId === "att-file-2"));
286
+ assert.notEqual(changed.blobPath, oldBlob);
287
+ assert.ok(!fs.existsSync(oldBlob));
288
+ await app.close();
289
+ });
290
+ test("the frozen manifest survives later row changes; legacy snapshots read empty", async () => {
291
+ const { createIssue } = await import("../repository/issues.js");
292
+ const { createIssueArtifact } = await import("../repository/artifacts.js");
293
+ const { replaceSourceAttachments } = await import("../repository/source-attachments.js");
294
+ const { getTaskSnapshot, TASK_SNAPSHOT_ARTIFACT_KIND } = await import("../coordinator/commands.js");
295
+ const issue = createIssue({
296
+ title: "Frozen",
297
+ repo: "acme/app",
298
+ baseBranch: "main",
299
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
300
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
301
+ });
302
+ const frozen = [
303
+ {
304
+ linearAttachmentId: "att-old",
305
+ kind: "file",
306
+ title: "old.bin",
307
+ safeFileName: "old.bin",
308
+ blobPath: "/blobs/old.bin",
309
+ sizeBytes: 3,
310
+ sha256: "aa",
311
+ url: "https://uploads.linear.app/old",
312
+ },
313
+ ];
314
+ replaceSourceAttachments(issue.id, frozen);
315
+ createIssueArtifact({
316
+ issueId: issue.id,
317
+ kind: TASK_SNAPSHOT_ARTIFACT_KIND,
318
+ author: "system",
319
+ content: {
320
+ title: issue.title,
321
+ description: "",
322
+ acceptanceCriteria: "",
323
+ repo: issue.repo,
324
+ baseBranch: issue.baseBranch,
325
+ workflowVersion: "dev_reviewer_v1",
326
+ executionContract: null,
327
+ sourceAttachments: frozen,
328
+ },
329
+ });
330
+ // A later Linear change swaps the live rows — the frozen read is unaffected.
331
+ replaceSourceAttachments(issue.id, [
332
+ {
333
+ linearAttachmentId: "att-new",
334
+ kind: "link",
335
+ title: "New",
336
+ url: "https://docs.example.com/new",
337
+ },
338
+ ]);
339
+ const { getIssue } = await import("../repository/issues.js");
340
+ assert.deepEqual(getTaskSnapshot(getIssue(issue.id)).sourceAttachments, frozen);
341
+ // Legacy snapshots (frozen before attachments) never gain inputs mid-flight.
342
+ const legacy = createIssue({
343
+ title: "Legacy",
344
+ repo: "acme/app",
345
+ baseBranch: "main",
346
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
347
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
348
+ });
349
+ createIssueArtifact({
350
+ issueId: legacy.id,
351
+ kind: TASK_SNAPSHOT_ARTIFACT_KIND,
352
+ author: "system",
353
+ content: {
354
+ title: legacy.title,
355
+ description: "",
356
+ acceptanceCriteria: "",
357
+ repo: legacy.repo,
358
+ baseBranch: legacy.baseBranch,
359
+ workflowVersion: "dev_reviewer_v1",
360
+ executionContract: null,
361
+ },
362
+ });
363
+ replaceSourceAttachments(legacy.id, frozen);
364
+ assert.deepEqual(getTaskSnapshot(getIssue(legacy.id)).sourceAttachments, []);
365
+ });
366
+ test("reload race with admission (in-transaction 409) leaves prior blob bytes untouched", async () => {
367
+ downloadBytes.set(FILE_URL, TARBALL_V1);
368
+ const app = await buildApp();
369
+ const created = (await (await app.inject({
370
+ method: "POST",
371
+ url: "/api/issues",
372
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT] },
373
+ })).json());
374
+ const beforeRows = listSourceAttachments(created.id);
375
+ assert.equal(beforeRows.length, 1);
376
+ const beforeBlobPath = beforeRows[0].blobPath;
377
+ const beforeHash = createHash("sha256").update(fs.readFileSync(beforeBlobPath)).digest("hex");
378
+ // The refreshed ticket carries changed bytes for the same attachment id.
379
+ downloadBytes.set(FILE_URL, TARBALL_V2);
380
+ const { reloadIssueSourceFromLinear } = await import("../coordinator/source-reload.js");
381
+ const { startWorkflowInstance } = await import("../repository/workflow-events.js");
382
+ const { getSourceAttachmentsDir } = await import("../paths.js");
383
+ const result = await reloadIssueSourceFromLinear(created.id, {
384
+ fetchLinearIssue: async (externalId) => {
385
+ // Admission wins the race mid-flight: by the time the write
386
+ // transaction runs, an active workflow owns the issue, so the
387
+ // in-transaction guard must answer 409 with no partial write.
388
+ startWorkflowInstance(created.id, "dev_reviewer_v1");
389
+ return {
390
+ id: externalId,
391
+ identifier: "NOT-1",
392
+ title: "Reloaded title",
393
+ description: "Do the thing, reloaded.",
394
+ url: "https://linear.app/not-so-fat/issue/NOT-1/x",
395
+ attachments: [{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL }],
396
+ };
397
+ },
398
+ });
399
+ if (result.ok)
400
+ assert.fail("expected the admission race to answer 409");
401
+ assert.equal(result.code, 409);
402
+ // Old task text and old attachment rows preserved — and, crucially, the
403
+ // old blob path still holds the old bytes (a pre-fix store overwrote the
404
+ // deterministic path before the transaction, breaking the frozen sha256).
405
+ const { getIssue } = await import("../repository/issues.js");
406
+ assert.equal(getIssue(created.id).title, BASE_LINEAR.title);
407
+ const afterRows = listSourceAttachments(created.id);
408
+ assert.deepEqual(afterRows.map((r) => r.linearAttachmentId), ["att-file-1"]);
409
+ assert.equal(afterRows[0].blobPath, beforeBlobPath);
410
+ assert.deepEqual(fs.readFileSync(afterRows[0].blobPath), TARBALL_V1);
411
+ assert.equal(createHash("sha256").update(fs.readFileSync(afterRows[0].blobPath)).digest("hex"), beforeHash);
412
+ // The staged-but-never-committed V2 bytes were pruned, not left behind.
413
+ assert.deepEqual(fs.readdirSync(path.join(getSourceAttachmentsDir(), created.id)), [
414
+ path.basename(beforeBlobPath),
415
+ ]);
416
+ await app.close();
417
+ });
418
+ test("failed reload preserves the old task text and attachment snapshot", async () => {
419
+ downloadBytes.set(FILE_URL, TARBALL_V1);
420
+ const app = await buildApp();
421
+ const created = (await (await app.inject({
422
+ method: "POST",
423
+ url: "/api/issues",
424
+ payload: { ...BASE_LINEAR, linearAttachments: [FILE_ATT] },
425
+ })).json());
426
+ const beforeRows = listSourceAttachments(created.id);
427
+ const beforeBlobHash = createHash("sha256")
428
+ .update(fs.readFileSync(beforeRows[0].blobPath))
429
+ .digest("hex");
430
+ // The refreshed ticket adds a file whose download fails.
431
+ const BAD_URL = "https://uploads.linear.app/abc/missing.bin";
432
+ downloadBytes.set(BAD_URL, "http500");
433
+ linearNode = linearNodeWith([
434
+ { id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
435
+ { id: "att-file-9", title: "missing.bin", url: BAD_URL },
436
+ ]);
437
+ const res = await app.inject({ method: "POST", url: `/api/issues/${created.id}/reload-source` });
438
+ assert.equal(res.statusCode, 502, res.body);
439
+ const { getIssue } = await import("../repository/issues.js");
440
+ const issue = getIssue(created.id);
441
+ assert.equal(issue.title, BASE_LINEAR.title, "old task text preserved");
442
+ assert.equal(issue.description, BASE_LINEAR.description);
443
+ const afterRows = listSourceAttachments(created.id);
444
+ assert.deepEqual(afterRows.map((r) => r.linearAttachmentId), ["att-file-1"], "old attachment snapshot preserved");
445
+ assert.equal(createHash("sha256").update(fs.readFileSync(afterRows[0].blobPath)).digest("hex"), beforeBlobHash, "old blob bytes intact");
446
+ await app.close();
447
+ });
@@ -1,5 +1,6 @@
1
1
  // packages/server/src/routes/issues.ts
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
3
4
  import { CreateIssueInput, ExecutionContractError, IssueStatus, UpdateIssueInput, } from "@agent-dealer/shared";
4
5
  import { getDb } from "../db/index.js";
5
6
  import { createIssue, getIssue, listIssues, findActiveIssueByExternalId, listIssuesByExternalId, queryIssues, updateIssue, listRecentRepos } from "../repository/issues.js";
@@ -10,12 +11,18 @@ import { listWorkflowEventsForIssue, appendWorkflowEvent, listWorkflowInstancesF
10
11
  import { listHumanActionsForIssue, listOpenHumanActions } from "../repository/human-actions.js";
11
12
  import { listFindingsForIssue } from "../repository/findings.js";
12
13
  import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, parkCapWait, PARKED_EDITABLE_FIELDS } from "../coordinator/commands.js";
14
+ import { deleteDealerIssue } from "../coordinator/delete-issue.js";
13
15
  import { getAgent } from "../repository/agents.js";
14
16
  import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
15
17
  import { computeHumanWaitMs } from "../coordinator/metrics.js";
16
18
  import { enqueueIssue, enqueueIssueWithOutcome, getQueuedEntryForIssue } from "../repository/queue-entries.js";
17
19
  import { latestSessionFailureForIssue } from "../coordinator/latest-failure.js";
18
20
  import { getIssueExecutionAnalysis } from "../read-models/execution-analysis.js";
21
+ import { reloadIssueSourceFromLinear } from "../coordinator/source-reload.js";
22
+ import { discardStaged, SourceAttachmentError, stageSourceAttachments, storeStagedAttachments, } from "../coordinator/source-attachments.js";
23
+ import { listSourceAttachments, replaceSourceAttachments } from "../repository/source-attachments.js";
24
+ import { getSourceAttachmentsDir } from "../paths.js";
25
+ import { persistLinearBranchForIssue } from "../adapters/linear-inbox.js";
19
26
  import { deriveLiveProgressFromLog } from "../coordinator/session-progress.js";
20
27
  import { branchTipStatusForIssue } from "../coordinator/branch-tip-status.js";
21
28
  const TRACE_DEFAULT_MAX_CHARS = 50_000;
@@ -144,6 +151,9 @@ export async function registerIssueRoutes(app) {
144
151
  // NOT-358: usage-cap / deck-outage wait behind an availability window, if any —
145
152
  // the dashboard offers "Park for human" next to the wait notice.
146
153
  capWait: capWaitForIssue(id),
154
+ // NOT-364: durable Linear source attachments — files with safe name, size
155
+ // and checksum plus Dealer blob path; links as labeled metadata only.
156
+ sourceAttachments: listSourceAttachments(id),
147
157
  };
148
158
  });
149
159
  /** NOT-173: stable issue-level execution read model. The existing
@@ -226,18 +236,77 @@ export async function registerIssueRoutes(app) {
226
236
  });
227
237
  }
228
238
  }
239
+ // NOT-364: snapshot Linear-hosted files BEFORE any row exists. The
240
+ // From-Linear form sends the lookup-time attachment metadata; every hosted
241
+ // file streams into the staging dir here, so an HTTP failure, timeout, or
242
+ // limit breach refuses the whole create with zero issue/queue/attachment
243
+ // rows. External links pass through as metadata (never downloaded).
244
+ // Non-Linear sources ignore the field; re-imports of a live issue return
245
+ // above and never re-download (use Reload from Linear pre-execution).
246
+ const snapshotAttachments = input.source === "linear" && (input.linearAttachments?.length ?? 0) > 0;
247
+ let staged = null;
248
+ if (snapshotAttachments) {
249
+ try {
250
+ staged = await stageSourceAttachments(input.linearAttachments ?? []);
251
+ }
252
+ catch (err) {
253
+ if (err instanceof SourceAttachmentError) {
254
+ return reply.status(err.httpStatus).send({ error: err.message });
255
+ }
256
+ throw err;
257
+ }
258
+ }
229
259
  // NOT-306: an ambiguous/malformed execution contract in the ticket
230
260
  // description is a 400 with an actionable message, never a silent drop.
231
261
  let issue;
262
+ // The issue id minted inside the transaction, so a throw after the blob
263
+ // copy can remove the orphaned bytes (rows roll back; files do not).
264
+ let createdIssueId = null;
232
265
  try {
233
- issue = createIssue(input);
266
+ // The issue row and its attachment rows commit in one transaction — a
267
+ // throw below the staging point (contract error, blob-store failure)
268
+ // rolls back to zero rows, never a partially hydrated issue.
269
+ issue = getDb().transaction(() => {
270
+ const created = createIssue(input);
271
+ createdIssueId = created.id;
272
+ if (staged) {
273
+ replaceSourceAttachments(created.id, storeStagedAttachments(created.id, staged));
274
+ }
275
+ return created;
276
+ })();
234
277
  }
235
278
  catch (err) {
279
+ if (staged)
280
+ discardStaged(staged);
281
+ if (createdIssueId) {
282
+ try {
283
+ const orphanDir = path.resolve(getSourceAttachmentsDir(), createdIssueId);
284
+ if (orphanDir.startsWith(path.resolve(getSourceAttachmentsDir()) + path.sep)) {
285
+ fs.rmSync(orphanDir, { recursive: true, force: true });
286
+ }
287
+ }
288
+ catch {
289
+ // orphan blobs are retained history, never a correctness issue
290
+ }
291
+ }
236
292
  if (err instanceof ExecutionContractError) {
237
293
  return reply.status(400).send({ error: err.message });
238
294
  }
295
+ if (err instanceof SourceAttachmentError) {
296
+ return reply.status(err.httpStatus).send({ error: err.message });
297
+ }
239
298
  throw err;
240
299
  }
300
+ if (staged)
301
+ discardStaged(staged);
302
+ // NOT-362: seed the issue branch from Linear's own `branchName` (source
303
+ // `linear` only; manual issues untouched). Best-effort and awaited — the
304
+ // developer round reads the branch at admission, so it must be stored
305
+ // before the enqueue below. Never fails the import.
306
+ if (input.source === "linear" && input.externalId) {
307
+ await persistLinearBranchForIssue(issue.id).catch(() => null);
308
+ issue = getIssue(issue.id) ?? issue;
309
+ }
241
310
  appendWorkflowEvent({ issueId: issue.id, type: "issue.created", actorType: "human", stage: issue.status });
242
311
  // NOT-118: create enqueues, it never starts. Server-side so the UI, CLI and agents all
243
312
  // behave the same — callers hold no workflow logic. `enqueue: false` creates a draft.
@@ -381,6 +450,26 @@ export async function registerIssueRoutes(app) {
381
450
  await refreshQueueWaitReasonForIssue(id);
382
451
  return updated;
383
452
  });
453
+ /**
454
+ * NOT-363: reload a Linear-sourced issue's task text from its linked ticket.
455
+ * Only while `ready` with no active workflow or running worker — a race
456
+ * with admission fails closed with 409. Replaces title + description
457
+ * atomically, recompiles the execution contract, and swaps the acceptance
458
+ * criteria for the freshly derived ones; repo, agents, policy fields,
459
+ * auto-merge, queue position, and coordinator-owned fields are preserved.
460
+ * Queue order is untouched (the write never touches queue_entries) — the
461
+ * position is preserved by construction.
462
+ */
463
+ app.post("/api/issues/:id/reload-source", async (req, reply) => {
464
+ const { id } = req.params;
465
+ const result = await reloadIssueSourceFromLinear(id);
466
+ if (!result.ok)
467
+ return reply.status(result.code).send({ error: result.error });
468
+ // A queued issue keeps its position; re-derive the visible wait reason now
469
+ // that admission may see a different (e.g. healthy) description.
470
+ await refreshQueueWaitReasonForIssue(id);
471
+ return result.issue;
472
+ });
384
473
  /**
385
474
  * NOT-118: Start = move to front of the admission queue, then admit if a slot is free.
386
475
  * No bypass — a busy or not-yet-eligible issue answers `{ state: "queued", ... }` and is
@@ -462,6 +551,25 @@ export async function registerIssueRoutes(app) {
462
551
  return reply.status(result.code).send({ error: result.error });
463
552
  return { issueStatus: result.issueStatus, alreadyClosed: result.alreadyClosed };
464
553
  });
554
+ /**
555
+ * NOT-365: permanently delete a Dealer-local issue and its Dealer-owned data.
556
+ * Only `ready`/`done`/`closed` with no active workflow, running session,
557
+ * leased/pending work, active authority attempt, or on-disk worktree — anything
558
+ * else answers 409 naming the blocker and changes nothing. A queued `ready`
559
+ * issue loses its live queue row in the same transaction. The linked Linear
560
+ * ticket is never touched: this path makes no Linear or GitHub call.
561
+ *
562
+ * File cleanup runs after the commit and stays inside AGENT_DEALER_HOME;
563
+ * anything it could not (or would not) remove is reported as `residualPaths`
564
+ * on the successful deletion rather than resurrecting the row.
565
+ */
566
+ app.delete("/api/issues/:id", async (req, reply) => {
567
+ const { id } = req.params;
568
+ const result = deleteDealerIssue(id);
569
+ if (!result.ok)
570
+ return reply.status(result.code).send({ error: result.error });
571
+ return { deleted: true, issueId: id, removedQueueEntry: result.removedQueueEntry, residualPaths: result.residualPaths };
572
+ });
465
573
  app.post("/api/issues/:id/guidance", async (req, reply) => {
466
574
  const { id } = req.params;
467
575
  const issue = getIssue(id);
@@ -205,3 +205,38 @@ test("GET /api/runtime-capacity serves a configured Codex account without spawni
205
205
  assert.equal(codex[0].unavailableReason, "missing");
206
206
  await app.close();
207
207
  });
208
+ test("GET /api/runtime-capacity explains a failed Claude refresh in operator text (NOT-366)", async () => {
209
+ clearAllCapacitySnapshots();
210
+ createAgent({
211
+ name: "route-claude-unavailable",
212
+ runtime: "claude_code",
213
+ deckId: "55555555-5555-4555-8555-555555555555",
214
+ });
215
+ const { recordClaudeAcquisitionFailure } = await import("../capacity/claude-local-cache.js");
216
+ const now = Date.now();
217
+ recordClaudeAcquisitionFailure({ failureKind: "no_windows", authSource: null }, now - 60_000, {
218
+ count: 1,
219
+ firstFailureMs: now - 60_000,
220
+ });
221
+ recordClaudeAcquisitionFailure({ failureKind: "no_windows", authSource: null }, now, {
222
+ count: 2,
223
+ firstFailureMs: now - 60_000,
224
+ });
225
+ const app = await buildApp();
226
+ const res = await app.inject({ method: "GET", url: "/api/runtime-capacity" });
227
+ assert.equal(res.statusCode, 200);
228
+ const body = RuntimeCapacityResponse.parse(res.json());
229
+ const claude = body.runtimes.find((r) => r.runtime === "claude_code");
230
+ assert.equal(claude.windows.length, 2);
231
+ for (const w of claude.windows) {
232
+ assert.equal(w.remainingPercent, null);
233
+ assert.ok(w.unavailableReason);
234
+ assert.equal(w.unavailableDetail?.message, "Claude /usage reported no 5H/1W plan limits");
235
+ assert.equal(w.unavailableDetail?.consecutiveFailures, 2);
236
+ assert.equal(w.unavailableDetail?.firstFailureAt, new Date(now - 60_000).toISOString());
237
+ }
238
+ const raw = JSON.stringify(res.json());
239
+ assert.ok(!raw.includes("no_windows"), "internal failure kind never leaves the server");
240
+ assert.ok(!raw.includes("evidence"), "no evidence pointers leak to the browser");
241
+ await app.close();
242
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.13",
3
+ "version": "1.2.15",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.2.13",
25
+ "@agent-dealer/shared": "1.2.15",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",