agent-dealer 1.0.1 → 1.0.2

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 (27) hide show
  1. package/bundle/server/dist/adapters/linear-graphql.js +165 -0
  2. package/bundle/server/dist/adapters/linear-graphql.test.js +129 -0
  3. package/bundle/server/dist/adapters/linear-inbox.js +12 -21
  4. package/bundle/server/dist/adapters/linear-sync.js +6 -19
  5. package/bundle/server/dist/coordinator/dependencies.js +5 -1
  6. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +33 -1
  7. package/bundle/server/dist/db/index.js +6 -0
  8. package/bundle/server/dist/direct-start-interrupt-probe.js +58 -0
  9. package/bundle/server/dist/direct-start-liveness.integration.test.js +114 -24
  10. package/bundle/server/dist/direct-start-temp-home-cleanup.js +124 -0
  11. package/bundle/server/dist/direct-start-temp-home-cleanup.test.js +92 -0
  12. package/bundle/server/dist/repository/queue-entries.js +49 -11
  13. package/bundle/server/dist/routes/queue-reorder.integration.test.js +144 -0
  14. package/bundle/server/dist/routes/queue.js +21 -2
  15. package/bundle/server/package.json +2 -2
  16. package/bundle/server/static-ui/assets/index-0kT1vk6L.js +60 -0
  17. package/bundle/server/static-ui/assets/{index-BxTx4b1d.css → index-hXICi1rX.css} +1 -1
  18. package/bundle/server/static-ui/index.html +2 -2
  19. package/bundle/shared/dist/queue-entries.d.ts +47 -0
  20. package/bundle/shared/dist/queue-entries.js +14 -0
  21. package/bundle/shared/package.json +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/queue.d.ts +5 -0
  24. package/dist/queue.js +50 -0
  25. package/dist/queue.test.js +39 -0
  26. package/package.json +1 -1
  27. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +0 -60
@@ -15,12 +15,17 @@ import path from "node:path";
15
15
  import net from "node:net";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { resolveTsxBin } from "./resolve-tsx-bin.js";
18
+ import { DirectStartLiveCleanup, sweepAbandonedDirectStartHomes, } from "./direct-start-temp-home-cleanup.js";
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const repoRoot = path.resolve(__dirname, "..", "..", "..");
20
21
  const serverEntry = path.join(repoRoot, "packages", "server", "src", "index.ts");
21
22
  const tsxBin = resolveTsxBin(repoRoot);
22
23
  const { isServiceRunning, runMigration } = await import("./db/migrate-to-issues.js");
23
24
  const Database = (await import("better-sqlite3")).default;
