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,666 @@
|
|
|
1
|
+
// packages/server/src/routes/issues-delete.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-365: DELETE /api/issues/:id — Dealer-local hard delete.
|
|
4
|
+
import { test, before, beforeEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import Fastify from "fastify";
|
|
10
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-delete-"));
|
|
11
|
+
const { migrate, getDb, getDataDir } = await import("../db/index.js");
|
|
12
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
13
|
+
const { registerIssueRoutes } = await import("./issues.js");
|
|
14
|
+
const { getIssue, transitionIssue } = await import("../repository/issues.js");
|
|
15
|
+
const { enqueueIssue, getQueuedEntryForIssue, listQueuedEntries } = await import("../repository/queue-entries.js");
|
|
16
|
+
const { appendWorkflowEvent } = await import("../repository/workflow-events.js");
|
|
17
|
+
const { startWorkflowInstance, completeWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
18
|
+
const { createWorkerSession, startSession, completeSession } = await import("../repository/worker-sessions.js");
|
|
19
|
+
const { enqueueWorkItem, cancelWorkItem } = await import("../repository/work-items.js");
|
|
20
|
+
const { createHumanAction } = await import("../repository/human-actions.js");
|
|
21
|
+
const { createIssueArtifact } = await import("../repository/artifacts.js");
|
|
22
|
+
const { recordUsageEvent } = await import("../repository/usage-events.js");
|
|
23
|
+
const { recordFailureCauses } = await import("../repository/failure-causes.js");
|
|
24
|
+
const { insertSessionActivityEvent } = await import("../repository/session-activity.js");
|
|
25
|
+
const { reconcileFinding } = await import("../repository/findings.js");
|
|
26
|
+
const { replaceSourceAttachments } = await import("../repository/source-attachments.js");
|
|
27
|
+
const { getSourceAttachmentsDir } = await import("../paths.js");
|
|
28
|
+
const { countIssueScopedRows, removeDealerPathContained, ISSUE_SCOPED_TABLES } = await import("../coordinator/delete-issue.js");
|
|
29
|
+
const { managedWorktreePath, managedWorktreesRoot } = await import("../adapters/managed-repo.js");
|
|
30
|
+
const { parseGitHubRepoInput } = await import("@agent-dealer/shared");
|
|
31
|
+
before(() => {
|
|
32
|
+
migrate();
|
|
33
|
+
});
|
|
34
|
+
async function buildApp() {
|
|
35
|
+
const app = Fastify();
|
|
36
|
+
await registerIssueRoutes(app);
|
|
37
|
+
return app;
|
|
38
|
+
}
|
|
39
|
+
function cleanTables() {
|
|
40
|
+
// Child-first so foreign-key enforcement (never disabled) stays satisfied:
|
|
41
|
+
// review_publications -> work_items; workflow_events/usage/activity/artifacts
|
|
42
|
+
// -> worker_sessions; work_items/human_actions -> workflow_instances; every
|
|
43
|
+
// issue-scoped row -> issues last. failure_causes, queue_entries and
|
|
44
|
+
// authority_attempts carry no REFERENCES but are still issue-scoped.
|
|
45
|
+
getDb().exec(`
|
|
46
|
+
DELETE FROM review_publications;
|
|
47
|
+
DELETE FROM workflow_events;
|
|
48
|
+
DELETE FROM usage_events;
|
|
49
|
+
DELETE FROM session_activity_events;
|
|
50
|
+
DELETE FROM artifacts;
|
|
51
|
+
DELETE FROM failure_causes;
|
|
52
|
+
DELETE FROM findings;
|
|
53
|
+
DELETE FROM human_actions;
|
|
54
|
+
DELETE FROM issue_source_attachments;
|
|
55
|
+
DELETE FROM work_items;
|
|
56
|
+
DELETE FROM queue_entries;
|
|
57
|
+
DELETE FROM worker_sessions;
|
|
58
|
+
DELETE FROM workflow_instances;
|
|
59
|
+
DELETE FROM authority_attempts;
|
|
60
|
+
DELETE FROM issues;
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
cleanTables();
|
|
65
|
+
});
|
|
66
|
+
async function createIssue(app, extra = {}, enqueue = true) {
|
|
67
|
+
const res = await app.inject({
|
|
68
|
+
method: "POST",
|
|
69
|
+
url: "/api/issues",
|
|
70
|
+
payload: {
|
|
71
|
+
title: "Delete me",
|
|
72
|
+
repo: "acme/app",
|
|
73
|
+
baseBranch: "main",
|
|
74
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
75
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
76
|
+
enqueue,
|
|
77
|
+
...extra,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
assert.equal(res.statusCode, 200, `create failed: ${res.body}`);
|
|
81
|
+
return res.json().id;
|
|
82
|
+
}
|
|
83
|
+
function setStatus(issueId, status) {
|
|
84
|
+
getDb().prepare("UPDATE issues SET status = ? WHERE id = ?").run(status, issueId);
|
|
85
|
+
}
|
|
86
|
+
function escapeRegExp(value) {
|
|
87
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
}
|
|
89
|
+
/** Fake a linked-worktree checkout at `dir` whose HEAD sits on `branch` (or is
|
|
90
|
+
* detached when `branch` is null) — `.git` file plus gitdir, no git binary. */
|
|
91
|
+
function fakeLinkedWorktree(dir, branch) {
|
|
92
|
+
fs.mkdirSync(path.join(dir, "gitdir"), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(dir, ".git"), "gitdir: ./gitdir\n");
|
|
94
|
+
fs.writeFileSync(path.join(dir, "gitdir", "HEAD"), branch === null ? "0123456789abcdef0123456789abcdef01234567\n" : `ref: refs/heads/${branch}\n`);
|
|
95
|
+
}
|
|
96
|
+
/** Write a Dealer-owned file under AGENT_DEALER_HOME and return its path. */
|
|
97
|
+
function homeFile(name, content = "dealer-owned bytes") {
|
|
98
|
+
const file = path.join(getDataDir(), name);
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
fs.writeFileSync(file, content);
|
|
101
|
+
return file;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Seed one row in every issue-scoped table for a settled (completed-instance,
|
|
105
|
+
* terminal-session, cancelled-work-item) issue, with real Dealer-owned files
|
|
106
|
+
* for every collected path kind. Returns the file paths the delete must remove.
|
|
107
|
+
*/
|
|
108
|
+
function seedFullHistory(issueId) {
|
|
109
|
+
// A live queue row alongside the settled history: the delete must remove it
|
|
110
|
+
// in the same transaction as every other issue-scoped row.
|
|
111
|
+
enqueueIssue(issueId);
|
|
112
|
+
const logFile = homeFile(`.temporal/logs/${issueId}-developer.ndjson`, '{"t":"log"}\n');
|
|
113
|
+
const artifactBlob = homeFile(`artifact-${issueId}.ndjson`, '{"t":"trace"}\n');
|
|
114
|
+
const failureLog = homeFile(`failure-${issueId}.log`, "boom\n");
|
|
115
|
+
const attachmentBlob = path.join(getSourceAttachmentsDir(), issueId, "spec.pdf");
|
|
116
|
+
fs.mkdirSync(path.dirname(attachmentBlob), { recursive: true });
|
|
117
|
+
fs.writeFileSync(attachmentBlob, "%PDF-bytes");
|
|
118
|
+
const instance = startWorkflowInstance(issueId, "dev-reviewer-v1");
|
|
119
|
+
const session = createWorkerSession({ issueId, role: "developer", round: 1, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
120
|
+
const created = appendWorkflowEvent({
|
|
121
|
+
issueId,
|
|
122
|
+
type: "issue.created",
|
|
123
|
+
actorType: "human",
|
|
124
|
+
stage: "ready",
|
|
125
|
+
workflowInstanceId: instance.id,
|
|
126
|
+
});
|
|
127
|
+
appendWorkflowEvent({
|
|
128
|
+
issueId,
|
|
129
|
+
type: "worker.completed",
|
|
130
|
+
actorType: "developer",
|
|
131
|
+
stage: "developing",
|
|
132
|
+
workflowInstanceId: instance.id,
|
|
133
|
+
workerSessionId: session.id,
|
|
134
|
+
causationEventId: created.id,
|
|
135
|
+
});
|
|
136
|
+
const item = enqueueWorkItem({ issueId, workflowInstanceId: instance.id, kind: "developer", round: 1 });
|
|
137
|
+
// A settled (cancelled) work item still owns a publication claim row.
|
|
138
|
+
getDb()
|
|
139
|
+
.prepare(`INSERT INTO review_publications (work_item_id, state, result_json, event, used_comment_fallback, claimed_at, updated_at)
|
|
140
|
+
VALUES (?, 'published', '{}', 'commented', 0, ?, ?)`)
|
|
141
|
+
.run(item.id, new Date().toISOString(), new Date().toISOString());
|
|
142
|
+
cancelWorkItem(item.id);
|
|
143
|
+
createHumanAction({
|
|
144
|
+
issueId,
|
|
145
|
+
workflowInstanceId: instance.id,
|
|
146
|
+
actionType: "attempts_exhausted",
|
|
147
|
+
reason: "spent",
|
|
148
|
+
question: "Retry?",
|
|
149
|
+
});
|
|
150
|
+
reconcileFinding({
|
|
151
|
+
issueId,
|
|
152
|
+
fingerprint: "fp-1",
|
|
153
|
+
severity: "blocking",
|
|
154
|
+
title: "Race",
|
|
155
|
+
rationale: "racy",
|
|
156
|
+
round: 1,
|
|
157
|
+
});
|
|
158
|
+
createIssueArtifact({ issueId, workerSessionId: session.id, kind: "dev_trace", blobPath: artifactBlob, author: "agent" });
|
|
159
|
+
recordUsageEvent({ issueId, workerSessionId: session.id, role: "developer" });
|
|
160
|
+
recordFailureCauses([
|
|
161
|
+
{
|
|
162
|
+
issueId,
|
|
163
|
+
workflowInstanceId: instance.id,
|
|
164
|
+
cause: {
|
|
165
|
+
code: "agent_cli_crash",
|
|
166
|
+
domain: "infrastructure",
|
|
167
|
+
primary: true,
|
|
168
|
+
confidence: "high",
|
|
169
|
+
evidenceSource: "session_error",
|
|
170
|
+
occurredAt: null,
|
|
171
|
+
eventCursor: null,
|
|
172
|
+
rawReason: "cli died",
|
|
173
|
+
sessionId: session.id,
|
|
174
|
+
logPath: failureLog,
|
|
175
|
+
eventId: null,
|
|
176
|
+
eventType: null,
|
|
177
|
+
quality: "exact",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
insertSessionActivityEvent({
|
|
182
|
+
issueId,
|
|
183
|
+
workerSessionId: session.id,
|
|
184
|
+
activityKind: "assistant_output",
|
|
185
|
+
state: "completed",
|
|
186
|
+
summary: "did a thing",
|
|
187
|
+
});
|
|
188
|
+
replaceSourceAttachments(issueId, [
|
|
189
|
+
{
|
|
190
|
+
linearAttachmentId: "att-1",
|
|
191
|
+
kind: "file",
|
|
192
|
+
title: "spec.pdf",
|
|
193
|
+
safeFileName: "spec.pdf",
|
|
194
|
+
blobPath: attachmentBlob,
|
|
195
|
+
contentType: "application/pdf",
|
|
196
|
+
sizeBytes: 10,
|
|
197
|
+
sha256: "abc",
|
|
198
|
+
url: "https://linear.app/file/att-1",
|
|
199
|
+
subtitle: undefined,
|
|
200
|
+
source: undefined,
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
// A settled (closed) authority attempt for this issue — deleted, never blocking.
|
|
204
|
+
getDb()
|
|
205
|
+
.prepare(`INSERT INTO authority_attempts
|
|
206
|
+
(id, owner_kind, owner_id, idempotency_key, authority_id, deck_id, run_id, attempt_id,
|
|
207
|
+
ttl_ms, tool_scope_hint_json, status, stale_at, expires_at, created_at, updated_at)
|
|
208
|
+
VALUES (?, 'developer', ?, ?, 'auth-1', 'deck-1', 'run-1', 'attempt-1',
|
|
209
|
+
60000, NULL, 'closed', NULL, ?, ?, ?)`)
|
|
210
|
+
.run(`aa-${issueId}`, `${issueId}:developer`, `idem-${issueId}`, new Date().toISOString(), new Date().toISOString(), new Date().toISOString());
|
|
211
|
+
completeSession(session.id, { status: "done", logPath: logFile });
|
|
212
|
+
completeWorkflowInstance(instance.id, "done");
|
|
213
|
+
const counts = countIssueScopedRows(issueId);
|
|
214
|
+
for (const table of ISSUE_SCOPED_TABLES) {
|
|
215
|
+
if (table === "issues")
|
|
216
|
+
continue;
|
|
217
|
+
assert.ok(counts[table] > 0, `fixture must seed ${table}`);
|
|
218
|
+
}
|
|
219
|
+
return { files: [logFile, artifactBlob, failureLog, attachmentBlob], attachmentDir: path.join(getSourceAttachmentsDir(), issueId) };
|
|
220
|
+
}
|
|
221
|
+
function foreignKeyViolations() {
|
|
222
|
+
return getDb().prepare("PRAGMA foreign_key_check").all()
|
|
223
|
+
.map((r) => r.table);
|
|
224
|
+
}
|
|
225
|
+
test("DELETE removes a ready issue; detail 404s and list/queue no longer name it", async () => {
|
|
226
|
+
const app = await buildApp();
|
|
227
|
+
const id = await createIssue(app);
|
|
228
|
+
assert.ok(getQueuedEntryForIssue(id), "ready issue starts queued");
|
|
229
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
230
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
231
|
+
const body = del.json();
|
|
232
|
+
assert.equal(body.deleted, true);
|
|
233
|
+
assert.equal(body.removedQueueEntry, true);
|
|
234
|
+
assert.deepEqual(body.residualPaths, []);
|
|
235
|
+
assert.equal((await app.inject({ method: "GET", url: `/api/issues/${id}` })).statusCode, 404);
|
|
236
|
+
const list = (await app.inject({ method: "GET", url: "/api/issues" })).json();
|
|
237
|
+
assert.ok(!list.some((i) => i.id === id));
|
|
238
|
+
assert.equal(getQueuedEntryForIssue(id), null);
|
|
239
|
+
assert.equal(listQueuedEntries().some((e) => e.issueId === id), false);
|
|
240
|
+
assert.deepEqual(foreignKeyViolations(), []);
|
|
241
|
+
await app.close();
|
|
242
|
+
});
|
|
243
|
+
test("DELETE removes done and closed issues (unguarded terminal states stay deletable)", async () => {
|
|
244
|
+
const app = await buildApp();
|
|
245
|
+
for (const status of ["done", "closed"]) {
|
|
246
|
+
const id = await createIssue(app, {}, false);
|
|
247
|
+
setStatus(id, status);
|
|
248
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
249
|
+
assert.equal(del.statusCode, 200, `${status}: ${del.body}`);
|
|
250
|
+
assert.equal((await app.inject({ method: "GET", url: `/api/issues/${id}` })).statusCode, 404);
|
|
251
|
+
assert.equal(getIssue(id), null);
|
|
252
|
+
}
|
|
253
|
+
assert.deepEqual(foreignKeyViolations(), []);
|
|
254
|
+
await app.close();
|
|
255
|
+
});
|
|
256
|
+
test("DELETE 404s for an unknown issue", async () => {
|
|
257
|
+
const app = await buildApp();
|
|
258
|
+
const res = await app.inject({ method: "DELETE", url: "/api/issues/does-not-exist" });
|
|
259
|
+
assert.equal(res.statusCode, 404);
|
|
260
|
+
await app.close();
|
|
261
|
+
});
|
|
262
|
+
test("DELETE empties every issue-scoped table and removes blobs/logs with no FK violations", async () => {
|
|
263
|
+
const app = await buildApp();
|
|
264
|
+
const id = await createIssue(app, {}, false);
|
|
265
|
+
const { files, attachmentDir } = seedFullHistory(id);
|
|
266
|
+
setStatus(id, "done");
|
|
267
|
+
for (const f of files)
|
|
268
|
+
assert.ok(fs.existsSync(f), `fixture file must exist: ${f}`);
|
|
269
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
270
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
271
|
+
assert.deepEqual(del.json().residualPaths, []);
|
|
272
|
+
const counts = countIssueScopedRows(id);
|
|
273
|
+
for (const [table, n] of Object.entries(counts)) {
|
|
274
|
+
assert.equal(n, 0, `${table} must be empty after delete`);
|
|
275
|
+
}
|
|
276
|
+
assert.deepEqual(foreignKeyViolations(), []);
|
|
277
|
+
for (const f of files)
|
|
278
|
+
assert.ok(!fs.existsSync(f), `blob/log must be removed: ${f}`);
|
|
279
|
+
assert.ok(!fs.existsSync(attachmentDir), "issue source-attachment dir must be removed");
|
|
280
|
+
assert.equal((await app.inject({ method: "GET", url: `/api/issues/${id}` })).statusCode, 404);
|
|
281
|
+
await app.close();
|
|
282
|
+
});
|
|
283
|
+
/** Full seeded-database plus file-tree snapshot — guard refusals must change neither. */
|
|
284
|
+
function snapshotAll() {
|
|
285
|
+
const tables = getDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all()
|
|
286
|
+
.map((t) => t.name)
|
|
287
|
+
.filter((n) => !n.startsWith("sqlite_"));
|
|
288
|
+
const db = {};
|
|
289
|
+
for (const table of tables) {
|
|
290
|
+
db[table] = getDb().prepare(`SELECT * FROM "${table}" ORDER BY rowid`).all();
|
|
291
|
+
}
|
|
292
|
+
const files = [];
|
|
293
|
+
const walk = (dir) => {
|
|
294
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
295
|
+
const p = path.join(dir, entry.name);
|
|
296
|
+
files.push(p);
|
|
297
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
298
|
+
walk(p);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
walk(getDataDir());
|
|
302
|
+
return { db: JSON.stringify(db), files: files.sort() };
|
|
303
|
+
}
|
|
304
|
+
async function assertRefused409(app, id, needle, before) {
|
|
305
|
+
const res = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
306
|
+
assert.equal(res.statusCode, 409, `expected 409, got ${res.statusCode}: ${res.body}`);
|
|
307
|
+
assert.match(res.json().error, needle);
|
|
308
|
+
assert.ok(getIssue(id), "refused delete must keep the issue row");
|
|
309
|
+
const after = snapshotAll();
|
|
310
|
+
assert.equal(after.db, before.db, "refused delete must change no database row");
|
|
311
|
+
assert.deepEqual(after.files, before.files, "refused delete must change no file");
|
|
312
|
+
}
|
|
313
|
+
test("DELETE refuses running/parked statuses with 409 naming the state", async () => {
|
|
314
|
+
const app = await buildApp();
|
|
315
|
+
for (const status of ["developing", "reviewing", "repairing", "final_review", "needs_human"]) {
|
|
316
|
+
const id = await createIssue(app, {}, false);
|
|
317
|
+
seedFullHistory(id);
|
|
318
|
+
setStatus(id, status);
|
|
319
|
+
await assertRefused409(app, id, new RegExp(`is ${status}`), snapshotAll());
|
|
320
|
+
}
|
|
321
|
+
await app.close();
|
|
322
|
+
});
|
|
323
|
+
test("DELETE refuses an active workflow instance, running session, and live work items", async () => {
|
|
324
|
+
const app = await buildApp();
|
|
325
|
+
// Active workflow instance (never completed).
|
|
326
|
+
{
|
|
327
|
+
const id = await createIssue(app, {}, false);
|
|
328
|
+
seedFullHistory(id);
|
|
329
|
+
startWorkflowInstance(id, "dev-reviewer-v1");
|
|
330
|
+
await assertRefused409(app, id, /active workflow instance/, snapshotAll());
|
|
331
|
+
cleanTables();
|
|
332
|
+
}
|
|
333
|
+
// Running worker session.
|
|
334
|
+
{
|
|
335
|
+
const id = await createIssue(app, {}, false);
|
|
336
|
+
seedFullHistory(id);
|
|
337
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 2, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
338
|
+
startSession(session.id);
|
|
339
|
+
await assertRefused409(app, id, /running worker session/, snapshotAll());
|
|
340
|
+
cleanTables();
|
|
341
|
+
}
|
|
342
|
+
// Pending and leased work items each block.
|
|
343
|
+
for (const live of ["pending", "leased"]) {
|
|
344
|
+
const id = await createIssue(app, {}, false);
|
|
345
|
+
seedFullHistory(id);
|
|
346
|
+
const instance = startWorkflowInstance(id, "dev-reviewer-v1");
|
|
347
|
+
const item = enqueueWorkItem({ issueId: id, workflowInstanceId: instance.id, kind: "reviewer", round: 1 });
|
|
348
|
+
if (live === "leased") {
|
|
349
|
+
getDb().prepare("UPDATE work_items SET status = 'leased', lease_owner = 'test' WHERE id = ?").run(item.id);
|
|
350
|
+
}
|
|
351
|
+
await assertRefused409(app, id, new RegExp(`${live} work item`), snapshotAll());
|
|
352
|
+
cleanTables();
|
|
353
|
+
}
|
|
354
|
+
await app.close();
|
|
355
|
+
});
|
|
356
|
+
test("DELETE refuses active authority attempts and existing worktrees", async () => {
|
|
357
|
+
const app = await buildApp();
|
|
358
|
+
// Active authority attempt naming the issue.
|
|
359
|
+
{
|
|
360
|
+
const id = await createIssue(app, {}, false);
|
|
361
|
+
seedFullHistory(id);
|
|
362
|
+
getDb()
|
|
363
|
+
.prepare(`INSERT INTO authority_attempts
|
|
364
|
+
(id, owner_kind, owner_id, idempotency_key, authority_id, deck_id, run_id, attempt_id,
|
|
365
|
+
ttl_ms, tool_scope_hint_json, status, stale_at, expires_at, created_at, updated_at)
|
|
366
|
+
VALUES (?, 'reviewer', ?, ?, 'auth-9', 'deck-1', 'run-1', 'attempt-9',
|
|
367
|
+
60000, NULL, 'active', NULL, ?, ?, ?)`)
|
|
368
|
+
.run(`aa-live-${id}`, `${id}:reviewer`, `idem-live-${id}`, new Date().toISOString(), new Date().toISOString(), new Date().toISOString());
|
|
369
|
+
await assertRefused409(app, id, /active authority attempt/, snapshotAll());
|
|
370
|
+
cleanTables();
|
|
371
|
+
}
|
|
372
|
+
// A session-recorded checkout that still exists on disk blocks, naming the path.
|
|
373
|
+
{
|
|
374
|
+
const id = await createIssue(app, {}, false);
|
|
375
|
+
seedFullHistory(id);
|
|
376
|
+
const wt = fs.mkdtempSync(path.join(getDataDir(), "wt-"));
|
|
377
|
+
fs.writeFileSync(path.join(wt, "wip.txt"), "uncommitted");
|
|
378
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 3, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
379
|
+
completeSession(session.id, { status: "failed", worktreePath: wt });
|
|
380
|
+
await assertRefused409(app, id, new RegExp(wt.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), snapshotAll());
|
|
381
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
382
|
+
cleanTables();
|
|
383
|
+
}
|
|
384
|
+
// A recorded worktree path that no longer exists does not block.
|
|
385
|
+
{
|
|
386
|
+
const id = await createIssue(app, {}, false);
|
|
387
|
+
seedFullHistory(id);
|
|
388
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 3, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
389
|
+
completeSession(session.id, { status: "done", worktreePath: path.join(getDataDir(), "wt-long-gone") });
|
|
390
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
391
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
392
|
+
}
|
|
393
|
+
await app.close();
|
|
394
|
+
});
|
|
395
|
+
test("DELETE refuses an unrecorded checkout at a session's deterministic managed path", async () => {
|
|
396
|
+
const app = await buildApp();
|
|
397
|
+
// The checkout exists on disk but no row names it — the deps-throw /
|
|
398
|
+
// salvage-conflict exits that return before patchRunningSession records it.
|
|
399
|
+
const id = await createIssue(app, {}, false);
|
|
400
|
+
seedFullHistory(id);
|
|
401
|
+
const identity = parseGitHubRepoInput(getIssue(id).repo).identity;
|
|
402
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 9, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
403
|
+
completeSession(session.id, { status: "failed" });
|
|
404
|
+
const unrecorded = managedWorktreePath(identity, session.id, "developer");
|
|
405
|
+
fs.mkdirSync(unrecorded, { recursive: true });
|
|
406
|
+
fs.writeFileSync(path.join(unrecorded, "wip.txt"), "uncommitted");
|
|
407
|
+
try {
|
|
408
|
+
await assertRefused409(app, id, new RegExp(escapeRegExp(unrecorded)), snapshotAll());
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
fs.rmSync(unrecorded, { recursive: true, force: true });
|
|
412
|
+
cleanTables();
|
|
413
|
+
}
|
|
414
|
+
await app.close();
|
|
415
|
+
});
|
|
416
|
+
test("DELETE refuses an unrecorded checkout at a session's legacy-local path", async () => {
|
|
417
|
+
const app = await buildApp();
|
|
418
|
+
const id = await createIssue(app, {}, false);
|
|
419
|
+
seedFullHistory(id);
|
|
420
|
+
const legacyRepo = fs.mkdtempSync(path.join(getDataDir(), "legacy-repo-"));
|
|
421
|
+
getDb().prepare("UPDATE issues SET repo = ? WHERE id = ?").run(legacyRepo, id);
|
|
422
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 9, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
423
|
+
completeSession(session.id, { status: "failed" });
|
|
424
|
+
const unrecorded = path.join(legacyRepo, ".agent-dealer-worktrees", `${session.id}-developer`);
|
|
425
|
+
fs.mkdirSync(unrecorded, { recursive: true });
|
|
426
|
+
fs.writeFileSync(path.join(unrecorded, "wip.txt"), "uncommitted");
|
|
427
|
+
try {
|
|
428
|
+
await assertRefused409(app, id, new RegExp(escapeRegExp(unrecorded)), snapshotAll());
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
fs.rmSync(legacyRepo, { recursive: true, force: true });
|
|
432
|
+
cleanTables();
|
|
433
|
+
}
|
|
434
|
+
await app.close();
|
|
435
|
+
});
|
|
436
|
+
test("DELETE refuses a branch-holding checkout under another session's name", async () => {
|
|
437
|
+
const app = await buildApp();
|
|
438
|
+
const id = await createIssue(app, {}, false);
|
|
439
|
+
seedFullHistory(id);
|
|
440
|
+
getDb().prepare("UPDATE issues SET branch = ? WHERE id = ?").run("dealer/issue-mine", id);
|
|
441
|
+
const identity = parseGitHubRepoInput(getIssue(id).repo).identity;
|
|
442
|
+
// A reused leftover under another session's deterministic name — no row of
|
|
443
|
+
// this issue names it, but its HEAD still sits on the issue branch.
|
|
444
|
+
const holder = path.join(managedWorktreesRoot(identity), "some-other-session-developer");
|
|
445
|
+
fakeLinkedWorktree(holder, "dealer/issue-mine");
|
|
446
|
+
try {
|
|
447
|
+
await assertRefused409(app, id, new RegExp(escapeRegExp(holder)), snapshotAll());
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
fs.rmSync(holder, { recursive: true, force: true });
|
|
451
|
+
cleanTables();
|
|
452
|
+
}
|
|
453
|
+
await app.close();
|
|
454
|
+
});
|
|
455
|
+
test("DELETE refuses a worktree path recorded in human-action evidence", async () => {
|
|
456
|
+
const app = await buildApp();
|
|
457
|
+
const id = await createIssue(app, {}, false);
|
|
458
|
+
seedFullHistory(id);
|
|
459
|
+
const preserved = fs.mkdtempSync(path.join(getDataDir(), "preserved-"));
|
|
460
|
+
createHumanAction({
|
|
461
|
+
issueId: id,
|
|
462
|
+
actionType: "policy_escalation",
|
|
463
|
+
reason: "push rejected; checkout preserved",
|
|
464
|
+
question: "What now?",
|
|
465
|
+
evidence: { pushDivergence: { worktreePath: preserved } },
|
|
466
|
+
});
|
|
467
|
+
try {
|
|
468
|
+
await assertRefused409(app, id, new RegExp(escapeRegExp(preserved)), snapshotAll());
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
fs.rmSync(preserved, { recursive: true, force: true });
|
|
472
|
+
cleanTables();
|
|
473
|
+
}
|
|
474
|
+
await app.close();
|
|
475
|
+
});
|
|
476
|
+
test("DELETE ignores other-branch checkouts, detached HEADs, and relative evidence paths", async () => {
|
|
477
|
+
const app = await buildApp();
|
|
478
|
+
const id = await createIssue(app, {}, false);
|
|
479
|
+
seedFullHistory(id);
|
|
480
|
+
getDb().prepare("UPDATE issues SET branch = ? WHERE id = ?").run("dealer/issue-mine", id);
|
|
481
|
+
const identity = parseGitHubRepoInput(getIssue(id).repo).identity;
|
|
482
|
+
const root = managedWorktreesRoot(identity);
|
|
483
|
+
const otherBranch = path.join(root, "other-issue-session-developer");
|
|
484
|
+
const detached = path.join(root, "reviewer-detached-reviewer");
|
|
485
|
+
fakeLinkedWorktree(otherBranch, "dealer/issue-someone-else");
|
|
486
|
+
fakeLinkedWorktree(detached, null);
|
|
487
|
+
// A relative in-repo pointer is never a checkout — only absolute paths qualify.
|
|
488
|
+
createHumanAction({
|
|
489
|
+
issueId: id,
|
|
490
|
+
actionType: "policy_escalation",
|
|
491
|
+
reason: "note",
|
|
492
|
+
question: "note?",
|
|
493
|
+
evidence: { pushDivergence: { worktreePath: "src/relative-pointer.txt" } },
|
|
494
|
+
});
|
|
495
|
+
try {
|
|
496
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
497
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
fs.rmSync(otherBranch, { recursive: true, force: true });
|
|
501
|
+
fs.rmSync(detached, { recursive: true, force: true });
|
|
502
|
+
cleanTables();
|
|
503
|
+
}
|
|
504
|
+
await app.close();
|
|
505
|
+
});
|
|
506
|
+
test("file cleanup removes in-root files, never follows out-of-root paths, reports failures as residuals", async () => {
|
|
507
|
+
const home = getDataDir();
|
|
508
|
+
// Outside sentinel: must never be touched by any path below.
|
|
509
|
+
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-delete-outside-"));
|
|
510
|
+
const sentinel = path.join(outsideDir, "sentinel.txt");
|
|
511
|
+
fs.writeFileSync(sentinel, "untouchable");
|
|
512
|
+
// In-root file is removed, no residual.
|
|
513
|
+
const owned = homeFile("owned.txt");
|
|
514
|
+
assert.equal(removeDealerPathContained(owned, home), null);
|
|
515
|
+
assert.ok(!fs.existsSync(owned));
|
|
516
|
+
// `..` escape to the sentinel is refused and reported.
|
|
517
|
+
const escape = path.join(home, "sub", "..", "..", path.basename(path.dirname(sentinel)), "sentinel.txt");
|
|
518
|
+
fs.mkdirSync(path.join(home, "sub"), { recursive: true });
|
|
519
|
+
assert.equal(removeDealerPathContained(escape, home), escape);
|
|
520
|
+
assert.ok(fs.existsSync(sentinel));
|
|
521
|
+
// Absolute out-of-root path is refused and reported.
|
|
522
|
+
assert.equal(removeDealerPathContained(sentinel, home), sentinel);
|
|
523
|
+
assert.ok(fs.existsSync(sentinel));
|
|
524
|
+
// Symlink inside the home pointing outside is never followed: the link stays,
|
|
525
|
+
// the target stays, and the path reports as residual.
|
|
526
|
+
const link = path.join(home, "evil-link.txt");
|
|
527
|
+
fs.symlinkSync(sentinel, link);
|
|
528
|
+
assert.equal(removeDealerPathContained(link, home), link);
|
|
529
|
+
assert.ok(fs.existsSync(sentinel), "symlink target outside the home must survive");
|
|
530
|
+
assert.ok(fs.lstatSync(link).isSymbolicLink(), "the link itself must not be followed into removal");
|
|
531
|
+
// The Dealer home root itself is never removable — a corrupted path resolving
|
|
532
|
+
// there reports as residual instead of wiping the whole home.
|
|
533
|
+
assert.equal(removeDealerPathContained(home, home), home);
|
|
534
|
+
assert.ok(fs.existsSync(home), "home root must survive");
|
|
535
|
+
assert.ok(fs.existsSync(path.join(home, "sub")), "home contents must survive");
|
|
536
|
+
// In-home symlink to an in-home file: the link itself is removed, the target
|
|
537
|
+
// survives, and nothing reports as residual.
|
|
538
|
+
const linkTarget = homeFile("link-target.txt", "keep me");
|
|
539
|
+
const inLink = path.join(home, "in-link.txt");
|
|
540
|
+
fs.symlinkSync(linkTarget, inLink);
|
|
541
|
+
assert.equal(removeDealerPathContained(inLink, home), null);
|
|
542
|
+
assert.ok(!fs.existsSync(inLink), "the in-home link itself must be removed");
|
|
543
|
+
assert.ok(fs.existsSync(linkTarget), "the in-home link target must survive");
|
|
544
|
+
fs.rmSync(linkTarget, { force: true });
|
|
545
|
+
// Outside link pointing in: neither the link nor its target is ours — both
|
|
546
|
+
// stay, and the path reports as residual.
|
|
547
|
+
const inwardTarget = homeFile("inward-target.txt", "keep me");
|
|
548
|
+
const inward = path.join(outsideDir, "inward.txt");
|
|
549
|
+
fs.symlinkSync(inwardTarget, inward);
|
|
550
|
+
assert.equal(removeDealerPathContained(inward, home), inward);
|
|
551
|
+
assert.ok(fs.lstatSync(inward).isSymbolicLink(), "outside link must stay");
|
|
552
|
+
assert.ok(fs.existsSync(inwardTarget), "in-home target of an outside link must stay");
|
|
553
|
+
fs.rmSync(inwardTarget, { force: true });
|
|
554
|
+
// An induced unlink failure (read-only parent dir) reports a residual.
|
|
555
|
+
const lockedDir = path.join(home, "locked");
|
|
556
|
+
fs.mkdirSync(lockedDir, { recursive: true });
|
|
557
|
+
const lockedFile = path.join(lockedDir, "x.txt");
|
|
558
|
+
fs.writeFileSync(lockedFile, "x");
|
|
559
|
+
fs.chmodSync(lockedDir, 0o555);
|
|
560
|
+
try {
|
|
561
|
+
assert.equal(removeDealerPathContained(lockedFile, home), lockedFile);
|
|
562
|
+
assert.ok(fs.existsSync(sentinel));
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
fs.chmodSync(lockedDir, 0o755);
|
|
566
|
+
}
|
|
567
|
+
// Already-gone paths are clean, not residual.
|
|
568
|
+
assert.equal(removeDealerPathContained(path.join(home, "never-existed.txt"), home), null);
|
|
569
|
+
fs.rmSync(link, { force: true });
|
|
570
|
+
fs.rmSync(outsideDir, { recursive: true, force: true });
|
|
571
|
+
assert.ok(!fs.existsSync(outsideDir), "test must clean up its outside dir");
|
|
572
|
+
});
|
|
573
|
+
test("DELETE reports safe-path cleanup failures as residuals after the record deletion", async () => {
|
|
574
|
+
const app = await buildApp();
|
|
575
|
+
const id = await createIssue(app, {}, false);
|
|
576
|
+
setStatus(id, "done");
|
|
577
|
+
const lockedDir = path.join(getDataDir(), `locked-${id}`);
|
|
578
|
+
fs.mkdirSync(lockedDir, { recursive: true });
|
|
579
|
+
const blob = path.join(lockedDir, "trace.ndjson");
|
|
580
|
+
fs.writeFileSync(blob, "{}\n");
|
|
581
|
+
const session = createWorkerSession({ issueId: id, role: "developer", round: 1, agentId: BUILTIN_AGENT_CLAUDE_ID, runtime: "claude_code" });
|
|
582
|
+
completeSession(session.id, { status: "done", logPath: blob });
|
|
583
|
+
fs.chmodSync(lockedDir, 0o555);
|
|
584
|
+
try {
|
|
585
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
586
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
587
|
+
const body = del.json();
|
|
588
|
+
assert.equal(body.deleted, true);
|
|
589
|
+
assert.ok(body.residualPaths.includes(blob), `expected residual, got ${JSON.stringify(body)}`);
|
|
590
|
+
// The database row is still gone — cleanup never resurrects it.
|
|
591
|
+
assert.equal(getIssue(id), null);
|
|
592
|
+
assert.equal((await app.inject({ method: "GET", url: `/api/issues/${id}` })).statusCode, 404);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
fs.chmodSync(lockedDir, 0o755);
|
|
596
|
+
fs.rmSync(lockedDir, { recursive: true, force: true });
|
|
597
|
+
}
|
|
598
|
+
await app.close();
|
|
599
|
+
});
|
|
600
|
+
test("DELETE of a linear-sourced issue makes zero Linear/GitHub calls and keeps the ticket identifiers local", async () => {
|
|
601
|
+
const app = await buildApp();
|
|
602
|
+
const id = await createIssue(app, {
|
|
603
|
+
source: "linear",
|
|
604
|
+
externalId: "lin-123",
|
|
605
|
+
externalLabel: "NOT-123",
|
|
606
|
+
externalUrl: "https://linear.app/not-so-fat/issue/NOT-123",
|
|
607
|
+
}, false);
|
|
608
|
+
seedFullHistory(id);
|
|
609
|
+
setStatus(id, "done");
|
|
610
|
+
// Outbound-adapter spy: the delete path must perform no fetch at all, and its
|
|
611
|
+
// module must not import any outbound adapter — source identifiers stay local.
|
|
612
|
+
const fetches = [];
|
|
613
|
+
const realFetch = globalThis.fetch;
|
|
614
|
+
globalThis.fetch = ((...args) => {
|
|
615
|
+
fetches.push(String(args[0]));
|
|
616
|
+
return realFetch(...args);
|
|
617
|
+
});
|
|
618
|
+
try {
|
|
619
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
620
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
globalThis.fetch = realFetch;
|
|
624
|
+
}
|
|
625
|
+
assert.deepEqual(fetches, [], "deleting a linear issue must make zero outbound calls");
|
|
626
|
+
const moduleSource = fs.readFileSync(new URL("../coordinator/delete-issue.js", import.meta.url).pathname.replace(/\.js$/, ".ts"), "utf8");
|
|
627
|
+
// Only local git/repo-path adapters (worktree-guard path computation) — no
|
|
628
|
+
// outbound adapter may be imported.
|
|
629
|
+
const adapterImports = [
|
|
630
|
+
...moduleSource.matchAll(/from\s+["']([^"']*adapters\/[^"']+)["']/g),
|
|
631
|
+
].map((m) => m[1]);
|
|
632
|
+
for (const imp of adapterImports) {
|
|
633
|
+
assert.ok(imp.endsWith("adapters/managed-repo.js") || imp.endsWith("adapters/git-worktree.js"), `delete command must not import outbound adapter ${imp}`);
|
|
634
|
+
}
|
|
635
|
+
assert.ok(!moduleSource.includes("linear-inbox"), "no Linear client usage");
|
|
636
|
+
assert.ok(!moduleSource.includes("linear-graphql"), "no Linear client usage");
|
|
637
|
+
assert.ok(!moduleSource.includes("adapters/github"), "no GitHub client usage");
|
|
638
|
+
assert.ok(!/[^a-z]fetch\(/.test(moduleSource), "delete command must not call fetch");
|
|
639
|
+
await app.close();
|
|
640
|
+
});
|
|
641
|
+
test("lifecycle docs distinguish Remove from queue, Close, Abort, and Delete", () => {
|
|
642
|
+
// Documentation assertion for the NOT-365 issue-lifecycle section: the four
|
|
643
|
+
// operator actions must be named together with their distinct promises, and
|
|
644
|
+
// the stale "no hard-delete path" claim must be gone.
|
|
645
|
+
const doc = fs.readFileSync(new URL("../../../../docs/LINEAR_INTEGRATION.md", import.meta.url), "utf8");
|
|
646
|
+
assert.match(doc, /Issue lifecycle: queue, close, abort, delete/);
|
|
647
|
+
for (const action of ["Remove from queue", "Close issue", "Abort workflow", "Delete from Dealer"]) {
|
|
648
|
+
assert.ok(doc.includes(action), `lifecycle docs must name ${action}`);
|
|
649
|
+
}
|
|
650
|
+
assert.match(doc, /Linear ticket.*(unchanged|not be deleted|will not be deleted)/);
|
|
651
|
+
assert.ok(!doc.includes("there is no hard-delete path"), "stale no-hard-delete claim must be gone");
|
|
652
|
+
});
|
|
653
|
+
test("DELETE closes over queue history: admitted/removed rows for the issue go too", async () => {
|
|
654
|
+
const app = await buildApp();
|
|
655
|
+
const id = await createIssue(app);
|
|
656
|
+
// Cycle the queue entry so non-live history rows exist alongside the live one.
|
|
657
|
+
getDb().prepare("UPDATE queue_entries SET state = 'admitted' WHERE issue_id = ?").run(id);
|
|
658
|
+
enqueueIssue(id);
|
|
659
|
+
const before = getDb().prepare("SELECT COUNT(*) AS n FROM queue_entries WHERE issue_id = ?").get(id);
|
|
660
|
+
assert.ok(before.n >= 2);
|
|
661
|
+
const del = await app.inject({ method: "DELETE", url: `/api/issues/${id}` });
|
|
662
|
+
assert.equal(del.statusCode, 200, del.body);
|
|
663
|
+
const after = getDb().prepare("SELECT COUNT(*) AS n FROM queue_entries WHERE issue_id = ?").get(id);
|
|
664
|
+
assert.equal(after.n, 0);
|
|
665
|
+
await app.close();
|
|
666
|
+
});
|