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