@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,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 */
|