@ianwremmel/dispatch 0.32.1-bootstrap.0

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 (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,359 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {assertInstant} from '../db/time.mts';
3
+ import {DataError, ensure} from '../errors/index.mts';
4
+ import {isOutcome, OUTCOMES} from '../model/status.mts';
5
+ import type {OutcomeKind} from '../model/status.mts';
6
+ import type {Outcome} from '../model/types.mts';
7
+ import {findNode} from './materialize.mts';
8
+
9
+ /* eslint-disable @typescript-eslint/require-await --
10
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
11
+
12
+ export interface ClaimResult {
13
+ outcome: 'claimed' | 'refreshed' | 'held' | 'full' | 'unknown-node';
14
+ /** The session that holds it when the outcome is `held`. */
15
+ heldBy?: string;
16
+ }
17
+
18
+ /**
19
+ * The runtime coordination a live unit holds and reports: claims and outcomes
20
+ * (final reports). Grouped because they are transactionally linked — recording
21
+ * an outcome releases the reporter's claim in the same write.
22
+ *
23
+ * The claim is also the compute grant: a worker is launched by the work order
24
+ * that claims its node, and the claim is what the admission budget counts. That
25
+ * makes the claim the machine-wide compute bound, so `claim` enforces the cap
26
+ * inside its own transaction rather than trusting a caller's earlier count —
27
+ * two servers scheduling at once both read the same free capacity, and only the
28
+ * transaction can decide which of them gets it.
29
+ */
30
+ export class CoordinationStore {
31
+ readonly #db: Database;
32
+
33
+ constructor(db: Database) {
34
+ this.#db = db;
35
+ }
36
+
37
+ async claim(input: {
38
+ node: string;
39
+ session: string;
40
+ actor?: string;
41
+ worktree?: string;
42
+ branch?: string;
43
+ claimedAt: string;
44
+ /**
45
+ * Bound on live claims across every session sharing this database — the
46
+ * host's compute, not one session's share of it. A fresh claim past `max` is refused
47
+ * as `full`; refreshing a claim this session already holds never is, so a
48
+ * cap lowered under running work does not strand it. Omit to claim
49
+ * unbounded. Both fields travel together because a live-claim count is
50
+ * meaningless without the staleness window that defines "live".
51
+ */
52
+ capacity?: {max: number; staleAfterSeconds: number} | undefined;
53
+ }): Promise<ClaimResult> {
54
+ assertInstant(input.claimedAt, 'claimedAt');
55
+ return this.#db.transaction(() => {
56
+ const node = findNode(this.#db, input.node);
57
+ if (node === null) return {outcome: 'unknown-node'};
58
+ const existing = this.#db.get(
59
+ 'SELECT session_id FROM claim WHERE node_id = ?',
60
+ [node.id]
61
+ );
62
+ if (existing !== undefined && existing.session_id !== input.session) {
63
+ return {outcome: 'held', heldBy: String(existing.session_id)};
64
+ }
65
+ if (existing === undefined && input.capacity !== undefined) {
66
+ const live = Number(
67
+ this.#db.get(
68
+ `SELECT COUNT(*) AS n
69
+ FROM claim c
70
+ JOIN session s ON s.id = c.session_id
71
+ WHERE unixepoch(?) - unixepoch(s.heartbeat_at) <= ?`,
72
+ [input.claimedAt, input.capacity.staleAfterSeconds]
73
+ )?.n ?? 0
74
+ );
75
+ if (live >= input.capacity.max) return {outcome: 'full'};
76
+ }
77
+ this.#db.run(
78
+ `INSERT INTO claim (node_id, session_id, actor, worktree, branch, claimed_at)
79
+ VALUES (?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(node_id) DO UPDATE SET
81
+ session_id = excluded.session_id, actor = excluded.actor,
82
+ worktree = excluded.worktree, branch = excluded.branch,
83
+ claimed_at = excluded.claimed_at`,
84
+ [
85
+ node.id,
86
+ input.session,
87
+ input.actor ?? null,
88
+ input.worktree ?? null,
89
+ input.branch ?? null,
90
+ input.claimedAt,
91
+ ]
92
+ );
93
+ return {outcome: existing === undefined ? 'claimed' : 'refreshed'};
94
+ });
95
+ }
96
+
97
+ async release(
98
+ node: string,
99
+ session: string
100
+ ): Promise<'released' | 'absent' | 'not-yours'> {
101
+ return this.#db.transaction(() => {
102
+ const row = this.#db.get(
103
+ `SELECT session_id FROM claim
104
+ WHERE node_id = (SELECT id FROM node WHERE external_id = ?)`,
105
+ [node]
106
+ );
107
+ if (row === undefined) return 'absent';
108
+ if (row.session_id !== session) return 'not-yours';
109
+ this.#db.run(
110
+ 'DELETE FROM claim WHERE node_id = (SELECT id FROM node WHERE external_id = ?)',
111
+ [node]
112
+ );
113
+ return 'released';
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Drop a recorded outcome, so the queue re-serves the node as fresh work.
119
+ *
120
+ * The watch a park left running goes with it. It suppresses the node from
121
+ * the queue while it is `watching` — that is what keeps a parked item from
122
+ * being served on a timer — so leaving it behind would make this verb wait
123
+ * on the very PR change the operator is answering out of band.
124
+ *
125
+ * Only the parked watch, though, and a recorded outcome does not identify
126
+ * one: a park that was already revived keeps its outcome until the resumed
127
+ * worker reports, so an outcome can sit alongside that worker's own yield.
128
+ * A live worker is what separates them — a park deletes the worker row, a
129
+ * launch recreates it — so a node that has one is being worked, and
130
+ * dropping its wait, with whatever it has observed and not yet delivered,
131
+ * would strand it. Unparking is then a no-op beyond the outcome: the worker
132
+ * is already on the item and its own watch will wake it.
133
+ *
134
+ * Liveness is judged on the worker's session heartbeat, the same rule the
135
+ * scheduler applies. A worker row outlives the agent it addresses, and a
136
+ * dead one must not hold an operator's unpark until the next stale sweep.
137
+ */
138
+ async removeOutcome(
139
+ node: string,
140
+ liveness: {now: string; staleAfterSeconds: number}
141
+ ): Promise<boolean> {
142
+ assertInstant(liveness.now, 'now');
143
+ return this.#db.transaction(() => {
144
+ const found = findNode(this.#db, node);
145
+ if (found === null) return false;
146
+ const worked =
147
+ this.#db.get(
148
+ `SELECT 1 AS held FROM worker w
149
+ JOIN session s ON s.id = w.session_id
150
+ WHERE w.node_id = ?
151
+ AND unixepoch(?) - unixepoch(s.heartbeat_at) <= ?`,
152
+ [found.id, liveness.now, liveness.staleAfterSeconds]
153
+ ) !== undefined;
154
+ const removed =
155
+ this.#db.run('DELETE FROM outcome WHERE node_id = ?', [found.id]) > 0;
156
+ if (removed && !worked) {
157
+ this.#db.run('DELETE FROM watch WHERE node_id = ?', [found.id]);
158
+ this.#db.run('DELETE FROM pr_event WHERE node_id = ?', [found.id]);
159
+ }
160
+ return removed;
161
+ });
162
+ }
163
+
164
+ /* eslint-disable @typescript-eslint/no-base-to-string --
165
+ * SQLite hands back `unknown`; `String()` converts a primitive rather than
166
+ * asserting a type the row has not been checked for. */
167
+ async claims(): Promise<
168
+ {node: string; session: string; actor: string | null}[]
169
+ > {
170
+ return this.#db
171
+ .all(
172
+ `SELECT n.external_id AS node, c.session_id AS session, c.actor
173
+ FROM claim c JOIN node n ON n.id = c.node_id`
174
+ )
175
+ .map((row) => ({
176
+ node: String(row.node),
177
+ session: String(row.session),
178
+ actor: row.actor === null ? null : String(row.actor),
179
+ }));
180
+ }
181
+ /**
182
+ * The claims `inFlightCount` counts: those whose session still heartbeats.
183
+ * Reporting these alongside that count keeps the two from disagreeing.
184
+ */
185
+ async liveClaims(input: {
186
+ now: string;
187
+ staleAfterSeconds: number;
188
+ }): Promise<{node: string; session: string}[]> {
189
+ assertInstant(input.now, 'now');
190
+ return this.#db
191
+ .all(
192
+ `SELECT n.external_id AS node, c.session_id AS session
193
+ FROM claim c
194
+ JOIN node n ON n.id = c.node_id
195
+ JOIN session s ON s.id = c.session_id
196
+ WHERE unixepoch(?) - unixepoch(s.heartbeat_at) <= ?
197
+ ORDER BY n.external_id`,
198
+ [input.now, input.staleAfterSeconds]
199
+ )
200
+ .map((row) => ({node: String(row.node), session: String(row.session)}));
201
+ }
202
+ /* eslint-enable @typescript-eslint/no-base-to-string */
203
+
204
+ /**
205
+ * Units of work currently in flight: every node held by a live claim. A
206
+ * claim is an obligation to run an agent — created when the scheduler emits
207
+ * the work order, dropped when the agent reports an outcome or hands its
208
+ * wait back to the server — so counting live claims is what bounds how many
209
+ * agents run at once.
210
+ */
211
+ async inFlightCount(input: {
212
+ now: string;
213
+ staleAfterSeconds: number;
214
+ }): Promise<number> {
215
+ assertInstant(input.now, 'now');
216
+ const row = this.#db.get(
217
+ `SELECT COUNT(*) AS n
218
+ FROM claim c
219
+ JOIN session s ON s.id = c.session_id
220
+ WHERE unixepoch(?) - unixepoch(s.heartbeat_at) <= ?`,
221
+ [input.now, input.staleAfterSeconds]
222
+ );
223
+ return Number(row?.n ?? 0);
224
+ }
225
+
226
+ /**
227
+ * Record a unit's final report on a node, releasing its claim in the same
228
+ * transaction — the artifact proves its writer exited. One row per node; a
229
+ * later pass's report replaces it.
230
+ *
231
+ * `requireClaim` makes the report conditional on the reporter still holding
232
+ * the node, checked inside this transaction rather than by the caller: a
233
+ * claim read outside it can be swept, released, or taken by another session
234
+ * before the write lands, and two concurrent reports can both pass a prior
235
+ * read and then overwrite each other.
236
+ *
237
+ * An unheld report is refused because an outcome is a report from work that
238
+ * was dispatched. The worst case for refusing is that the node is served
239
+ * again; the worst case for accepting is a terminal outcome recorded over a
240
+ * live worker, or a ticket that leaves the queue for good.
241
+ */
242
+ async recordOutcome(
243
+ report: {
244
+ node: string;
245
+ outcome: OutcomeKind;
246
+ retryable: boolean | null;
247
+ detail: string | null;
248
+ recordedAt: string;
249
+ },
250
+ holder: {session: string; requireClaim?: boolean}
251
+ ): Promise<void> {
252
+ ensure(
253
+ isOutcome(report.outcome),
254
+ () =>
255
+ new DataError(`"${report.outcome}" is not an outcome`, {
256
+ hint: `use one of: ${OUTCOMES.join(', ')}.`,
257
+ })
258
+ );
259
+ ensure(
260
+ report.retryable === null || report.outcome === 'failed',
261
+ () =>
262
+ new DataError('retryable is meaningful only with outcome "failed"', {
263
+ hint: 'drop retryable, or report the failure as outcome "failed".',
264
+ })
265
+ );
266
+ assertInstant(report.recordedAt, 'recordedAt');
267
+ await this.#db.transaction(() => {
268
+ const node = findNode(this.#db, report.node);
269
+ ensure(
270
+ node !== null,
271
+ () =>
272
+ new DataError(`no node "${report.node}" to record an outcome on`, {
273
+ hint: 'an outcome is recorded on a node the graph already holds.',
274
+ })
275
+ );
276
+ this.#db.run(
277
+ `INSERT INTO outcome (node_id, outcome, retryable, detail, recorded_at)
278
+ VALUES (?, ?, ?, ?, ?)
279
+ ON CONFLICT(node_id) DO UPDATE SET
280
+ outcome = excluded.outcome, retryable = excluded.retryable,
281
+ detail = excluded.detail, recorded_at = excluded.recorded_at`,
282
+ [
283
+ node.id,
284
+ report.outcome,
285
+ report.retryable === null ? null : report.retryable ? 1 : 0,
286
+ report.detail,
287
+ report.recordedAt,
288
+ ]
289
+ );
290
+ if (holder.requireClaim === true) {
291
+ const held = this.#db.get(
292
+ 'SELECT session_id FROM claim WHERE node_id = ?',
293
+ [node.id]
294
+ );
295
+ ensure(
296
+ held?.session_id === holder.session,
297
+ () =>
298
+ new DataError(
299
+ `this session holds no claim on "${report.node}", so it cannot report an outcome for it`,
300
+ {
301
+ hint: 'you were not dispatched for this node, or your claim was already released. Stop without reporting; the node stays dispatchable and the scheduler will serve it again.',
302
+ }
303
+ )
304
+ );
305
+ }
306
+ this.#db.run('DELETE FROM claim WHERE node_id = ? AND session_id = ?', [
307
+ node.id,
308
+ holder.session,
309
+ ]);
310
+ // The report ends the worker's addressability along with its claim: an
311
+ // event for a concluded node has no one to wake.
312
+ this.#db.run('DELETE FROM worker WHERE node_id = ?', [node.id]);
313
+ // A final report ends any server-side wait. The watch would otherwise
314
+ // re-serve work that just concluded.
315
+ //
316
+ // `human-blocked` is the one report that concludes nobody: the wait
317
+ // moved from the worker to the operator, and something still has to
318
+ // notice when they answer on the PR. Keep the row watching, with its
319
+ // snapshot, so the baseline spans the park and the answer reads as a
320
+ // change; unbind it from the departed worker's session so whichever
321
+ // server sees it next may route what it observes.
322
+ if (report.outcome === 'human-blocked') {
323
+ this.#db.run(
324
+ `UPDATE watch SET state = 'watching', session_id = NULL
325
+ WHERE node_id = ?`,
326
+ [node.id]
327
+ );
328
+ } else {
329
+ this.#db.run('DELETE FROM watch WHERE node_id = ?', [node.id]);
330
+ }
331
+ // Undelivered observations describe a wait nobody is in any more,
332
+ // whichever way the report went.
333
+ this.#db.run('DELETE FROM pr_event WHERE node_id = ?', [node.id]);
334
+ });
335
+ }
336
+
337
+ /* eslint-disable @typescript-eslint/no-base-to-string --
338
+ * SQLite hands back `unknown`; `String()` converts a primitive rather than
339
+ * asserting a type the row has not been checked for. */
340
+ async getOutcome(node: string): Promise<Outcome | null> {
341
+ const row = this.#db.get(
342
+ `SELECT n.external_id AS node, o.outcome, o.retryable, o.detail, o.recorded_at
343
+ FROM outcome o JOIN node n ON n.id = o.node_id
344
+ WHERE n.external_id = ?`,
345
+ [node]
346
+ );
347
+ if (row === undefined) return null;
348
+ return {
349
+ node: String(row.node),
350
+ outcome: row.outcome as OutcomeKind,
351
+ retryable: row.retryable === null ? null : row.retryable === 1,
352
+ detail: row.detail === null ? null : String(row.detail),
353
+ recordedAt: String(row.recorded_at),
354
+ };
355
+ }
356
+ /* eslint-enable @typescript-eslint/no-base-to-string */
357
+ }
358
+
359
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,41 @@
1
+ import type {Database} from '../db/database.mts';
2
+
3
+ /* eslint-disable @typescript-eslint/require-await --
4
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
5
+
6
+ /** Opaque per-source delta-sync cursors persisted between ticks. */
7
+ export class CursorStore {
8
+ readonly #db: Database;
9
+
10
+ constructor(db: Database) {
11
+ this.#db = db;
12
+ }
13
+
14
+ async getCursor(source: string): Promise<string | null> {
15
+ const row = this.#db.get('SELECT value FROM cursor WHERE source = ?', [
16
+ source,
17
+ ]);
18
+ return row === undefined ? null : String(row.value);
19
+ }
20
+
21
+ async setCursor(source: string, value: string): Promise<void> {
22
+ this.#db.run(
23
+ `INSERT INTO cursor (source, value) VALUES (?, ?)
24
+ ON CONFLICT(source) DO UPDATE SET value = excluded.value`,
25
+ [source, value]
26
+ );
27
+ }
28
+
29
+ async clearCursor(source: string): Promise<boolean> {
30
+ return this.#db.run('DELETE FROM cursor WHERE source = ?', [source]) > 0;
31
+ }
32
+
33
+ /** Clear every cursor. The graph-wide rebuild deletes all sources' nodes, so
34
+ * every source must re-sync from scratch or it silently keeps a cursor
35
+ * pointing past data that no longer exists. */
36
+ async clearAllCursors(): Promise<number> {
37
+ return this.#db.run('DELETE FROM cursor');
38
+ }
39
+ }
40
+
41
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,138 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {DataError, ensure} from '../errors/index.mts';
3
+ import type {Edge} from '../model/types.mts';
4
+ import {nodeRef} from './materialize.mts';
5
+
6
+ /* eslint-disable @typescript-eslint/require-await --
7
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
8
+
9
+ /** The blocking DAG. `blocker` blocks `blocked`; any kind may block any other. */
10
+ export class EdgeStore {
11
+ readonly #db: Database;
12
+
13
+ constructor(db: Database) {
14
+ this.#db = db;
15
+ }
16
+
17
+ async addEdge(blocker: string, blocked: string): Promise<boolean> {
18
+ return this.#db.transaction(() => {
19
+ const added = this.#insert(blocker, blocked);
20
+ if (added) this.#rejectIfCycle(blocked);
21
+ return added;
22
+ });
23
+ }
24
+
25
+ async removeEdge(blocker: string, blocked: string): Promise<boolean> {
26
+ return (
27
+ this.#db.run(
28
+ `DELETE FROM edge
29
+ WHERE blocker = (SELECT id FROM node WHERE external_id = ?)
30
+ AND blocked = (SELECT id FROM node WHERE external_id = ?)`,
31
+ [blocker, blocked]
32
+ ) > 0
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Replace every edge in one direction of a node with the given set — lets a
38
+ * re-fetch declare "these are now exactly my blockers/blocks" atomically.
39
+ *
40
+ * "Every edge" means every edge the declaration could have named. A tracker
41
+ * lists tickets, and an id it names that nobody has fetched yet is a
42
+ * placeholder, so those two kinds are what a redeclaration replaces. Edges
43
+ * whose other endpoint is a PR item or a milestone were minted here — a
44
+ * worker registering the PR that implements a ticket, a scan recording
45
+ * milestone membership — and no tracker relation list mentions them, so a
46
+ * redeclaration that dropped them would be deleting on no evidence. Remove
47
+ * one of those with `edge rm`, which says which edge it means.
48
+ */
49
+ async setEdges(
50
+ node: string,
51
+ direction: 'blockers' | 'blocks',
52
+ others: readonly string[]
53
+ ): Promise<void> {
54
+ await this.#db.transaction(() => {
55
+ const nodeId = nodeRef(this.#db, node);
56
+ const [own, far] =
57
+ direction === 'blockers'
58
+ ? ['blocked', 'blocker']
59
+ : ['blocker', 'blocked'];
60
+ this.#db.run(
61
+ `DELETE FROM edge
62
+ WHERE ${own} = ?
63
+ AND ${far} IN (
64
+ SELECT id FROM node WHERE kind IN ('ticket', 'unknown')
65
+ )`,
66
+ [nodeId]
67
+ );
68
+ for (const other of others) {
69
+ if (direction === 'blockers') this.#insert(other, node);
70
+ else this.#insert(node, other);
71
+ }
72
+ this.#rejectIfCycle(node);
73
+ });
74
+ }
75
+
76
+ async edges(): Promise<Edge[]> {
77
+ return this.#db
78
+ .all(
79
+ `SELECT bn.external_id AS blocker, dn.external_id AS blocked
80
+ FROM edge e
81
+ JOIN node bn ON bn.id = e.blocker
82
+ JOIN node dn ON dn.id = e.blocked`
83
+ )
84
+ .map((row) => ({
85
+ blocker: String(row.blocker),
86
+ blocked: String(row.blocked),
87
+ }));
88
+ }
89
+
90
+ #insert(blocker: string, blocked: string): boolean {
91
+ ensure(
92
+ blocker !== blocked,
93
+ () =>
94
+ new DataError(`a node cannot block itself (${blocker})`, {
95
+ hint: 'a self-edge is an illegal one-node cycle.',
96
+ })
97
+ );
98
+ const blockerId = nodeRef(this.#db, blocker);
99
+ const blockedId = nodeRef(this.#db, blocked);
100
+ return (
101
+ this.#db.run(
102
+ 'INSERT INTO edge (blocker, blocked) VALUES (?, ?) ON CONFLICT DO NOTHING',
103
+ [blockerId, blockedId]
104
+ ) > 0
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Throw (rolling back the transaction) if `node` now sits on a cycle. A cycle
110
+ * can only have appeared via an edge just written through `node`, so checking
111
+ * reachability from it alone suffices. Walked by a recursive CTE.
112
+ */
113
+ #rejectIfCycle(externalId: string): void {
114
+ const onCycle = this.#db.get(
115
+ `WITH RECURSIVE reach(id) AS (
116
+ SELECT blocked FROM edge
117
+ WHERE blocker = (SELECT id FROM node WHERE external_id = ?)
118
+ UNION
119
+ SELECT e.blocked FROM edge e JOIN reach r ON e.blocker = r.id
120
+ )
121
+ SELECT 1 FROM reach
122
+ WHERE id = (SELECT id FROM node WHERE external_id = ?) LIMIT 1`,
123
+ [externalId, externalId]
124
+ );
125
+ ensure(
126
+ onCycle === undefined,
127
+ () =>
128
+ new DataError(
129
+ `that edge would create a dependency cycle through ${externalId}`,
130
+ {
131
+ hint: 'remove the opposing edge first, or fix the dependency direction.',
132
+ }
133
+ )
134
+ );
135
+ }
136
+ }
137
+
138
+ /* eslint-enable @typescript-eslint/require-await */