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.
Files changed (39) 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/coordinator/auto-merge.js +10 -0
  7. package/bundle/server/dist/coordinator/branch-progress.js +5 -4
  8. package/bundle/server/dist/coordinator/commands.js +37 -3
  9. package/bundle/server/dist/coordinator/developer-effect.js +33 -6
  10. package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
  11. package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
  12. package/bundle/server/dist/coordinator/prompts.js +9 -1
  13. package/bundle/server/dist/coordinator/prompts.test.js +93 -0
  14. package/bundle/server/dist/coordinator/source-attachments.js +473 -0
  15. package/bundle/server/dist/coordinator/source-attachments.test.js +284 -0
  16. package/bundle/server/dist/coordinator/source-reload.js +178 -0
  17. package/bundle/server/dist/db/schema.sql +28 -0
  18. package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
  19. package/bundle/server/dist/paths.js +12 -0
  20. package/bundle/server/dist/repository/source-attachments.js +56 -0
  21. package/bundle/server/dist/routes/human-actions.js +17 -0
  22. package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
  23. package/bundle/server/dist/routes/issues-source-attachments.test.js +411 -0
  24. package/bundle/server/dist/routes/issues.js +89 -1
  25. package/bundle/server/package.json +2 -2
  26. package/bundle/server/static-ui/assets/index-DDbJ9ZU7.js +64 -0
  27. package/bundle/server/static-ui/index.html +1 -1
  28. package/bundle/shared/dist/index.d.ts +108 -2
  29. package/bundle/shared/dist/index.js +12 -1
  30. package/bundle/shared/dist/issues.d.ts +43 -2
  31. package/bundle/shared/dist/issues.js +10 -0
  32. package/bundle/shared/dist/linear-intake.d.ts +85 -0
  33. package/bundle/shared/dist/linear-intake.js +56 -0
  34. package/bundle/shared/dist/linear-intake.test.js +55 -1
  35. package/bundle/shared/dist/workflow.d.ts +4 -4
  36. package/bundle/shared/dist/workflow.js +7 -0
  37. package/bundle/shared/package.json +1 -1
  38. package/package.json +1 -1
  39. package/bundle/server/static-ui/assets/index-BzLjMBI9.js +0 -64
@@ -0,0 +1,284 @@
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
+ test("reviewer prompt section carries manifest metadata, never paths or bytes", () => {
264
+ const lines = sourceAttachmentsReviewerSection([
265
+ {
266
+ linearAttachmentId: "att-file-1",
267
+ kind: "file",
268
+ title: "repro.tar.gz",
269
+ safeFileName: "repro.tar.gz",
270
+ blobPath: "/blobs/repro.tar.gz",
271
+ sizeBytes: 18,
272
+ sha256: "ab".repeat(32),
273
+ contentType: "application/gzip",
274
+ url: HOSTED,
275
+ },
276
+ { linearAttachmentId: "att-link-1", kind: "link", title: "Design doc", url: LINK },
277
+ ]);
278
+ const text = lines.join("\n");
279
+ assert.ok(text.includes("metadata only, no file contents"));
280
+ assert.ok(text.includes('"repro.tar.gz"'));
281
+ assert.ok(text.includes("18 bytes"));
282
+ assert.ok(!text.includes(".agent-dealer-inputs"), "no worktree path is promised to reviewers");
283
+ assert.ok(!text.includes("/blobs/"), "server blob paths stay server-side");
284
+ });
@@ -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
+ }
@@ -566,3 +566,31 @@ CREATE INDEX IF NOT EXISTS idx_queue_entries_queued_position
566
566
  ON queue_entries(position) WHERE state = 'queued';
567
567
  -- NOT-173: the read model loads every queue-entry row for an issue set.
568
568
  CREATE INDEX IF NOT EXISTS idx_queue_entries_issue ON queue_entries(issue_id);