25
+ // One-time sweep of abandoned dealer-direct-start-* homes left by earlier interrupted
26
+ // runs. Live-pid + recent-mtime guards keep this from deleting out from under a run
27
+ // that is still in flight on this machine (NOT-140).
28
+ sweepAbandonedDirectStartHomes(os.tmpdir());
24
29
  async function getEphemeralPort() {
25
30
  return new Promise((resolve, reject) => {
26
31
  const srv = net.createServer();
@@ -47,35 +52,40 @@ async function waitUntil(check, timeoutMs, stepMs = 150) {
47
52
  await new Promise((r) => setTimeout(r, stepMs));
48
53
  }
49
54
  }
55
+ // Every server spawned by this file that stopServer() has not yet confirmed dead, plus
56
+ // every temp home makeHome() still owns. A detached child sits in its own session, so it
57
+ // no longer receives the terminal's SIGINT along with the test runner — the usual
58
+ // "Ctrl-C reaps the whole foreground group" safety net does not cover these, and an
59
+ // interrupted run would leak exactly what this file was leaking before. This registry is
60
+ // that net, re-implemented explicitly — servers and homes share one reapAll so
61
+ // process.exit(1) cannot clear one and abandon the other (NOT-140).
62
+ const liveCleanup = new DirectStartLiveCleanup();
63
+ function reapAllLiveCleanup() {
64
+ liveCleanup.reapAll(signalGroup);
65
+ }
66
+ process.on("exit", reapAllLiveCleanup);
67
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
68
+ // Registering a listener suppresses the default terminate, so exit explicitly.
69
+ process.on(signal, () => {
70
+ reapAllLiveCleanup();
71
+ process.exit(1);
72
+ });
73
+ }
50
74
  /** A temp AGENT_DEALER_HOME that is deleted when the test ends, however it ends. Registered
51
75
  * as a test hook rather than dropped in a `finally` on purpose: teardown here asserts (that
52
76
  * the server it stopped is really dead), and an assertion that throws must not be able to
53
77
  * skip the cleanup and leave a directory behind — which is precisely how $TMPDIR filled up
54
78
  * with hundreds of these. Runs after the body's own `finally`, so servers are stopped by
55
- * then. */
79
+ * then. The interrupt path (SIGINT/SIGTERM/SIGHUP → process.exit) skips t.after, so the
80
+ * home is also tracked on `liveCleanup` and reaped there (NOT-140). */
56
81
  function makeHome(t, prefix) {
57
82
  const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
58
- t.after(() => fs.rmSync(home, { recursive: true, force: true }));
59
- return home;
60
- }
61
- // Every server spawned by this file that stopServer() has not yet confirmed dead. A
62
- // detached child sits in its own session, so it no longer receives the terminal's SIGINT
63
- // along with the test runner — the usual "Ctrl-C reaps the whole foreground group" safety
64
- // net does not cover these, and an interrupted run would leak exactly what this file was
65
- // leaking before. This registry is that net, re-implemented explicitly.
66
- const liveServers = new Set();
67
- function killAllServerGroups() {
68
- for (const child of liveServers)
69
- signalGroup(child, "SIGKILL");
70
- liveServers.clear();
71
- }
72
- process.on("exit", killAllServerGroups);
73
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
74
- // Registering a listener suppresses the default terminate, so exit explicitly.
75
- process.on(signal, () => {
76
- killAllServerGroups();
77
- process.exit(1);
83
+ liveCleanup.trackHome(home);
84
+ t.after(() => {
85
+ liveCleanup.untrackHome(home);
86
+ fs.rmSync(home, { recursive: true, force: true });
78
87
  });
88
+ return home;
79
89
  }
80
90
  function spawnServer(home, port) {
81
91
  const child = spawn(tsxBin, [serverEntry], {
@@ -94,10 +104,10 @@ function spawnServer(home, port) {
94
104
  // Every test awaits its own teardown, so this handle is never what keeps the run
95
105
  // correct — but while it is ref'd it *does* keep the runner's event loop alive, so a
96
106
  // teardown that throws before stopping a server wedges the whole file at 100% "waiting
97
- // for a child that will never exit" instead of failing. unref() + killAllServerGroups()
107
+ // for a child that will never exit" instead of failing. unref() + reapAllLiveCleanup()
98
108
  // on exit is the pair that makes the failure path terminate and still reap.
99
109
  child.unref();
100
- liveServers.add(child);
110
+ liveCleanup.addServer(child);
101
111
  return child;
102
112
  }
103
113
  function hasExited(child) {
@@ -182,7 +192,7 @@ async function stopServer(child, home) {
182
192
  if (recordedPid !== undefined) {
183
193
  assert.equal(isPidAlive(recordedPid), false, `the server pid recorded in server.pid (${recordedPid}) survived teardown — the tsx launcher (${child.pid}) was reaped but its server child leaked`);
184
194
  }
185
- liveServers.delete(child);
195
+ liveCleanup.removeServer(child);
186
196
  return { exitedOnTerm, recordedPid };
187
197
  }
188
198
  async function waitForHealth(port, timeoutMs) {
@@ -348,3 +358,83 @@ test("a real running server blocks the migration end to end, and the migration s
348
358
  await stopServer(server, home);
349
359
  }
350
360
  });
361
+ function listOrphanIndexTsPids() {
362
+ try {
363
+ const out = execFileSync("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" });
364
+ const orphans = [];
365
+ for (const line of out.split("\n")) {
366
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
367
+ if (!match)
368
+ continue;
369
+ const pid = Number(match[1]);
370
+ const ppid = Number(match[2]);
371
+ const command = match[3];
372
+ if (ppid === 1 && command.includes("packages/server/src/index.ts"))
373
+ orphans.push(pid);
374
+ }
375
+ return orphans;
376
+ }
377
+ catch {
378
+ return [];
379
+ }
380
+ }
381
+ test("SIGINT mid-run leaves zero dealer-direct-start homes from that run and no PPID=1 index.ts orphans", { timeout: 60000 }, async () => {
382
+ // Acceptance for NOT-140: the probe mirrors this file's signal handler (reapAll then
383
+ // process.exit(1)). process.exit skips t.after, so only the shared liveCleanup registry
384
+ // can remove the temp home — and it must still reap the detached server group.
385
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-direct-start-sigint-"));
386
+ const readyFile = path.join(home, "ready.json");
387
+ const port = await getEphemeralPort();
388
+ const probe = path.join(__dirname, "direct-start-interrupt-probe.ts");
389
+ const orphansBefore = new Set(listOrphanIndexTsPids());
390
+ const probeChild = spawn(tsxBin, [probe, home, readyFile, serverEntry, String(port)], {
391
+ cwd: repoRoot,
392
+ stdio: "ignore",
393
+ });
394
+ try {
395
+ const ready = await waitUntil(() => fs.existsSync(readyFile), 15000);
396
+ assert.ok(ready, "interrupt probe should write its ready file");
397
+ const healthy = await waitForHealth(port, 20000);
398
+ assert.ok(healthy, "interrupt probe's server should become healthy before SIGINT");
399
+ probeChild.kill("SIGINT");
400
+ const exited = await waitUntil(() => probeChild.exitCode !== null || probeChild.signalCode !== null, 15000);
401
+ assert.ok(exited, "interrupt probe should exit after SIGINT");
402
+ assert.equal(fs.existsSync(home), false, "SIGINT must rmSync the tracked temp home — t.after never runs after process.exit(1)");
403
+ const orphansAfter = listOrphanIndexTsPids().filter((pid) => !orphansBefore.has(pid));
404
+ assert.deepEqual(orphansAfter, [], `SIGINT must not leave packages/server/src/index.ts reparented to init; new orphans: ${orphansAfter.join(",")}`);
405
+ }
406
+ finally {
407
+ // SIGKILL skips the probe's exit handler, so a stuck probe would leave its
408
+ // detached server group alive (often reparented to init). Prefer SIGINT so
409
+ // reapAll runs; only then SIGKILL the process group ourselves as a backstop.
410
+ if (probeChild.exitCode === null && probeChild.signalCode === null) {
411
+ probeChild.kill("SIGINT");
412
+ await waitUntil(() => probeChild.exitCode !== null || probeChild.signalCode !== null, 5000);
413
+ }
414
+ if (probeChild.exitCode === null && probeChild.signalCode === null) {
415
+ let launcherPid;
416
+ try {
417
+ launcherPid = JSON.parse(fs.readFileSync(readyFile, "utf8")).serverLauncherPid;
418
+ }
419
+ catch {
420
+ // ready file may be missing if the probe died before writing it
421
+ }
422
+ if (typeof launcherPid === "number" && Number.isFinite(launcherPid)) {
423
+ try {
424
+ process.kill(-launcherPid, "SIGKILL");
425
+ }
426
+ catch {
427
+ // ESRCH — group already gone
428
+ }
429
+ }
430
+ probeChild.kill("SIGKILL");
431
+ }
432
+ // If the probe failed before reaping, do not leave debris behind for the suite.
433
+ try {
434
+ fs.rmSync(home, { recursive: true, force: true });
435
+ }
436
+ catch {
437
+ // already gone on the success path
438
+ }
439
+ }
440
+ });
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const DIRECT_START_HOME_PREFIX = "dealer-direct-start-";
4
+ /** Homes modified more recently than this are assumed to belong to a run in flight. */
5
+ export const DEFAULT_RECENT_MS = 60_000;
6
+ export function isPidAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ export function readRecordedPid(home) {
16
+ try {
17
+ const recorded = JSON.parse(fs.readFileSync(path.join(home, "server.pid"), "utf8"))
18
+ .pid;
19
+ return typeof recorded === "number" && Number.isFinite(recorded) ? recorded : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * One registry for the interrupt path: every server spawnServer() still owns, and
27
+ * every temp home makeHome() still owns. reapAll() is the single net used by both
28
+ * process.on("exit") and the SIGINT/SIGTERM/SIGHUP handlers.
29
+ */
30
+ export class DirectStartLiveCleanup {
31
+ servers = new Set();
32
+ homes = new Set();
33
+ trackHome(home) {
34
+ this.homes.add(home);
35
+ }
36
+ untrackHome(home) {
37
+ this.homes.delete(home);
38
+ }
39
+ addServer(child) {
40
+ this.servers.add(child);
41
+ }
42
+ removeServer(child) {
43
+ this.servers.delete(child);
44
+ }
45
+ /**
46
+ * Kill every tracked server group, then rmSync every tracked home.
47
+ * Order matters: never delete a home out from under a still-live server.
48
+ */
49
+ reapAll(signalGroup) {
50
+ for (const child of this.servers)
51
+ signalGroup(child, "SIGKILL");
52
+ this.servers.clear();
53
+ for (const home of this.homes) {
54
+ try {
55
+ fs.rmSync(home, { recursive: true, force: true });
56
+ }
57
+ catch {
58
+ // Best-effort on the interrupt path — do not throw out of a signal handler.
59
+ }
60
+ }
61
+ this.homes.clear();
62
+ }
63
+ }
64
+ export function shouldSweepHome(home, opts) {
65
+ let mtimeMs;
66
+ try {
67
+ mtimeMs = fs.statSync(home).mtimeMs;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ if (opts.now - mtimeMs < opts.recentMs)
73
+ return false;
74
+ const pid = readRecordedPid(home);
75
+ if (pid !== undefined && opts.isPidAlive(pid))
76
+ return false;
77
+ return true;
78
+ }
79
+ /**
80
+ * One-time (or on-load) sweep of abandoned `$TMPDIR/dealer-direct-start-*` homes.
81
+ * Skips any home whose recorded server.pid is still alive, and any home modified
82
+ * within `recentMs`, so a concurrent in-flight run is never deleted out from under.
83
+ */
84
+ export function sweepAbandonedDirectStartHomes(tmpDir, opts) {
85
+ const now = opts?.now ?? Date.now();
86
+ const recentMs = opts?.recentMs ?? DEFAULT_RECENT_MS;
87
+ const alive = opts?.isPidAlive ?? isPidAlive;
88
+ const prefix = opts?.prefix ?? DIRECT_START_HOME_PREFIX;
89
+ const removed = [];
90
+ const skipped = [];
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(tmpDir);
94
+ }
95
+ catch {
96
+ return { removed, skipped };
97
+ }
98
+ for (const name of entries) {
99
+ if (!name.startsWith(prefix))
100
+ continue;
101
+ const home = path.join(tmpDir, name);
102
+ let st;
103
+ try {
104
+ st = fs.statSync(home);
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ if (!st.isDirectory())
110
+ continue;
111
+ if (!shouldSweepHome(home, { now, recentMs, isPidAlive: alive })) {
112
+ skipped.push(home);
113
+ continue;
114
+ }
115
+ try {
116
+ fs.rmSync(home, { recursive: true, force: true });
117
+ removed.push(home);
118
+ }
119
+ catch {
120
+ skipped.push(home);
121
+ }
122
+ }
123
+ return { removed, skipped };
124
+ }
@@ -0,0 +1,92 @@
1
+ // packages/server/src/direct-start-temp-home-cleanup.test.ts
2
+ //
3
+ // NOT-140: interrupt path must reap temp AGENT_DEALER_HOME dirs (process.exit skips
4
+ // t.after), and a guarded one-time sweep must clear abandoned dealer-direct-start-* debris.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { DirectStartLiveCleanup, DEFAULT_RECENT_MS, sweepAbandonedDirectStartHomes, } from "./direct-start-temp-home-cleanup.js";
11
+ function makeStaleHome(tmpDir, name, ageMs) {
12
+ const home = path.join(tmpDir, name);
13
+ fs.mkdirSync(home);
14
+ const when = new Date(Date.now() - ageMs);
15
+ fs.utimesSync(home, when, when);
16
+ return home;
17
+ }
18
+ test("DirectStartLiveCleanup.reapAll removes tracked homes after signalling servers", () => {
19
+ const cleanup = new DirectStartLiveCleanup();
20
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-direct-start-reap-"));
21
+ cleanup.trackHome(home);
22
+ fs.writeFileSync(path.join(home, "marker"), "alive");
23
+ const signalled = [];
24
+ cleanup.reapAll((child, signal) => {
25
+ signalled.push({ child, signal });
26
+ });
27
+ assert.equal(fs.existsSync(home), false, "tracked home must be rmSync'd on reap");
28
+ assert.equal(cleanup.homes.size, 0);
29
+ assert.equal(cleanup.servers.size, 0);
30
+ assert.deepEqual(signalled, []);
31
+ });
32
+ test("sweepAbandonedDirectStartHomes removes stale homes without a live server.pid", () => {
33
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
34
+ const stale = path.join(tmpDir, "dealer-direct-start-stale");
35
+ fs.mkdirSync(stale);
36
+ fs.writeFileSync(path.join(stale, "dealer.db"), "");
37
+ // utimes after writes — creating children bumps the directory mtime.
38
+ const when = new Date(Date.now() - (DEFAULT_RECENT_MS + 5_000));
39
+ fs.utimesSync(stale, when, when);
40
+ const result = sweepAbandonedDirectStartHomes(tmpDir, {
41
+ now: Date.now(),
42
+ recentMs: DEFAULT_RECENT_MS,
43
+ isPidAlive: () => false,
44
+ });
45
+ assert.equal(fs.existsSync(stale), false);
46
+ assert.deepEqual(result.removed, [stale]);
47
+ assert.deepEqual(result.skipped, []);
48
+ fs.rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+ test("sweepAbandonedDirectStartHomes skips a home whose server.pid names a live process", () => {
51
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
52
+ const liveHome = path.join(tmpDir, "dealer-direct-start-live");
53
+ fs.mkdirSync(liveHome);
54
+ fs.writeFileSync(path.join(liveHome, "server.pid"), JSON.stringify({ pid: 424242 }));
55
+ const when = new Date(Date.now() - (DEFAULT_RECENT_MS + 5_000));
56
+ fs.utimesSync(liveHome, when, when);
57
+ const result = sweepAbandonedDirectStartHomes(tmpDir, {
58
+ now: Date.now(),
59
+ recentMs: DEFAULT_RECENT_MS,
60
+ isPidAlive: (pid) => pid === 424242,
61
+ });
62
+ assert.equal(fs.existsSync(liveHome), true, "must not delete out from under a live server");
63
+ assert.deepEqual(result.removed, []);
64
+ assert.deepEqual(result.skipped, [liveHome]);
65
+ fs.rmSync(tmpDir, { recursive: true, force: true });
66
+ });
67
+ test("sweepAbandonedDirectStartHomes skips a recently-modified home", () => {
68
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
69
+ const fresh = makeStaleHome(tmpDir, "dealer-direct-start-fresh", 1_000);
70
+ const result = sweepAbandonedDirectStartHomes(tmpDir, {
71
+ now: Date.now(),
72
+ recentMs: DEFAULT_RECENT_MS,
73
+ isPidAlive: () => false,
74
+ });
75
+ assert.equal(fs.existsSync(fresh), true, "must not delete a home still in use by a run in flight");
76
+ assert.deepEqual(result.removed, []);
77
+ assert.deepEqual(result.skipped, [fresh]);
78
+ fs.rmSync(tmpDir, { recursive: true, force: true });
79
+ });
80
+ test("sweepAbandonedDirectStartHomes ignores directories that do not use the dealer-direct-start- prefix", () => {
81
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-sweep-root-"));
82
+ const other = makeStaleHome(tmpDir, "dealer-paths-unrelated", DEFAULT_RECENT_MS + 5_000);
83
+ const result = sweepAbandonedDirectStartHomes(tmpDir, {
84
+ now: Date.now(),
85
+ recentMs: DEFAULT_RECENT_MS,
86
+ isPidAlive: () => false,
87
+ });
88
+ assert.equal(fs.existsSync(other), true);
89
+ assert.deepEqual(result.removed, []);
90
+ assert.deepEqual(result.skipped, []);
91
+ fs.rmSync(tmpDir, { recursive: true, force: true });
92
+ });
@@ -94,24 +94,62 @@ export function enqueueIssueWithOutcome(issueId) {
94
94
  return { entry: getQueueEntry(id), created: true };
95
95
  }
96
96
  /**
97
- * NOT-118: move a queued entry to position 1 and renumber the rest 2..N in their current
98
- * order, so `position` stays a readable 1-based rank instead of drifting into negatives.
99
- * This is the only reorder primitive in the repo today — NOT-112's general reorder reuses
100
- * it rather than adding a second way to renumber the queue.
97
+ * Single position-computation path: write 1-based ranks for the given order.
98
+ * Callers must already hold a DB transaction when they need atomic reorder + lookup.
101
99
  */
102
- export function moveQueueEntryToTop(issueId) {
100
+ function applyQueuedOrder(ordered) {
101
+ const setPosition = getDb().prepare("UPDATE queue_entries SET position = ? WHERE id = ?");
102
+ ordered.forEach((entry, index) => setPosition.run(index + 1, entry.id));
103
+ }
104
+ function buildOrderAfterMove(entry, without, to) {
105
+ if (to === "top")
106
+ return [entry, ...without];
107
+ if (to === "bottom")
108
+ return [...without, entry];
109
+ if ("before" in to) {
110
+ const idx = without.findIndex((e) => e.issueId === to.before);
111
+ if (idx < 0) {
112
+ throw Object.assign(new Error("Reference issue is not in the queue"), { code: 409 });
113
+ }
114
+ return [...without.slice(0, idx), entry, ...without.slice(idx)];
115
+ }
116
+ const idx = without.findIndex((e) => e.issueId === to.after);
117
+ if (idx < 0) {
118
+ throw Object.assign(new Error("Reference issue is not in the queue"), { code: 409 });
119
+ }
120
+ return [...without.slice(0, idx + 1), entry, ...without.slice(idx + 1)];
121
+ }
122
+ /**
123
+ * NOT-112: move a queued entry relative to the live queue (top / bottom / before / after).
124
+ * Only `queued` entries are orderable. Throws `{ code: 404 }` when the issue is not queued
125
+ * (missing or already admitted) and `{ code: 409 }` when a relative reference is gone.
126
+ */
127
+ export function moveQueueEntry(issueId, to) {
103
128
  const db = getDb();
104
129
  return db.transaction(() => {
105
130
  const entry = getQueuedEntryForIssue(issueId);
106
- if (!entry)
107
- return null;
108
- const rest = listQueuedEntries().filter((e) => e.issueId !== issueId);
109
- const setPosition = db.prepare("UPDATE queue_entries SET position = ? WHERE id = ?");
110
- setPosition.run(1, entry.id);
111
- rest.forEach((e, index) => setPosition.run(index + 2, e.id));
131
+ if (!entry) {
132
+ throw Object.assign(new Error("Issue is not in the queue"), { code: 404 });
133
+ }
134
+ const without = listQueuedEntries().filter((e) => e.issueId !== issueId);
135
+ applyQueuedOrder(buildOrderAfterMove(entry, without, to));
112
136
  return getQueueEntry(entry.id);
113
137
  })();
114
138
  }
139
+ /**
140
+ * NOT-118 Start: move to front. Thin wrapper over the shared renumber path — returns null
141
+ * when the issue is not queued (Start's enqueue step normally prevents that).
142
+ */
143
+ export function moveQueueEntryToTop(issueId) {
144
+ try {
145
+ return moveQueueEntry(issueId, "top");
146
+ }
147
+ catch (err) {
148
+ if (err.code === 404)
149
+ return null;
150
+ throw err;
151
+ }
152
+ }
115
153
  /** Operator dequeue — marks removed (keeps history row). Returns the removed entry or null. */
116
154
  export function dequeueIssue(issueId) {
117
155
  const entry = getQueuedEntryForIssue(issueId);
@@ -0,0 +1,144 @@
1
+ // packages/server/src/routes/queue-reorder.integration.test.ts
2
+ //
3
+ // NOT-112 acceptance: operator can reorder queued issues via relative moves; admission
4
+ // respects the new order; moving a missing/admitted entry fails clearly without corrupting
5
+ // the remaining order.
6
+ import { test, before, beforeEach, after } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import Fastify from "fastify";
12
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not112-"));
13
+ const { migrate, getDb } = await import("../db/index.js");
14
+ const { createAgent } = await import("../repository/agents.js");
15
+ const { getIssue } = await import("../repository/issues.js");
16
+ const { listQueuedEntries, getQueuedEntryForIssue, markQueueEntryAdmitted } = await import("../repository/queue-entries.js");
17
+ const { registerIssueRoutes } = await import("./issues.js");
18
+ const { registerQueueRoutes } = await import("./queue.js");
19
+ const { admitNext, setAdmissionHealthCheckerForTests, resetCapacityPolicyForTests, resetEligibilityRulesForTests, } = await import("../coordinator/admission.js");
20
+ const { setBlockersProviderForTests, resetDependenciesForTests } = await import("../coordinator/dependencies.js");
21
+ before(() => migrate());
22
+ beforeEach(() => {
23
+ getDb().exec(`
24
+ DELETE FROM work_items;
25
+ DELETE FROM human_actions;
26
+ DELETE FROM workflow_events;
27
+ DELETE FROM worker_sessions;
28
+ DELETE FROM artifacts;
29
+ DELETE FROM workflow_instances;
30
+ DELETE FROM queue_entries;
31
+ DELETE FROM issues;
32
+ `);
33
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
34
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [i.externalId, []])));
35
+ resetCapacityPolicyForTests();
36
+ resetEligibilityRulesForTests();
37
+ });
38
+ after(() => {
39
+ setAdmissionHealthCheckerForTests(null);
40
+ resetDependenciesForTests();
41
+ });
42
+ async function buildApp() {
43
+ const app = Fastify();
44
+ await registerIssueRoutes(app);
45
+ await registerQueueRoutes(app);
46
+ return app;
47
+ }
48
+ const repo = "acme/not112";
49
+ const dev = createAgent({
50
+ name: `not112-dev-${Math.random()}`,
51
+ runtime: "claude_code",
52
+ deckId: "00000000-0000-4000-a000-000000000112",
53
+ });
54
+ const rev = createAgent({
55
+ name: `not112-rev-${Math.random()}`,
56
+ runtime: "claude_code",
57
+ deckId: "00000000-0000-4000-a000-000000000112",
58
+ });
59
+ async function createIssueViaApi(app, title) {
60
+ const res = await app.inject({
61
+ method: "POST",
62
+ url: "/api/issues",
63
+ payload: {
64
+ title,
65
+ repo,
66
+ baseBranch: "main",
67
+ developerAgentId: dev.id,
68
+ reviewerAgentId: rev.id,
69
+ acceptanceCriteria: "It works",
70
+ },
71
+ });
72
+ assert.equal(res.statusCode, 200);
73
+ return res.json().id;
74
+ }
75
+ async function moveViaApi(app, issueId, to) {
76
+ return app.inject({
77
+ method: "POST",
78
+ url: `/api/queue/${issueId}/move`,
79
+ payload: { to },
80
+ });
81
+ }
82
+ function queuedIds() {
83
+ return listQueuedEntries().map((e) => e.issueId);
84
+ }
85
+ test("POST /api/queue/:id/move supports top, bottom, before, and after", async () => {
86
+ const app = await buildApp();
87
+ const a = await createIssueViaApi(app, "A");
88
+ const b = await createIssueViaApi(app, "B");
89
+ const c = await createIssueViaApi(app, "C");
90
+ assert.deepEqual(queuedIds(), [a, b, c]);
91
+ let res = await moveViaApi(app, c, "top");
92
+ assert.equal(res.statusCode, 200);
93
+ assert.equal(res.json().issueId, c);
94
+ assert.equal(res.json().position, 1);
95
+ assert.deepEqual(queuedIds(), [c, a, b]);
96
+ res = await moveViaApi(app, c, "bottom");
97
+ assert.equal(res.statusCode, 200);
98
+ assert.deepEqual(queuedIds(), [a, b, c]);
99
+ res = await moveViaApi(app, c, { before: a });
100
+ assert.equal(res.statusCode, 200);
101
+ assert.deepEqual(queuedIds(), [c, a, b]);
102
+ res = await moveViaApi(app, c, { after: a });
103
+ assert.equal(res.statusCode, 200);
104
+ assert.deepEqual(queuedIds(), [a, c, b]);
105
+ await app.close();
106
+ });
107
+ test("next admission respects the reordered queue", async () => {
108
+ const app = await buildApp();
109
+ const a = await createIssueViaApi(app, "First");
110
+ const b = await createIssueViaApi(app, "Second");
111
+ const c = await createIssueViaApi(app, "Urgent");
112
+ assert.deepEqual(queuedIds(), [a, b, c]);
113
+ const moved = await moveViaApi(app, c, "top");
114
+ assert.equal(moved.statusCode, 200);
115
+ assert.deepEqual(queuedIds(), [c, a, b]);
116
+ const admitted = await admitNext();
117
+ assert.equal(admitted?.issueId, c);
118
+ assert.equal(getIssue(c).status, "developing");
119
+ assert.equal(getQueuedEntryForIssue(c), null);
120
+ assert.deepEqual(queuedIds(), [a, b]);
121
+ await app.close();
122
+ });
123
+ test("moving a missing or admitted entry returns a clear error and leaves order unchanged", async () => {
124
+ const app = await buildApp();
125
+ const a = await createIssueViaApi(app, "A");
126
+ const b = await createIssueViaApi(app, "B");
127
+ const c = await createIssueViaApi(app, "C");
128
+ assert.deepEqual(queuedIds(), [a, b, c]);
129
+ const missing = await moveViaApi(app, "00000000-0000-4000-a000-000000000099", "top");
130
+ assert.equal(missing.statusCode, 404);
131
+ assert.match(String(missing.json().error ?? ""), /not in the queue|not found/i);
132
+ assert.deepEqual(queuedIds(), [a, b, c]);
133
+ markQueueEntryAdmitted(c);
134
+ assert.equal(getQueuedEntryForIssue(c), null);
135
+ const admittedMove = await moveViaApi(app, c, "top");
136
+ assert.ok(admittedMove.statusCode === 404 || admittedMove.statusCode === 409);
137
+ assert.match(String(admittedMove.json().error ?? ""), /not in the queue|admitted/i);
138
+ assert.deepEqual(queuedIds(), [a, b]);
139
+ const relativeGone = await moveViaApi(app, a, { before: c });
140
+ assert.equal(relativeGone.statusCode, 409);
141
+ assert.match(String(relativeGone.json().error ?? ""), /not in the queue|reference/i);
142
+ assert.deepEqual(queuedIds(), [a, b]);
143
+ await app.close();
144
+ });
@@ -1,8 +1,9 @@
1
1
  // packages/server/src/routes/queue.ts
2
2
  //
3
3
  // NOT-103: operator enqueue / dequeue / list for the sequential admission queue.
4
- import { EnqueueIssueInput } from "@agent-dealer/shared";
5
- import { dequeueIssue, enqueueIssue, getQueuedEntryForIssue, } from "../repository/queue-entries.js";
4
+ // NOT-112: relative reorder (top / bottom / before / after).
5
+ import { EnqueueIssueInput, MoveQueueEntryInput } from "@agent-dealer/shared";
6
+ import { dequeueIssue, enqueueIssue, getQueuedEntryForIssue, moveQueueEntry, } from "../repository/queue-entries.js";
6
7
  import { listQueuedEntriesForRead } from "../coordinator/admission.js";
7
8
  export async function registerQueueRoutes(app) {
8
9
  /** NOT-118: positions are 1-based ranks and a full system derives its own
@@ -28,6 +29,24 @@ export async function registerQueueRoutes(app) {
28
29
  throw err;
29
30
  }
30
31
  });
32
+ app.post("/api/queue/:issueId/move", async (req, reply) => {
33
+ const { issueId } = req.params;
34
+ const parsed = MoveQueueEntryInput.safeParse(req.body);
35
+ if (!parsed.success)
36
+ return reply.status(400).send({ error: parsed.error.message });
37
+ try {
38
+ return moveQueueEntry(issueId, parsed.data.to);
39
+ }
40
+ catch (err) {
41
+ const code = err.code;
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ if (code === 404)
44
+ return reply.status(404).send({ error: message });
45
+ if (code === 409)
46
+ return reply.status(409).send({ error: message });
47
+ throw err;
48
+ }
49
+ });
31
50
  app.delete("/api/queue/:issueId", async (req, reply) => {
32
51
  const { issueId } = req.params;
33
52
  if (!getQueuedEntryForIssue(issueId)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.0.1",
25
+ "@agent-dealer/shared": "1.0.2",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",