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.
- 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/capacity/claude-events.js +25 -5
- package/bundle/server/dist/capacity/claude-local-cache.js +159 -13
- package/bundle/server/dist/capacity/claude-local-cache.test.js +191 -3
- 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/delete-issue.js +473 -0
- 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 +496 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +396 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/index.js +4 -0
- package/bundle/server/dist/db/schema.sql +32 -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/runtime-capacity.js +19 -3
- 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-delete.test.js +666 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +447 -0
- package/bundle/server/dist/routes/issues.js +109 -1
- package/bundle/server/dist/routes/runtime-capacity.test.js +35 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DVyqblie.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/runtime-capacity.d.ts +147 -0
- package/bundle/shared/dist/runtime-capacity.js +15 -0
- 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,396 @@
|
|
|
1
|
+
// NOT-364: Linear source-attachment staging, safe names, materialization, prompts.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { LINEAR_INPUT_EXCLUDE_LINE, MAX_SOURCE_ATTACHMENT_FILE_BYTES, MAX_SOURCE_ATTACHMENT_TOTAL_BYTES, materializeSourceAttachments, safeAttachmentFileName, sourceAttachmentsDeveloperSection, sourceAttachmentsReviewerSection, stageSourceAttachments, storeStagedAttachments, discardStaged, SourceAttachmentError, } from "./source-attachments.js";
|
|
10
|
+
const HOSTED = "https://uploads.linear.app/abc/repro.tar.gz";
|
|
11
|
+
const LINK = "https://docs.example.com/spec";
|
|
12
|
+
function downloadDouble(bytes, opts = {}) {
|
|
13
|
+
return async (url) => {
|
|
14
|
+
opts.seen?.push(url);
|
|
15
|
+
const headers = {
|
|
16
|
+
get: (name) => {
|
|
17
|
+
if (name === "content-type")
|
|
18
|
+
return opts.contentType ?? "application/gzip";
|
|
19
|
+
if (name === "content-length")
|
|
20
|
+
return String(bytes.byteLength);
|
|
21
|
+
return null;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
if (opts.streamed === false) {
|
|
25
|
+
const exact = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
26
|
+
return { ok: true, status: 200, headers, arrayBuffer: async () => exact };
|
|
27
|
+
}
|
|
28
|
+
const chunks = [bytes.subarray(0, 5), bytes.subarray(5)];
|
|
29
|
+
let i = 0;
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
status: 200,
|
|
33
|
+
headers,
|
|
34
|
+
body: {
|
|
35
|
+
getReader: () => ({
|
|
36
|
+
read: async () => {
|
|
37
|
+
if (i >= chunks.length)
|
|
38
|
+
return { done: true, value: undefined };
|
|
39
|
+
const value = chunks[i++];
|
|
40
|
+
return { done: false, value };
|
|
41
|
+
},
|
|
42
|
+
cancel: async () => { },
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const FILE_ATTACHMENT = { id: "att-file-1", title: "repro.tar.gz", url: HOSTED };
|
|
49
|
+
const LINK_ATTACHMENT = {
|
|
50
|
+
id: "att-link-1",
|
|
51
|
+
title: "Design doc",
|
|
52
|
+
url: LINK,
|
|
53
|
+
subtitle: "Spec",
|
|
54
|
+
source: "google-docs",
|
|
55
|
+
};
|
|
56
|
+
test("staging snapshots exact bytes with sha256 and passes links through unfetched", async () => {
|
|
57
|
+
const bytes = Buffer.from("fake-tar-bytes-0123456789");
|
|
58
|
+
const seen = [];
|
|
59
|
+
const staged = await stageSourceAttachments([FILE_ATTACHMENT, LINK_ATTACHMENT], {
|
|
60
|
+
fetchImpl: downloadDouble(bytes, { seen }),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
assert.equal(staged.files.length, 1);
|
|
64
|
+
const [file] = staged.files;
|
|
65
|
+
assert.equal(file.safeFileName, "repro.tar.gz");
|
|
66
|
+
assert.equal(file.sizeBytes, bytes.byteLength);
|
|
67
|
+
assert.equal(file.sha256, createHash("sha256").update(bytes).digest("hex"));
|
|
68
|
+
assert.equal(file.contentType, "application/gzip");
|
|
69
|
+
assert.deepEqual(fs.readFileSync(file.tempPath), bytes);
|
|
70
|
+
// The external link is metadata only — its URL is never fetched.
|
|
71
|
+
assert.equal(staged.links.length, 1);
|
|
72
|
+
assert.equal(staged.links[0].kind, "link");
|
|
73
|
+
assert.equal(staged.links[0].url, LINK);
|
|
74
|
+
assert.deepEqual(seen, [HOSTED]);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
discardStaged(staged);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test("staging refuses an HTTP failure without a partial bundle", async () => {
|
|
81
|
+
const fetchImpl = async () => ({
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 500,
|
|
84
|
+
headers: { get: () => null },
|
|
85
|
+
});
|
|
86
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], { fetchImpl }), (err) => {
|
|
87
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
88
|
+
assert.equal(err.httpStatus, 502);
|
|
89
|
+
assert.match(err.message, /HTTP 500/);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
test("staging times out a hanging download", async () => {
|
|
94
|
+
const fetchImpl = (_url, init) => new Promise((_resolve, reject) => {
|
|
95
|
+
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
|
|
96
|
+
});
|
|
97
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], { fetchImpl, timeoutMs: 20 }), (err) => {
|
|
98
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
99
|
+
assert.equal(err.httpStatus, 502);
|
|
100
|
+
assert.match(err.message, /timed out/);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
test("staging enforces the per-file and total snapshot limits", async () => {
|
|
105
|
+
const bytes = Buffer.from("0123456789abcdef");
|
|
106
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], {
|
|
107
|
+
fetchImpl: downloadDouble(bytes),
|
|
108
|
+
maxFileBytes: 4,
|
|
109
|
+
}), (err) => {
|
|
110
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
111
|
+
assert.equal(err.httpStatus, 400);
|
|
112
|
+
assert.match(err.message, /too large to snapshot/);
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
const second = { id: "att-file-2", title: "b.bin", url: `${HOSTED}-b` };
|
|
116
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT, second], {
|
|
117
|
+
fetchImpl: downloadDouble(bytes),
|
|
118
|
+
maxTotalBytes: bytes.byteLength + 1,
|
|
119
|
+
}), (err) => {
|
|
120
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
121
|
+
assert.equal(err.httpStatus, 400);
|
|
122
|
+
assert.match(err.message, /total snapshot limit/);
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
test("staging fails fast on a declared over-limit body", async () => {
|
|
127
|
+
let read = false;
|
|
128
|
+
const fetchImpl = async () => ({
|
|
129
|
+
ok: true,
|
|
130
|
+
status: 200,
|
|
131
|
+
headers: {
|
|
132
|
+
get: (name) => (name === "content-length" ? String(MAX_SOURCE_ATTACHMENT_FILE_BYTES + 1) : "application/gzip"),
|
|
133
|
+
},
|
|
134
|
+
arrayBuffer: async () => {
|
|
135
|
+
read = true;
|
|
136
|
+
return new ArrayBuffer(0);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], { fetchImpl }), (err) => {
|
|
140
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
141
|
+
assert.equal(err.httpStatus, 400);
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
assert.equal(read, false, "over-limit body is refused before reading");
|
|
145
|
+
});
|
|
146
|
+
test("default snapshot limits are 100 MiB per file and 250 MiB total", () => {
|
|
147
|
+
assert.equal(MAX_SOURCE_ATTACHMENT_FILE_BYTES, 100 * 1024 * 1024);
|
|
148
|
+
assert.equal(MAX_SOURCE_ATTACHMENT_TOTAL_BYTES, 250 * 1024 * 1024);
|
|
149
|
+
});
|
|
150
|
+
test("safeAttachmentFileName neutralizes traversal and de-duplicates", () => {
|
|
151
|
+
const taken = new Set();
|
|
152
|
+
assert.equal(safeAttachmentFileName("../../etc/passwd", "att-1", taken), "passwd");
|
|
153
|
+
assert.equal(safeAttachmentFileName(".hidden", "att-2", taken), "_hidden");
|
|
154
|
+
assert.equal(safeAttachmentFileName("..", "abcdef12", taken), "attachment-abcdef12");
|
|
155
|
+
assert.equal(safeAttachmentFileName("repro.tar.gz", "x1", taken), "repro.tar.gz");
|
|
156
|
+
assert.equal(safeAttachmentFileName("repro.tar.gz", "x2", taken), "repro.tar-2.gz");
|
|
157
|
+
for (const name of taken) {
|
|
158
|
+
assert.ok(!name.includes("/") && !name.includes("\\"), name);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
function initWorktree() {
|
|
162
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-inputs-worktree-"));
|
|
163
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
164
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
165
|
+
execFileSync("git", ["config", "user.name", "test"], { cwd: dir });
|
|
166
|
+
execFileSync("git", ["commit", "-q", "--allow-empty", "-m", "init"], { cwd: dir });
|
|
167
|
+
return dir;
|
|
168
|
+
}
|
|
169
|
+
test("materialization writes verified bytes to an ignored, contained path — twice", async () => {
|
|
170
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-inputs-blobs-"));
|
|
171
|
+
const bytes = Buffer.concat([Buffer.from("tar-bytes-"), randomBytes(64)]);
|
|
172
|
+
const staged = await stageSourceAttachments([FILE_ATTACHMENT, LINK_ATTACHMENT], {
|
|
173
|
+
fetchImpl: downloadDouble(bytes, { streamed: false }),
|
|
174
|
+
});
|
|
175
|
+
const worktree = initWorktree();
|
|
176
|
+
try {
|
|
177
|
+
const records = storeStagedAttachments("issue-1", staged);
|
|
178
|
+
const manifest = records.filter((r) => r.kind === "file");
|
|
179
|
+
assert.equal(manifest[0].blobPath && fs.existsSync(manifest[0].blobPath), true);
|
|
180
|
+
const first = materializeSourceAttachments(worktree, records);
|
|
181
|
+
assert.equal(first.length, 1);
|
|
182
|
+
assert.equal(first[0].relativePath, ".agent-dealer-inputs/linear/repro.tar.gz");
|
|
183
|
+
const dest = path.join(worktree, first[0].relativePath);
|
|
184
|
+
assert.deepEqual(fs.readFileSync(dest), bytes);
|
|
185
|
+
assert.equal(first[0].sha256, createHash("sha256").update(bytes).digest("hex"));
|
|
186
|
+
// Repair/retry stability: re-materializing yields identical bytes.
|
|
187
|
+
const second = materializeSourceAttachments(worktree, records);
|
|
188
|
+
assert.deepEqual(second, first);
|
|
189
|
+
assert.deepEqual(fs.readFileSync(dest), bytes);
|
|
190
|
+
// Ignored: the inputs root is in git info/exclude and status stays clean.
|
|
191
|
+
const excludeOut = execFileSync("git", ["-C", worktree, "rev-parse", "--git-path", "info/exclude"], {
|
|
192
|
+
encoding: "utf8",
|
|
193
|
+
});
|
|
194
|
+
const exclude = fs.readFileSync(path.resolve(worktree, excludeOut.trim()), "utf8");
|
|
195
|
+
assert.ok(exclude.split("\n").includes(LINEAR_INPUT_EXCLUDE_LINE));
|
|
196
|
+
assert.equal(execFileSync("git", ["-C", worktree, "status", "--porcelain"], { encoding: "utf8" }), "");
|
|
197
|
+
// Never auto-extracted: exactly one file landed, no directory expansion.
|
|
198
|
+
const landed = fs.readdirSync(path.join(worktree, ".agent-dealer-inputs", "linear"));
|
|
199
|
+
assert.deepEqual(landed, ["repro.tar.gz"]);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
discardStaged(staged);
|
|
203
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test("materialization fails closed on missing or corrupt blobs", () => {
|
|
207
|
+
const worktree = initWorktree();
|
|
208
|
+
try {
|
|
209
|
+
assert.throws(() => materializeSourceAttachments(worktree, [
|
|
210
|
+
{
|
|
211
|
+
linearAttachmentId: "a",
|
|
212
|
+
kind: "file",
|
|
213
|
+
title: "gone.bin",
|
|
214
|
+
safeFileName: "gone.bin",
|
|
215
|
+
blobPath: path.join(worktree, "no-such-blob"),
|
|
216
|
+
sha256: "0".repeat(64),
|
|
217
|
+
url: HOSTED,
|
|
218
|
+
},
|
|
219
|
+
]), /missing/);
|
|
220
|
+
const blob = path.join(os.tmpdir(), `dealer-corrupt-blob-${Date.now()}.bin`);
|
|
221
|
+
fs.writeFileSync(blob, Buffer.from("tampered"));
|
|
222
|
+
assert.throws(() => materializeSourceAttachments(worktree, [
|
|
223
|
+
{
|
|
224
|
+
linearAttachmentId: "a",
|
|
225
|
+
kind: "file",
|
|
226
|
+
title: "evil.bin",
|
|
227
|
+
safeFileName: "evil.bin",
|
|
228
|
+
blobPath: blob,
|
|
229
|
+
sha256: "1".repeat(64),
|
|
230
|
+
url: HOSTED,
|
|
231
|
+
},
|
|
232
|
+
]), /checksum/);
|
|
233
|
+
fs.rmSync(blob, { force: true });
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
test("developer prompt section lists local paths and links with the trust boundary", () => {
|
|
240
|
+
const lines = sourceAttachmentsDeveloperSection([
|
|
241
|
+
{
|
|
242
|
+
linearAttachmentId: "att-file-1",
|
|
243
|
+
kind: "file",
|
|
244
|
+
title: "repro.tar.gz",
|
|
245
|
+
safeFileName: "repro.tar.gz",
|
|
246
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
247
|
+
sizeBytes: 18,
|
|
248
|
+
sha256: "ab".repeat(32),
|
|
249
|
+
url: HOSTED,
|
|
250
|
+
},
|
|
251
|
+
{ linearAttachmentId: "att-link-1", kind: "link", title: "Design doc", url: LINK, subtitle: "Spec" },
|
|
252
|
+
]);
|
|
253
|
+
const text = lines.join("\n");
|
|
254
|
+
assert.ok(text.includes("## Source attachments (untrusted ticket inputs — inspect only as needed)"));
|
|
255
|
+
assert.ok(text.includes("`.agent-dealer-inputs/linear/repro.tar.gz`"));
|
|
256
|
+
assert.ok(text.includes(`link: "Design doc" — ${LINK}`));
|
|
257
|
+
assert.ok(text.includes("never commit them"));
|
|
258
|
+
assert.ok(text.includes("never extract archives outside a fresh contained directory"));
|
|
259
|
+
assert.ok(text.includes("never fetch external-link targets automatically"));
|
|
260
|
+
assert.equal(sourceAttachmentsDeveloperSection([]).length, 0);
|
|
261
|
+
assert.equal(sourceAttachmentsDeveloperSection(undefined).length, 0);
|
|
262
|
+
});
|
|
263
|
+
// NOT-367: Linear's upload host authenticates like GraphQL — the snapshot
|
|
264
|
+
// download must carry Authorization: <LINEAR_API_KEY>, and a 401 must read
|
|
265
|
+
// as an auth failure, never as an expired URL.
|
|
266
|
+
async function withLinearApiKey(value, fn) {
|
|
267
|
+
const prev = process.env.LINEAR_API_KEY;
|
|
268
|
+
if (value === undefined)
|
|
269
|
+
delete process.env.LINEAR_API_KEY;
|
|
270
|
+
else
|
|
271
|
+
process.env.LINEAR_API_KEY = value;
|
|
272
|
+
try {
|
|
273
|
+
return await fn();
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
if (prev === undefined)
|
|
277
|
+
delete process.env.LINEAR_API_KEY;
|
|
278
|
+
else
|
|
279
|
+
process.env.LINEAR_API_KEY = prev;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
test("staging sends the Linear API key as Authorization on hosted downloads", async () => {
|
|
283
|
+
await withLinearApiKey("lin_test_key", async () => {
|
|
284
|
+
const bytes = Buffer.from("auth-bytes");
|
|
285
|
+
const seenInit = [];
|
|
286
|
+
const staged = await stageSourceAttachments([FILE_ATTACHMENT], {
|
|
287
|
+
fetchImpl: async (url, init) => {
|
|
288
|
+
seenInit.push(init);
|
|
289
|
+
return downloadDouble(bytes)(url);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
assert.equal(staged.files.length, 1);
|
|
294
|
+
assert.equal(seenInit.length, 1);
|
|
295
|
+
assert.equal(seenInit[0]?.headers?.Authorization, "lin_test_key");
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
discardStaged(staged);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
test("staging invents no credentials when LINEAR_API_KEY is missing", async () => {
|
|
303
|
+
await withLinearApiKey(undefined, async () => {
|
|
304
|
+
const bytes = Buffer.from("no-key-bytes");
|
|
305
|
+
let captured;
|
|
306
|
+
const staged = await stageSourceAttachments([FILE_ATTACHMENT], {
|
|
307
|
+
fetchImpl: async (url, init) => {
|
|
308
|
+
captured = init;
|
|
309
|
+
return downloadDouble(bytes)(url);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
try {
|
|
313
|
+
assert.equal(staged.files.length, 1, "the attempt still goes out unauthenticated");
|
|
314
|
+
assert.ok(!captured?.headers?.Authorization, "no Authorization header is invented");
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
discardStaged(staged);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
test("staging reports HTTP 401 as rejected credentials when a key is configured", async () => {
|
|
322
|
+
await withLinearApiKey("lin_test_key", async () => {
|
|
323
|
+
const fetchImpl = async () => ({
|
|
324
|
+
ok: false,
|
|
325
|
+
status: 401,
|
|
326
|
+
headers: { get: () => null },
|
|
327
|
+
});
|
|
328
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], { fetchImpl }), (err) => {
|
|
329
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
330
|
+
assert.equal(err.httpStatus, 502);
|
|
331
|
+
assert.match(err.message, /HTTP 401/);
|
|
332
|
+
assert.match(err.message, /Unauthorized/);
|
|
333
|
+
assert.match(err.message, /rejected the download credentials/);
|
|
334
|
+
assert.match(err.message, /LINEAR_API_KEY/);
|
|
335
|
+
assert.ok(!/expired/i.test(err.message), "a 401 must not blame URL expiry");
|
|
336
|
+
assert.match(err.message, /Nothing was queued/);
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
test("staging reports HTTP 401 as missing credentials when no key is configured", async () => {
|
|
342
|
+
await withLinearApiKey(undefined, async () => {
|
|
343
|
+
const fetchImpl = async () => ({
|
|
344
|
+
ok: false,
|
|
345
|
+
status: 401,
|
|
346
|
+
headers: { get: () => null },
|
|
347
|
+
});
|
|
348
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT], { fetchImpl }), (err) => {
|
|
349
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
350
|
+
assert.equal(err.httpStatus, 502);
|
|
351
|
+
assert.match(err.message, /HTTP 401/);
|
|
352
|
+
assert.match(err.message, /Unauthorized/);
|
|
353
|
+
assert.match(err.message, /LINEAR_API_KEY is not configured/);
|
|
354
|
+
assert.ok(!/expired/i.test(err.message), "a 401 must not blame URL expiry");
|
|
355
|
+
return true;
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
test("a 401 on the second file fails the whole staging with nothing returned", async () => {
|
|
360
|
+
await withLinearApiKey("lin_test_key", async () => {
|
|
361
|
+
const second = { id: "att-file-2", title: "b.bin", url: `${HOSTED}-b` };
|
|
362
|
+
const fetchImpl = async (url) => {
|
|
363
|
+
if (url === HOSTED)
|
|
364
|
+
return downloadDouble(Buffer.from("first-bytes"))(url);
|
|
365
|
+
return { ok: false, status: 401, headers: { get: () => null } };
|
|
366
|
+
};
|
|
367
|
+
await assert.rejects(() => stageSourceAttachments([FILE_ATTACHMENT, second], { fetchImpl }), (err) => {
|
|
368
|
+
assert.ok(err instanceof SourceAttachmentError);
|
|
369
|
+
assert.match(err.message, /Unauthorized/);
|
|
370
|
+
assert.match(err.message, /Nothing was queued/);
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
test("reviewer prompt section carries manifest metadata, never paths or bytes", () => {
|
|
376
|
+
const lines = sourceAttachmentsReviewerSection([
|
|
377
|
+
{
|
|
378
|
+
linearAttachmentId: "att-file-1",
|
|
379
|
+
kind: "file",
|
|
380
|
+
title: "repro.tar.gz",
|
|
381
|
+
safeFileName: "repro.tar.gz",
|
|
382
|
+
blobPath: "/blobs/repro.tar.gz",
|
|
383
|
+
sizeBytes: 18,
|
|
384
|
+
sha256: "ab".repeat(32),
|
|
385
|
+
contentType: "application/gzip",
|
|
386
|
+
url: HOSTED,
|
|
387
|
+
},
|
|
388
|
+
{ linearAttachmentId: "att-link-1", kind: "link", title: "Design doc", url: LINK },
|
|
389
|
+
]);
|
|
390
|
+
const text = lines.join("\n");
|
|
391
|
+
assert.ok(text.includes("metadata only, no file contents"));
|
|
392
|
+
assert.ok(text.includes('"repro.tar.gz"'));
|
|
393
|
+
assert.ok(text.includes("18 bytes"));
|
|
394
|
+
assert.ok(!text.includes(".agent-dealer-inputs"), "no worktree path is promised to reviewers");
|
|
395
|
+
assert.ok(!text.includes("/blobs/"), "server blob paths stay server-side");
|
|
396
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// packages/server/src/coordinator/source-reload.ts
|
|
2
|
+
// NOT-363: reload a Linear-sourced issue's task text from its linked ticket.
|
|
3
|
+
//
|
|
4
|
+
// Before the workflow snapshot freezes, an operator can pull the latest title
|
|
5
|
+
// and description from Linear. The shared execution-contract compiler rebuilds
|
|
6
|
+
// the contract from the refreshed description, and the derived acceptance
|
|
7
|
+
// criteria replace whatever the issue carried — old local criteria never
|
|
8
|
+
// survive a source reload. Repository, base branch, agent assignments, policy
|
|
9
|
+
// limits, auto-merge, queue position, and every coordinator-owned field are
|
|
10
|
+
// untouched; nothing is written back to Linear.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { compileExecutionContract, ExecutionContractError, } from "@agent-dealer/shared";
|
|
13
|
+
import { getDb } from "../db/index.js";
|
|
14
|
+
import { getIssue } from "../repository/issues.js";
|
|
15
|
+
import { appendWorkflowEvent, getActiveWorkflowInstance } from "../repository/workflow-events.js";
|
|
16
|
+
import { getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
|
|
17
|
+
import { getLinearIssue } from "../adapters/linear-inbox.js";
|
|
18
|
+
import { discardStaged, pruneDisusedBlobs, pruneUncommittedBlobs, SourceAttachmentError, stageSourceAttachments, storeStagedAttachments, } from "./source-attachments.js";
|
|
19
|
+
import { listSourceAttachments, replaceSourceAttachments, } from "../repository/source-attachments.js";
|
|
20
|
+
/** Short digest of task text for the timeline payload — never the full text. */
|
|
21
|
+
export function digestTaskText(text) {
|
|
22
|
+
return createHash("sha256").update(text ?? "").digest("hex").slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* NOT-363: the issue must still be pre-execution — `ready` with no active
|
|
26
|
+
* workflow and no running worker. Anything else answers 409 and changes
|
|
27
|
+
* nothing, so a reload that races admission fails closed instead of landing
|
|
28
|
+
* on a live snapshot.
|
|
29
|
+
*/
|
|
30
|
+
function reloadConflict(issue) {
|
|
31
|
+
if (issue.status !== "ready") {
|
|
32
|
+
return `Cannot reload the source of an issue that is ${issue.status}`;
|
|
33
|
+
}
|
|
34
|
+
if (getActiveWorkerSessionForIssue(issue.id)) {
|
|
35
|
+
return "Cannot reload the source of an issue with an actively running session";
|
|
36
|
+
}
|
|
37
|
+
if (getActiveWorkflowInstance(issue.id)) {
|
|
38
|
+
return "Cannot reload the source of an issue with an active workflow";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One explicit server operation: fetch the linked Linear ticket through the
|
|
44
|
+
* direct GraphQL adapter, replace title + description atomically, recompile
|
|
45
|
+
* the execution contract, and replace the acceptance criteria with the
|
|
46
|
+
* criteria derived from the refreshed description.
|
|
47
|
+
*
|
|
48
|
+
* `fetchLinearIssue` is injectable for tests; production passes the shared
|
|
49
|
+
* `getLinearIssue` adapter. The Linear fetch happens outside the write
|
|
50
|
+
* transaction, and the pre-execution guard runs twice — once for the fast
|
|
51
|
+
* path, once inside the transaction — so a race with admission answers 409
|
|
52
|
+
* with no partial write and no timeline event.
|
|
53
|
+
*/
|
|
54
|
+
export async function reloadIssueSourceFromLinear(issueId, opts) {
|
|
55
|
+
const issue = getIssue(issueId);
|
|
56
|
+
if (!issue)
|
|
57
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
58
|
+
if (issue.source !== "linear") {
|
|
59
|
+
return { ok: false, code: 400, error: "Only Linear-sourced issues can be reloaded from Linear" };
|
|
60
|
+
}
|
|
61
|
+
if (!issue.externalId) {
|
|
62
|
+
return { ok: false, code: 400, error: "Issue has no linked Linear ticket to reload from" };
|
|
63
|
+
}
|
|
64
|
+
const fastPath = reloadConflict(issue);
|
|
65
|
+
if (fastPath)
|
|
66
|
+
return { ok: false, code: 409, error: fastPath };
|
|
67
|
+
const fetchLinearIssue = opts?.fetchLinearIssue ?? getLinearIssue;
|
|
68
|
+
let candidate;
|
|
69
|
+
try {
|
|
70
|
+
candidate = await fetchLinearIssue(issue.externalId);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { ok: false, code: 502, error: `Linear reload failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
74
|
+
}
|
|
75
|
+
if (!candidate) {
|
|
76
|
+
return { ok: false, code: 404, error: "Linear issue not found — it may have been deleted" };
|
|
77
|
+
}
|
|
78
|
+
// NOT-363: the same title convention as import (`<identifier>: <title>`);
|
|
79
|
+
// the description mirrors import (trimmed, empty falls back to null) so a
|
|
80
|
+
// reload and a fresh import of the same ticket agree field-for-field.
|
|
81
|
+
const nextTitle = `${candidate.identifier}: ${candidate.title}`;
|
|
82
|
+
const nextDescription = candidate.description?.trim() ? candidate.description : null;
|
|
83
|
+
// A source refresh recompiles through the shared compiler — an
|
|
84
|
+
// ambiguous/malformed refreshed description is a 400 with an actionable
|
|
85
|
+
// message, never a silent half-parse and never a partial write.
|
|
86
|
+
let nextAcceptanceCriteria;
|
|
87
|
+
try {
|
|
88
|
+
nextAcceptanceCriteria = compileExecutionContract(nextDescription).acceptanceCriteria;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err instanceof ExecutionContractError) {
|
|
92
|
+
return { ok: false, code: 400, error: err.message };
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const prevTitleDigest = digestTaskText(issue.title);
|
|
97
|
+
const prevDescriptionDigest = digestTaskText(issue.description);
|
|
98
|
+
// NOT-364: staged all-or-nothing attachment reconciliation, reusing the same
|
|
99
|
+
// operation as import. Every hosted file downloads (and the refreshed
|
|
100
|
+
// contract compiles) BEFORE the write transaction; staged bytes land on
|
|
101
|
+
// content-addressed paths the live rows never reference, and the rows then
|
|
102
|
+
// swap inside that transaction, so a failed reload preserves the old task
|
|
103
|
+
// text AND the old attachment snapshot (old blob bytes stay verifiable).
|
|
104
|
+
// Removed Linear files disappear from the pre-execution snapshot; their
|
|
105
|
+
// blobs are pruned after a successful commit.
|
|
106
|
+
const prevAttachments = listSourceAttachments(issueId);
|
|
107
|
+
let staged = null;
|
|
108
|
+
try {
|
|
109
|
+
staged = await stageSourceAttachments(candidate.attachments);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof SourceAttachmentError) {
|
|
113
|
+
return { ok: false, code: err.httpStatus, error: err.message };
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
let nextAttachmentRecords;
|
|
118
|
+
try {
|
|
119
|
+
nextAttachmentRecords = storeStagedAttachments(issueId, staged);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
discardStaged(staged);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const updated = getDb().transaction(() => {
|
|
126
|
+
const fresh = getIssue(issueId);
|
|
127
|
+
if (!fresh)
|
|
128
|
+
throw Object.assign(new Error("Not found"), { code: 404 });
|
|
129
|
+
const conflict = reloadConflict(fresh);
|
|
130
|
+
if (conflict)
|
|
131
|
+
throw Object.assign(new Error(conflict), { code: 409 });
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
getDb()
|
|
134
|
+
.prepare(`UPDATE issues SET title = @title, description = @description,
|
|
135
|
+
acceptance_criteria = @acceptance_criteria, updated_at = @updated_at WHERE id = @id`)
|
|
136
|
+
.run({
|
|
137
|
+
id: issueId,
|
|
138
|
+
title: nextTitle,
|
|
139
|
+
description: nextDescription,
|
|
140
|
+
acceptance_criteria: nextAcceptanceCriteria,
|
|
141
|
+
updated_at: now,
|
|
142
|
+
});
|
|
143
|
+
replaceSourceAttachments(issueId, nextAttachmentRecords);
|
|
144
|
+
appendWorkflowEvent({
|
|
145
|
+
issueId,
|
|
146
|
+
type: "issue.source_reloaded",
|
|
147
|
+
actorType: "human",
|
|
148
|
+
stage: fresh.status,
|
|
149
|
+
payload: {
|
|
150
|
+
source: "linear",
|
|
151
|
+
externalId: fresh.externalId,
|
|
152
|
+
externalLabel: fresh.externalLabel,
|
|
153
|
+
prevTitleDigest,
|
|
154
|
+
newTitleDigest: digestTaskText(nextTitle),
|
|
155
|
+
prevDescriptionDigest,
|
|
156
|
+
newDescriptionDigest: digestTaskText(nextDescription),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const next = getIssue(issueId);
|
|
160
|
+
if (!next)
|
|
161
|
+
throw Object.assign(new Error("Not found"), { code: 404 });
|
|
162
|
+
return next;
|
|
163
|
+
})();
|
|
164
|
+
pruneDisusedBlobs(prevAttachments, nextAttachmentRecords);
|
|
165
|
+
return { ok: true, issue: updated };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// The staged bytes landed on content-addressed paths the old rows never
|
|
169
|
+
// reference, so the prior snapshot is intact — drop the orphaned new
|
|
170
|
+
// blobs before answering.
|
|
171
|
+
pruneUncommittedBlobs(prevAttachments, nextAttachmentRecords);
|
|
172
|
+
const code = err.code;
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
+
if (code === 404 || code === 409)
|
|
175
|
+
return { ok: false, code, error: message };
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -412,6 +412,10 @@ export function migrate() {
|
|
|
412
412
|
if (!capacityCols.some((c) => c.name === "critical_role")) {
|
|
413
413
|
db.exec("ALTER TABLE runtime_capacity_snapshots ADD COLUMN critical_role TEXT");
|
|
414
414
|
}
|
|
415
|
+
// NOT-366: acquisition-failure streak on a table that predates it.
|
|
416
|
+
if (!capacityCols.some((c) => c.name === "unavailable_detail")) {
|
|
417
|
+
db.exec("ALTER TABLE runtime_capacity_snapshots ADD COLUMN unavailable_detail TEXT");
|
|
418
|
+
}
|
|
415
419
|
// NOT-249: normalized Cursor-team billing snapshot for databases created
|
|
416
420
|
// before schema.sql declared the table (same upgrade-path pattern as
|
|
417
421
|
// runtime_capacity_snapshots above).
|
|
@@ -411,6 +411,10 @@ CREATE TABLE IF NOT EXISTS runtime_capacity_snapshots (
|
|
|
411
411
|
-- it collapsed into) — never re-derived client-side from a window key or
|
|
412
412
|
-- label, which cannot disambiguate multiple same-duration buckets.
|
|
413
413
|
critical_role TEXT,
|
|
414
|
+
-- NOT-366: JSON acquisition-failure streak for an unavailable row
|
|
415
|
+
-- (operator summary, consecutive-failure count, first/last failure time).
|
|
416
|
+
-- Null on every successful reading.
|
|
417
|
+
unavailable_detail TEXT,
|
|
414
418
|
PRIMARY KEY (runtime, window_key)
|
|
415
419
|
);
|
|
416
420
|
|
|
@@ -566,3 +570,31 @@ CREATE INDEX IF NOT EXISTS idx_queue_entries_queued_position
|
|
|
566
570
|
ON queue_entries(position) WHERE state = 'queued';
|
|
567
571
|
-- NOT-173: the read model loads every queue-entry row for an issue set.
|
|
568
572
|
CREATE INDEX IF NOT EXISTS idx_queue_entries_issue ON queue_entries(issue_id);
|
|
573
|
+
|
|
574
|
+
-- NOT-364: durable snapshots of a Linear issue's own file/link attachments.
|
|
575
|
+
-- One row per (issue, Linear attachment id): hosted files carry a Dealer-owned
|
|
576
|
+
-- blob_path plus integrity fields so a queued Builder never needs the
|
|
577
|
+
-- expiring Linear URL; links carry source metadata only. Blobs live under
|
|
578
|
+
-- <datadir>/source-attachments/<issueId>/ and are retained with the issue
|
|
579
|
+
-- history (there is no hard delete — `closed` issues stay queryable).
|
|
580
|
+
CREATE TABLE IF NOT EXISTS issue_source_attachments (
|
|
581
|
+
id TEXT PRIMARY KEY,
|
|
582
|
+
issue_id TEXT NOT NULL REFERENCES issues(id),
|
|
583
|
+
linear_attachment_id TEXT NOT NULL,
|
|
584
|
+
kind TEXT NOT NULL CHECK (kind IN ('file', 'link')),
|
|
585
|
+
title TEXT NOT NULL,
|
|
586
|
+
safe_file_name TEXT,
|
|
587
|
+
blob_path TEXT,
|
|
588
|
+
content_type TEXT,
|
|
589
|
+
size_bytes INTEGER,
|
|
590
|
+
sha256 TEXT,
|
|
591
|
+
url TEXT NOT NULL,
|
|
592
|
+
subtitle TEXT,
|
|
593
|
+
source TEXT,
|
|
594
|
+
created_at TEXT NOT NULL,
|
|
595
|
+
updated_at TEXT NOT NULL
|
|
596
|
+
);
|
|
597
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_source_attachments_issue_linear
|
|
598
|
+
ON issue_source_attachments(issue_id, linear_attachment_id);
|
|
599
|
+
CREATE INDEX IF NOT EXISTS idx_issue_source_attachments_issue
|
|
600
|
+
ON issue_source_attachments(issue_id);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// NOT-364: docs/LINEAR_INTEGRATION.md is the canonical issue-attachment
|
|
2
|
+
// contract — durability, limits, trust boundary, and the comment-only
|
|
3
|
+
// exclusion must stay stated there, and the stated limits must match code.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { MAX_SOURCE_ATTACHMENT_FILE_BYTES, MAX_SOURCE_ATTACHMENT_TOTAL_BYTES, } from "./coordinator/source-attachments.js";
|
|
10
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
11
|
+
const doc = fs.readFileSync(path.join(repoRoot, "docs/LINEAR_INTEGRATION.md"), "utf8");
|
|
12
|
+
test("the Linear doc states attachment durability, limits, trust, and exclusions", () => {
|
|
13
|
+
for (const phrase of [
|
|
14
|
+
"### Issue attachments (NOT-364)",
|
|
15
|
+
"100 MiB per file",
|
|
16
|
+
"250 MiB total per issue",
|
|
17
|
+
"External links",
|
|
18
|
+
"never downloaded",
|
|
19
|
+
"comments",
|
|
20
|
+
"never auto-extracted",
|
|
21
|
+
"untrusted ticket inputs",
|
|
22
|
+
"never commit them",
|
|
23
|
+
"fresh contained directory",
|
|
24
|
+
".agent-dealer-inputs/linear/",
|
|
25
|
+
"Reload from Linear",
|
|
26
|
+
"Reviewers receive the manifest metadata only",
|
|
27
|
+
]) {
|
|
28
|
+
assert.ok(doc.includes(phrase), `LINEAR_INTEGRATION.md must state: ${phrase}`);
|
|
29
|
+
}
|
|
30
|
+
// The stale line claiming attachments are never imported is gone.
|
|
31
|
+
assert.ok(!doc.includes("attachments are not imported"));
|
|
32
|
+
});
|
|
33
|
+
test("documented limits match the enforced snapshot caps", () => {
|
|
34
|
+
assert.equal(MAX_SOURCE_ATTACHMENT_FILE_BYTES, 100 * 1024 * 1024);
|
|
35
|
+
assert.equal(MAX_SOURCE_ATTACHMENT_TOTAL_BYTES, 250 * 1024 * 1024);
|
|
36
|
+
});
|