569
+
570
+ -- NOT-364: durable snapshots of a Linear issue's own file/link attachments.
571
+ -- One row per (issue, Linear attachment id): hosted files carry a Dealer-owned
572
+ -- blob_path plus integrity fields so a queued Builder never needs the
573
+ -- expiring Linear URL; links carry source metadata only. Blobs live under
574
+ -- <datadir>/source-attachments/<issueId>/ and are retained with the issue
575
+ -- history (there is no hard delete — `closed` issues stay queryable).
576
+ CREATE TABLE IF NOT EXISTS issue_source_attachments (
577
+ id TEXT PRIMARY KEY,
578
+ issue_id TEXT NOT NULL REFERENCES issues(id),
579
+ linear_attachment_id TEXT NOT NULL,
580
+ kind TEXT NOT NULL CHECK (kind IN ('file', 'link')),
581
+ title TEXT NOT NULL,
582
+ safe_file_name TEXT,
583
+ blob_path TEXT,
584
+ content_type TEXT,
585
+ size_bytes INTEGER,
586
+ sha256 TEXT,
587
+ url TEXT NOT NULL,
588
+ subtitle TEXT,
589
+ source TEXT,
590
+ created_at TEXT NOT NULL,
591
+ updated_at TEXT NOT NULL
592
+ );
593
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_source_attachments_issue_linear
594
+ ON issue_source_attachments(issue_id, linear_attachment_id);
595
+ CREATE INDEX IF NOT EXISTS idx_issue_source_attachments_issue
596
+ 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
+ });
@@ -35,6 +35,18 @@ export function cleanupOrphanedWorkerMcpConfig() {
35
35
  fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
36
36
  }
37
37
  }
