@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.
- package/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- 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 */
|