@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,346 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {DataError, ensure} from '../errors/index.mts';
3
+ import {assertInstant} from '../db/time.mts';
4
+
5
+ /* eslint-disable @typescript-eslint/require-await --
6
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
7
+
8
+ export const FETCH_KINDS = [
9
+ 'scan_project',
10
+ 'fetch_ticket',
11
+ 'refresh_ticket',
12
+ ] as const;
13
+ export type FetchKind = (typeof FETCH_KINDS)[number];
14
+ export type FetchResolution = 'materialized' | 'missing';
15
+
16
+ export interface ScanPayload {
17
+ projects: string[];
18
+ cursor: string | null;
19
+ }
20
+
21
+ export interface TicketPayload {
22
+ ticket: string;
23
+ }
24
+
25
+ export interface FetchRequest {
26
+ id: number;
27
+ source: string;
28
+ kind: FetchKind;
29
+ payload: ScanPayload | TicketPayload;
30
+ createdAt: string;
31
+ deliveredAt: string | null;
32
+ resolution: FetchResolution | null;
33
+ }
34
+
35
+ const COLUMNS =
36
+ 'id, source, kind, payload, created_at, delivered_at, resolution';
37
+
38
+ /**
39
+ * The durable instruction queue. A ticket request is keyed by its serialized
40
+ * payload, so a row that was already resolved `missing` still suppresses a
41
+ * re-enqueue — that is what stops one dead reference restarting the loop.
42
+ */
43
+ export class FetchRequestStore {
44
+ readonly #db: Database;
45
+
46
+ constructor(db: Database) {
47
+ this.#db = db;
48
+ }
49
+
50
+ async enqueueScan(input: {
51
+ source: string;
52
+ projects: readonly string[];
53
+ cursor: string | null;
54
+ at: string;
55
+ }): Promise<number> {
56
+ assertInstant(input.at, 'at');
57
+ const payload: ScanPayload = {
58
+ projects: [...input.projects],
59
+ cursor: input.cursor,
60
+ };
61
+ return this.#insert(
62
+ input.source,
63
+ 'scan_project',
64
+ JSON.stringify(payload),
65
+ input.at
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Enqueue a ticket request. The existence check and insert execute atomically in a
71
+ * transaction to prevent two processes racing past the check and both inserting for
72
+ * the same ticket. The check deliberately matches any existing row, including one
73
+ * already resolved `missing`, so a ticket the tracker does not have is not
74
+ * requested again for the rest of the refresh.
75
+ */
76
+ async enqueueTicket(input: {
77
+ source: string;
78
+ ticket: string;
79
+ at: string;
80
+ }): Promise<number | null> {
81
+ assertInstant(input.at, 'at');
82
+ const payload = JSON.stringify({
83
+ ticket: input.ticket,
84
+ } satisfies TicketPayload);
85
+ return this.#db.transaction(() => {
86
+ const existing = this.#db.get(
87
+ `SELECT id FROM fetch_request
88
+ WHERE source = ? AND kind = 'fetch_ticket' AND payload = ?`,
89
+ [input.source, payload]
90
+ );
91
+ if (existing !== undefined) return null;
92
+ return this.#insert(input.source, 'fetch_ticket', payload, input.at);
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Enqueue a recurring re-read of a ticket the graph already holds.
98
+ *
99
+ * A distinct kind from `fetch_ticket`, because their lifecycles differ in
100
+ * both directions: reconcile resolves a `fetch_ticket` the moment the node
101
+ * exists (for a re-read it always does, which would resolve it before any
102
+ * fetch happened), and `enqueueTicket` dedupes against resolved rows for
103
+ * the life of a refresh (which would allow exactly one re-read ever).
104
+ * A `refresh_ticket` is resolved by the ticket write itself — the UPDATE
105
+ * inside `TicketStore.upsertTicket`'s transaction, so `ticket set` answers
106
+ * whoever asked — and dedupes only against its own open rows: one
107
+ * outstanding ask per ticket.
108
+ */
109
+ async enqueueTicketRefresh(input: {
110
+ source: string;
111
+ ticket: string;
112
+ at: string;
113
+ }): Promise<number | null> {
114
+ assertInstant(input.at, 'at');
115
+ const payload = JSON.stringify({
116
+ ticket: input.ticket,
117
+ } satisfies TicketPayload);
118
+ return this.#db.transaction(() => {
119
+ const open = this.#db.get(
120
+ `SELECT id FROM fetch_request
121
+ WHERE source = ? AND kind = 'refresh_ticket' AND payload = ?
122
+ AND resolution IS NULL`,
123
+ [input.source, payload]
124
+ );
125
+ if (open !== undefined) return null;
126
+ // The history's only job is carrying the newest ask time for the
127
+ // cadence; without this, every answered ask is a permanent row and a
128
+ // long-lived ticket accretes one per cadence forever.
129
+ this.#db.run(
130
+ `DELETE FROM fetch_request
131
+ WHERE kind = 'refresh_ticket' AND payload = ? AND resolution IS NOT NULL`,
132
+ [payload]
133
+ );
134
+ return this.#insert(input.source, 'refresh_ticket', payload, input.at);
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Re-offer refresh asks that were pushed but never answered. Scan-resume
140
+ * redelivery deliberately does not own these, so without this a push the
141
+ * session never heard (channel down, session gone) would leave the ask
142
+ * open forever — and an open ask suppresses every future one.
143
+ */
144
+ async redeliverStaleTicketRefreshes(
145
+ now: string,
146
+ olderThanSeconds: number
147
+ ): Promise<number> {
148
+ assertInstant(now, 'now');
149
+ return this.#db.run(
150
+ `UPDATE fetch_request SET delivered_at = NULL
151
+ WHERE kind = 'refresh_ticket' AND resolution IS NULL
152
+ AND delivered_at IS NOT NULL
153
+ AND unixepoch(?) - unixepoch(delivered_at) >= ?`,
154
+ [now, olderThanSeconds]
155
+ );
156
+ }
157
+
158
+ /**
159
+ * When a ticket was last asked about via `refresh_ticket`, or null if
160
+ * never. Drives the cadence: due = no open ask and the last one is older
161
+ * than the interval.
162
+ */
163
+ async lastTicketRefreshAt(ticket: string): Promise<string | null> {
164
+ const row = this.#db.get(
165
+ `SELECT MAX(created_at) AS at FROM fetch_request
166
+ WHERE kind = 'refresh_ticket' AND payload = ?`,
167
+ [JSON.stringify({ticket} satisfies TicketPayload)]
168
+ );
169
+ return typeof row?.at === 'string' ? row.at : null;
170
+ }
171
+
172
+ async undelivered(): Promise<FetchRequest[]> {
173
+ return this.#db
174
+ .all(
175
+ `SELECT ${COLUMNS} FROM fetch_request
176
+ WHERE delivered_at IS NULL AND resolution IS NULL
177
+ ORDER BY id`
178
+ )
179
+ .map(toRequest);
180
+ }
181
+
182
+ /**
183
+ * Mark a source's scan instruction answered. Nothing else resolves it —
184
+ * `openCount` ignores it, and the refresh's own state holds the scan open —
185
+ * so without this it stays outstanding for the life of the refresh and
186
+ * `redeliver` would put a finished scan back on the wire, whose `refresh
187
+ * done` the CLI would then refuse for having no scan in progress.
188
+ */
189
+ async resolveScan(source: string): Promise<number> {
190
+ return this.#db.run(
191
+ `UPDATE fetch_request SET resolution = 'materialized'
192
+ WHERE source = ? AND kind = 'scan_project' AND resolution IS NULL`,
193
+ [source]
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Offer every unresolved request for a source to the channel again by
199
+ * clearing its delivery mark. Nothing acknowledges a channel push, so a
200
+ * delivery mark records that the server wrote the event, not that a session
201
+ * received it. Re-delivering costs the agent a duplicate instruction it will
202
+ * satisfy idempotently; not re-delivering leaves it waiting on an event that
203
+ * is already marked sent, with nothing to time it out.
204
+ */
205
+ async redeliver(source: string): Promise<number> {
206
+ return this.#db.run(
207
+ `UPDATE fetch_request SET delivered_at = NULL
208
+ WHERE source = ? AND resolution IS NULL
209
+ AND kind IN ('scan_project','fetch_ticket')`,
210
+ [source]
211
+ );
212
+ }
213
+
214
+ async markDelivered(id: number, at: string): Promise<void> {
215
+ assertInstant(at, 'at');
216
+ this.#db.run('UPDATE fetch_request SET delivered_at = ? WHERE id = ?', [
217
+ at,
218
+ id,
219
+ ]);
220
+ }
221
+
222
+ async resolve(id: number, resolution: FetchResolution): Promise<void> {
223
+ this.#db.run('UPDATE fetch_request SET resolution = ? WHERE id = ?', [
224
+ resolution,
225
+ id,
226
+ ]);
227
+ }
228
+
229
+ async openTickets(): Promise<{id: number; source: string; ticket: string}[]> {
230
+ return this.#db
231
+ .all(
232
+ `SELECT id, source, payload FROM fetch_request
233
+ WHERE kind = 'fetch_ticket' AND resolution IS NULL
234
+ ORDER BY id`
235
+ )
236
+ .map((row) => ({
237
+ id: Number(row.id),
238
+ source: String(row.source),
239
+ ticket: (JSON.parse(String(row.payload)) as TicketPayload).ticket,
240
+ }));
241
+ }
242
+
243
+ async openTicketRequest(
244
+ ticket: string
245
+ ): Promise<{id: number; source: string} | null> {
246
+ const row = this.#db.get(
247
+ `SELECT id, source FROM fetch_request
248
+ WHERE kind IN ('fetch_ticket','refresh_ticket')
249
+ AND resolution IS NULL AND payload = ?`,
250
+ [JSON.stringify({ticket} satisfies TicketPayload)]
251
+ );
252
+ return row === undefined
253
+ ? null
254
+ : {id: Number(row.id), source: String(row.source)};
255
+ }
256
+
257
+ async bySource(source: string): Promise<FetchRequest[]> {
258
+ return this.#db
259
+ .all(
260
+ `SELECT ${COLUMNS} FROM fetch_request WHERE source = ? ORDER BY id`,
261
+ [source]
262
+ )
263
+ .map(toRequest);
264
+ }
265
+
266
+ /**
267
+ * Outstanding ticket requests. Deliberately not counting the `scan_project`
268
+ * row: it resolves only when the scan completes (`resolveScan`), and a
269
+ * refresh in `scanning` is already held open by its state, so counting it
270
+ * here would just delay the close.
271
+ */
272
+ async openCount(source: string): Promise<number> {
273
+ const row = this.#db.get(
274
+ `SELECT COUNT(*) AS n FROM fetch_request
275
+ WHERE source = ? AND kind = 'fetch_ticket' AND resolution IS NULL`,
276
+ [source]
277
+ );
278
+ return row === undefined ? 0 : Number(row.n);
279
+ }
280
+
281
+ async clear(source: string): Promise<number> {
282
+ return this.#db.run('DELETE FROM fetch_request WHERE source = ?', [source]);
283
+ }
284
+
285
+ /**
286
+ * Forget a closing refresh's own requests, except the `missing` tombstones.
287
+ * Those are what stop a ticket the tracker does not have from being
288
+ * requested again, so a refresh that closes must leave them behind; only an
289
+ * explicit new scan forgets them.
290
+ *
291
+ * Scoped to the kinds a refresh owns, the same pair `redeliver` scopes to.
292
+ * A `refresh_ticket` row belongs to the cadence, not to any one refresh: it
293
+ * carries the only record of when its ticket was last asked about, and
294
+ * deleting it reads as "never asked", which re-asks on the next tick and
295
+ * every tick after. That history prunes itself — `enqueueTicketRefresh`
296
+ * drops the resolved row it replaces — so nothing here needs to.
297
+ */
298
+ async clearExceptMissing(source: string): Promise<number> {
299
+ return this.#db.run(
300
+ `DELETE FROM fetch_request
301
+ WHERE source = ? AND (resolution IS NULL OR resolution <> 'missing')
302
+ AND kind IN ('scan_project','fetch_ticket')`,
303
+ [source]
304
+ );
305
+ }
306
+
307
+ #insert(
308
+ source: string,
309
+ kind: FetchKind,
310
+ payload: string,
311
+ at: string
312
+ ): number {
313
+ this.#db.run(
314
+ `INSERT INTO fetch_request (source, kind, payload, created_at)
315
+ VALUES (?, ?, ?, ?)`,
316
+ [source, kind, payload, at]
317
+ );
318
+ // The id must be read back per-connection: several dispatch processes write this
319
+ // table concurrently, and a table-wide "highest id" would return another process's row.
320
+ const row = this.#db.get('SELECT last_insert_rowid() AS id');
321
+ ensure(
322
+ row !== undefined,
323
+ () => new DataError('a fetch request just inserted must exist')
324
+ );
325
+ return Number(row.id);
326
+ }
327
+ }
328
+
329
+ /* eslint-disable @typescript-eslint/no-base-to-string --
330
+ * SQLite hands back `unknown`; `String()` converts a primitive rather than
331
+ * asserting a type the row has not been checked for. */
332
+ function toRequest(row: Record<string, unknown>): FetchRequest {
333
+ return {
334
+ id: Number(row.id),
335
+ source: String(row.source),
336
+ kind: row.kind as FetchKind,
337
+ payload: JSON.parse(String(row.payload)) as ScanPayload | TicketPayload,
338
+ createdAt: String(row.created_at),
339
+ deliveredAt: row.delivered_at === null ? null : String(row.delivered_at),
340
+ resolution:
341
+ row.resolution === null ? null : (row.resolution as FetchResolution),
342
+ };
343
+ }
344
+ /* eslint-enable @typescript-eslint/no-base-to-string */
345
+
346
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,19 @@
1
+ export * from './materialize.mts';
2
+ export * from './project.mts';
3
+ export * from './milestone.mts';
4
+ export * from './ticket.mts';
5
+ export * from './pr.mts';
6
+ export * from './edge.mts';
7
+ export * from './session.mts';
8
+ export * from './coordination.mts';
9
+ export * from './cursor.mts';
10
+ export * from './refresh.mts';
11
+ export * from './fetch-request.mts';
12
+ export * from './notice.mts';
13
+ export * from './policy.mts';
14
+ export * from './review.mts';
15
+ export {PrEventStore} from './pr-event.mts';
16
+ export type {PendingEvent} from './pr-event.mts';
17
+ export {WatchStore} from './watch.mts';
18
+ export type {DueWatch} from './watch.mts';
19
+ export {WorkerStore} from './worker.mts';
@@ -0,0 +1,69 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {DataError, ensure} from '../errors/index.mts';
3
+ import type {ConcreteKind, Kind} from '../model/status.mts';
4
+
5
+ export interface NodeRow {
6
+ id: number;
7
+ kind: Kind;
8
+ }
9
+
10
+ export function findNode(db: Database, externalId: string): NodeRow | null {
11
+ const row = db.get('SELECT id, kind FROM node WHERE external_id = ?', [
12
+ externalId,
13
+ ]);
14
+ if (row === undefined) return null;
15
+ return {id: Number(row.id), kind: row.kind as Kind};
16
+ }
17
+
18
+ /** The node for an id, creating an `unknown` placeholder when nobody wrote it. */
19
+ export function nodeRef(db: Database, externalId: string): number {
20
+ const existing = findNode(db, externalId);
21
+ if (existing !== null) return existing.id;
22
+ db.run("INSERT INTO node (external_id, kind) VALUES (?, 'unknown')", [
23
+ externalId,
24
+ ]);
25
+ const created = findNode(db, externalId);
26
+ ensure(
27
+ created !== null,
28
+ () => new DataError('a node just inserted must exist')
29
+ );
30
+ return created.id;
31
+ }
32
+
33
+ /**
34
+ * The node for a satellite being written: created with its kind, or promoted
35
+ * from a placeholder. Any kind may block any other, so promotion has no edge
36
+ * legality to check — only that the id is not already the *other* concrete kind.
37
+ */
38
+ export function materialize(
39
+ db: Database,
40
+ externalId: string,
41
+ kind: ConcreteKind
42
+ ): number {
43
+ const existing = findNode(db, externalId);
44
+ if (existing === null) {
45
+ db.run('INSERT INTO node (external_id, kind) VALUES (?, ?)', [
46
+ externalId,
47
+ kind,
48
+ ]);
49
+ const created = findNode(db, externalId);
50
+ ensure(
51
+ created !== null,
52
+ () => new DataError('a node just inserted must exist')
53
+ );
54
+ return created.id;
55
+ }
56
+ if (existing.kind === kind) return existing.id;
57
+ ensure(
58
+ existing.kind === 'unknown',
59
+ () =>
60
+ new DataError(
61
+ `id "${externalId}" is already a ${existing.kind}; it cannot also be a ${kind}`,
62
+ {
63
+ hint: `entities share one id space — give the ${kind} a different id, or remove the ${existing.kind} first.`,
64
+ }
65
+ )
66
+ );
67
+ db.run('UPDATE node SET kind = ? WHERE id = ?', [kind, existing.id]);
68
+ return existing.id;
69
+ }
@@ -0,0 +1,74 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {ensure, UsageError} from '../errors/index.mts';
3
+ import type {Milestone} from '../model/types.mts';
4
+ import {findNode, materialize} from './materialize.mts';
5
+
6
+ /* eslint-disable @typescript-eslint/require-await --
7
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
8
+
9
+ /**
10
+ * Milestones. Membership is not stored here — a ticket belongs to a milestone by
11
+ * blocking it (a `ticket → milestone` edge), so removing a milestone node lets
12
+ * the edge FK cascade its membership away.
13
+ */
14
+ export class MilestoneStore {
15
+ readonly #db: Database;
16
+
17
+ constructor(db: Database) {
18
+ this.#db = db;
19
+ }
20
+
21
+ async upsertMilestone(milestone: Milestone): Promise<void> {
22
+ await this.#db.transaction(() => {
23
+ // Same rule as tickets: the project must already be recorded. A name
24
+ // passed where the id belongs would otherwise materialize an
25
+ // unknown-kind placeholder and silently parent the milestone onto a
26
+ // node no project join can see.
27
+ const project = findNode(this.#db, milestone.project);
28
+ ensure(
29
+ project !== null && project.kind === 'project',
30
+ () =>
31
+ new UsageError(`"${milestone.project}" is not a recorded project`, {
32
+ hint: 'record it first with `dispatch project set`, and pass the project id — a project name is not its id.',
33
+ })
34
+ );
35
+ const projectId = project.id;
36
+ const nodeId = materialize(this.#db, milestone.id, 'milestone');
37
+ this.#db.run(
38
+ `INSERT INTO milestone (node_id, project_id, name) VALUES (?, ?, ?)
39
+ ON CONFLICT(node_id) DO UPDATE SET
40
+ project_id = excluded.project_id, name = excluded.name`,
41
+ [nodeId, projectId, milestone.name]
42
+ );
43
+ });
44
+ }
45
+
46
+ /** Remove a milestone; its edges, claim, and review cascade via node FKs. */
47
+ async removeMilestone(id: string): Promise<boolean> {
48
+ return this.#db.transaction(() => {
49
+ const node = findNode(this.#db, id);
50
+ if (node?.kind !== 'milestone') return false;
51
+ this.#db.run('DELETE FROM node WHERE id = ?', [node.id]);
52
+ return true;
53
+ });
54
+ }
55
+
56
+ async getMilestone(id: string): Promise<Milestone | null> {
57
+ const row = this.#db.get(
58
+ `SELECT n.external_id AS id, pn.external_id AS project, m.name AS name
59
+ FROM milestone m
60
+ JOIN node n ON n.id = m.node_id
61
+ JOIN node pn ON pn.id = m.project_id
62
+ WHERE n.external_id = ?`,
63
+ [id]
64
+ );
65
+ if (row === undefined) return null;
66
+ return {
67
+ id: String(row.id),
68
+ project: String(row.project),
69
+ name: String(row.name),
70
+ };
71
+ }
72
+ }
73
+
74
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,57 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {assertInstant} from '../db/time.mts';
3
+
4
+ /* eslint-disable @typescript-eslint/require-await --
5
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
6
+
7
+ export const NOTICE_KINDS = [
8
+ 'park_human_blocked',
9
+ 'alert_failure',
10
+ 'project_complete',
11
+ ] as const;
12
+ export type NoticeKind = (typeof NOTICE_KINDS)[number];
13
+
14
+ /**
15
+ * Once-per-episode markers for the condition orders. A condition (a ticket
16
+ * needs parking, a failure needs surfacing, a project completed) fires one
17
+ * order while it holds; `prune` clears markers whose condition lapsed so a new
18
+ * episode fires again.
19
+ */
20
+ export class NoticeStore {
21
+ readonly #db: Database;
22
+
23
+ constructor(db: Database) {
24
+ this.#db = db;
25
+ }
26
+
27
+ async has(kind: NoticeKind, node: string): Promise<boolean> {
28
+ return (
29
+ this.#db.get('SELECT 1 AS x FROM notice WHERE kind = ? AND node = ?', [
30
+ kind,
31
+ node,
32
+ ]) !== undefined
33
+ );
34
+ }
35
+
36
+ async record(kind: NoticeKind, node: string, at: string): Promise<void> {
37
+ assertInstant(at, 'at');
38
+ this.#db.run(
39
+ `INSERT INTO notice (kind, node, emitted_at) VALUES (?, ?, ?)
40
+ ON CONFLICT(kind, node) DO NOTHING`,
41
+ [kind, node, at]
42
+ );
43
+ }
44
+
45
+ /** Drop every marker of `kind` whose node is not in `holding`. */
46
+ async prune(kind: NoticeKind, holding: readonly string[]): Promise<number> {
47
+ const placeholders = holding.map(() => '?').join(', ');
48
+ return this.#db.run(
49
+ holding.length === 0
50
+ ? 'DELETE FROM notice WHERE kind = ?'
51
+ : `DELETE FROM notice WHERE kind = ? AND node NOT IN (${placeholders})`,
52
+ [kind, ...holding]
53
+ );
54
+ }
55
+ }
56
+
57
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,48 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {DEFAULT_REPO_CAPS} from '../model/index.mts';
3
+ import type {RepoCapPolicy} from '../model/index.mts';
4
+
5
+ /* eslint-disable @typescript-eslint/require-await --
6
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
7
+
8
+ const REPO_CAPS_KEY = 'repo_caps';
9
+
10
+ /**
11
+ * The admission policy the running server was started with, kept in the
12
+ * database so a reader that is not that server — `dispatch status`, mainly —
13
+ * reports against the caps actually in force instead of guessing the
14
+ * defaults. The server writes it at startup; the last one to start wins,
15
+ * which matches the caps' meaning: they bound one host's shared resources,
16
+ * not one session's share of them.
17
+ */
18
+ export class PolicyStore {
19
+ readonly #db: Database;
20
+
21
+ constructor(db: Database) {
22
+ this.#db = db;
23
+ }
24
+
25
+ /** The stored policy, or the defaults when none was ever written. */
26
+ async getRepoCaps(): Promise<RepoCapPolicy> {
27
+ const row = this.#db.get('SELECT value FROM meta WHERE key = ?', [
28
+ REPO_CAPS_KEY,
29
+ ]);
30
+ if (typeof row?.value !== 'string') return DEFAULT_REPO_CAPS;
31
+ const stored = JSON.parse(row.value) as Partial<RepoCapPolicy>;
32
+ return {
33
+ openPrs: stored.openPrs ?? DEFAULT_REPO_CAPS.openPrs,
34
+ inFlightBuilds: stored.inFlightBuilds ?? DEFAULT_REPO_CAPS.inFlightBuilds,
35
+ openPrsByRepo: stored.openPrsByRepo ?? {},
36
+ inFlightBuildsByRepo: stored.inFlightBuildsByRepo ?? {},
37
+ };
38
+ }
39
+
40
+ async setRepoCaps(policy: RepoCapPolicy): Promise<void> {
41
+ this.#db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', [
42
+ REPO_CAPS_KEY,
43
+ JSON.stringify(policy),
44
+ ]);
45
+ }
46
+ }
47
+
48
+ /* eslint-enable @typescript-eslint/require-await */