38
+ /**
39
+ * NOT-364: Dealer-owned blobs for Linear issue-source attachments. Content is
40
+ * addressed under `<dir>/<issueId>/`; rows in `issue_source_attachments` point
41
+ * here. Retained with the issue history — worktree copies are removed with
42
+ * normal worktree cleanup, but these blobs survive until the issue row itself
43
+ * is gone (and there is no hard-delete path: `closed` issues stay queryable).
44
+ */
45
+ export function getSourceAttachmentsDir() {
46
+ const dir = path.join(getDataDir(), "source-attachments");
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ return dir;
49
+ }
38
50
  /**
39
51
  * Root for Dealer-managed repository clones and session worktrees (NOT-149).
40
52
  * Defaults under AGENT_DEALER_HOME; override with AGENT_DEALER_EXECUTION_ROOT for a
@@ -0,0 +1,56 @@
1
+ // packages/server/src/repository/source-attachments.ts
2
+ //
3
+ // NOT-364: read/write side for `issue_source_attachments` — durable snapshots
4
+ // of a Linear issue's own file/link attachments, keyed by
5
+ // (Dealer issue id, Linear attachment id). Hosted files carry a Dealer-owned
6
+ // blob_path plus integrity fields; links carry source metadata only.
7
+ import { v4 as uuid } from "uuid";
8
+ import { getDb } from "../db/index.js";
9
+ function rowToRecord(row) {
10
+ const out = {
11
+ linearAttachmentId: row.linear_attachment_id,
12
+ kind: row.kind === "file" ? "file" : "link",
13
+ title: row.title,
14
+ url: row.url,
15
+ };
16
+ if (row.safe_file_name)
17
+ out.safeFileName = row.safe_file_name;
18
+ if (row.blob_path)
19
+ out.blobPath = row.blob_path;
20
+ if (row.content_type)
21
+ out.contentType = row.content_type;
22
+ if (row.size_bytes !== null)
23
+ out.sizeBytes = row.size_bytes;
24
+ if (row.sha256)
25
+ out.sha256 = row.sha256;
26
+ if (row.subtitle)
27
+ out.subtitle = row.subtitle;
28
+ if (row.source)
29
+ out.source = row.source;
30
+ return out;
31
+ }
32
+ /** Every source attachment for the issue, oldest first (stable prompt order). */
33
+ export function listSourceAttachments(issueId) {
34
+ const rows = getDb()
35
+ .prepare("SELECT * FROM issue_source_attachments WHERE issue_id = ? ORDER BY created_at ASC, rowid ASC")
36
+ .all(issueId);
37
+ return rows.map(rowToRecord);
38
+ }
39
+ /**
40
+ * All-or-nothing replacement of an issue's attachment snapshot: deletes every
41
+ * existing row for the issue, then inserts the new set. Call inside the
42
+ * caller's transaction when the replacement must commit atomically with other
43
+ * writes (import text, reload text) — a throw leaves the prior rows untouched.
44
+ */
45
+ export function replaceSourceAttachments(issueId, records) {
46
+ const db = getDb();
47
+ const now = new Date().toISOString();
48
+ db.prepare("DELETE FROM issue_source_attachments WHERE issue_id = ?").run(issueId);
49
+ const insert = db.prepare(`INSERT INTO issue_source_attachments
50
+ (id, issue_id, linear_attachment_id, kind, title, safe_file_name, blob_path,
51
+ content_type, size_bytes, sha256, url, subtitle, source, created_at, updated_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
53
+ for (const r of records) {
54
+ insert.run(uuid(), issueId, r.linearAttachmentId, r.kind, r.title, r.safeFileName ?? null, r.blobPath ?? null, r.contentType ?? null, r.sizeBytes ?? null, r.sha256 ?? null, r.url, r.subtitle ?? null, r.source ?? null, now, now);
55
+ }
56
+ }
@@ -2,6 +2,7 @@ import { getHumanAction, listOpenHumanActions } from "../repository/human-action
2
2
  import { resolveHumanActionAndAdvanceAsync } from "../coordinator/commands.js";
3
3
  import { normalizeResolutionNote } from "../coordinator/human-resolution.js";
4
4
  import { triggerIssueReflect, resolveReflectionInteractionAction } from "../coordinator/reflect-trigger.js";
5
+ import { isLinearMergeStaleAction, resolveLinearMergeStaleAction, } from "../coordinator/linear-merge-verify.js";
5
6
  import { resolveOutboundDeliveryAction } from "../queue/approve-deliver.js";
6
7
  export async function registerHumanActionRoutes(app) {
7
8
  app.get("/api/human-actions", async () => listOpenHumanActions());
@@ -50,6 +51,22 @@ export async function registerHumanActionRoutes(app) {
50
51
  return reply.status(deliveryResult.code).send({ error: deliveryResult.error });
51
52
  return { runStatus: deliveryResult.runStatus, delivered: deliveryResult.delivered };
52
53
  }
54
+ // NOT-362: the stale-source notice sits on an already-`done` issue with no
55
+ // active workflow to advance — the generic resolver would 409 on "no active
56
+ // workflow for this action" and the notice could never close. Resolve it
57
+ // directly (same shape as the reflection branch above); `recheck` re-runs
58
+ // the post-merge verification fire-and-forget.
59
+ if (action.actionType === "policy_escalation" && isLinearMergeStaleAction(action)) {
60
+ const staleResult = resolveLinearMergeStaleAction(id, resolvedBy, choice);
61
+ if (!staleResult.ok)
62
+ return reply.status(staleResult.code).send({ error: staleResult.error });
63
+ return {
64
+ issueStatus: staleResult.issueStatus,
65
+ nextWorkItemId: null,
66
+ instanceCompleted: false,
67
+ restarted: false,
68
+ };
69
+ }
53
70
  // Awaits undraft+merge when final_review:complete (NOT-102) — sync resolve alone would
54
71
  // only park at AUTO_MERGE_INTENT and leave the PR draft.
55
72
  const result = await resolveHumanActionAndAdvanceAsync(id, resolvedBy, choice, {