@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,360 @@
|
|
|
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 type {Observation} from '../watch/diff.mts';
|
|
5
|
+
import type {PrSnapshot} from '../watch/snapshot.mts';
|
|
6
|
+
import {findNode} from './materialize.mts';
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
9
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
10
|
+
|
|
11
|
+
export interface DueWatch {
|
|
12
|
+
node: string;
|
|
13
|
+
repo: string;
|
|
14
|
+
prNumber: number;
|
|
15
|
+
/** The last observation, or null when none has been taken yet. */
|
|
16
|
+
snapshot: PrSnapshot | null;
|
|
17
|
+
/** Identity token for `observe`: a replacement watch mints a new one. */
|
|
18
|
+
createdAt: string;
|
|
19
|
+
/**
|
|
20
|
+
* Past its expiry. The snapshot sees only the forge, so a signal outside it
|
|
21
|
+
* — an approval given on the ticket, a reaction, an out-of-band go-ahead —
|
|
22
|
+
* would otherwise never reach the worker. An expired watch fires on no diff
|
|
23
|
+
* at all, reporting `watch_expired`, which tells the worker to go look for
|
|
24
|
+
* itself.
|
|
25
|
+
*
|
|
26
|
+
* Never true for a parked item: expiry addresses a worker, and an item
|
|
27
|
+
* whose outcome is recorded has none. Its watch keeps running until a real
|
|
28
|
+
* diff fires it.
|
|
29
|
+
*/
|
|
30
|
+
expired: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A worker's PR wait, handed to the server. The worker records what it waits
|
|
35
|
+
* on and returns; the server snapshots the PR on its tick, diffs against the
|
|
36
|
+
* stored snapshot, and fires the row when something a worker would act on
|
|
37
|
+
* changed. The row survives dispatch — a crashed resume still reads as a wait
|
|
38
|
+
* to pick up — and is removed when the item's outcome is recorded.
|
|
39
|
+
*
|
|
40
|
+
* `human-blocked` is the exception both ways: it keeps its watch, because a
|
|
41
|
+
* park is a wait handed to the operator rather than a conclusion, and
|
|
42
|
+
* `dispatch outcome rm` is what finally drops it.
|
|
43
|
+
*/
|
|
44
|
+
export class WatchStore {
|
|
45
|
+
readonly #db: Database;
|
|
46
|
+
|
|
47
|
+
constructor(db: Database) {
|
|
48
|
+
this.#db = db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async set(input: {
|
|
52
|
+
node: string;
|
|
53
|
+
intervalSeconds: number;
|
|
54
|
+
at: string;
|
|
55
|
+
/**
|
|
56
|
+
* The deadline this wait fires at whatever the diff says. Arming is the
|
|
57
|
+
* only thing that sets it: a PR polled more often than the expiry window
|
|
58
|
+
* never reaches a deadline a poll can push out.
|
|
59
|
+
*/
|
|
60
|
+
expiresAt: string;
|
|
61
|
+
/**
|
|
62
|
+
* The PR as of arming. Recording it here is what closes the gap between
|
|
63
|
+
* the worker's last look and the first poll; null (the read failed)
|
|
64
|
+
* degrades to priming on the first successful poll, which can miss a
|
|
65
|
+
* change that landed in between.
|
|
66
|
+
*/
|
|
67
|
+
snapshot: PrSnapshot | null;
|
|
68
|
+
/** The session whose worker armed this wait; only it can route the events. */
|
|
69
|
+
session: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Release this session's claim in the same transaction. The two must not
|
|
72
|
+
* be separable: between a released claim and an installed watch the item
|
|
73
|
+
* is neither claimed nor watching, so another server can claim and
|
|
74
|
+
* dispatch it — and the late arm then hides that live worker's claim
|
|
75
|
+
* behind a watching row.
|
|
76
|
+
*/
|
|
77
|
+
releaseClaimFor?: string | null;
|
|
78
|
+
}): Promise<void> {
|
|
79
|
+
assertInstant(input.at, 'at');
|
|
80
|
+
assertInstant(input.expiresAt, 'expiresAt');
|
|
81
|
+
await this.#db.transaction(() => {
|
|
82
|
+
const node = findNode(this.#db, input.node);
|
|
83
|
+
ensure(
|
|
84
|
+
node !== null,
|
|
85
|
+
() =>
|
|
86
|
+
new DataError(`no node "${input.node}" to watch`, {
|
|
87
|
+
hint: 'a watch is set on a PR item the graph already holds.',
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
if (input.releaseClaimFor != null) {
|
|
91
|
+
this.#db.run('DELETE FROM claim WHERE node_id = ? AND session_id = ?', [
|
|
92
|
+
node.id,
|
|
93
|
+
input.releaseClaimFor,
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
// Re-arming starts a new wait, so anything the previous one observed
|
|
97
|
+
// and never delivered describes a wait nobody is in any more.
|
|
98
|
+
this.#db.run('DELETE FROM pr_event WHERE node_id = ?', [node.id]);
|
|
99
|
+
this.#db.run(
|
|
100
|
+
`INSERT INTO watch (node_id, state, snapshot, interval_s, session_id, created_at, expires_at)
|
|
101
|
+
VALUES (?, 'watching', ?, ?, ?, ?, ?)
|
|
102
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
103
|
+
state = 'watching',
|
|
104
|
+
snapshot = excluded.snapshot,
|
|
105
|
+
interval_s = excluded.interval_s, checked_at = NULL,
|
|
106
|
+
session_id = excluded.session_id,
|
|
107
|
+
created_at = excluded.created_at, expires_at = excluded.expires_at`,
|
|
108
|
+
[
|
|
109
|
+
node.id,
|
|
110
|
+
input.snapshot === null ? null : JSON.stringify(input.snapshot),
|
|
111
|
+
input.intervalSeconds,
|
|
112
|
+
input.session,
|
|
113
|
+
input.at,
|
|
114
|
+
input.expiresAt,
|
|
115
|
+
]
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Watching rows ready for a poll — expired, never checked, or past their
|
|
122
|
+
* interval — capped so one pass stays short. A row whose item lacks PR
|
|
123
|
+
* coordinates is skipped; there is nothing to read yet.
|
|
124
|
+
*
|
|
125
|
+
* A parked row (an outcome is recorded, so the only watch left is the one
|
|
126
|
+
* `human-blocked` keeps) is never expired and is never made due by expiry:
|
|
127
|
+
* it comes round on its interval alone. Otherwise the pass would fire it
|
|
128
|
+
* into a `resume` nobody prompted, on a schedule rather than on an answer.
|
|
129
|
+
*
|
|
130
|
+
* Expired rows come first, then oldest check. An expired row has been
|
|
131
|
+
* checked, so under oldest-check order alone a steady influx of new watches
|
|
132
|
+
* holds it outside the cap indefinitely.
|
|
133
|
+
*/
|
|
134
|
+
async due(now: string, limit: number): Promise<DueWatch[]> {
|
|
135
|
+
assertInstant(now, 'now');
|
|
136
|
+
return this.#db
|
|
137
|
+
.all(
|
|
138
|
+
`SELECT n.external_id AS node, pr.repo, pr.pr_number,
|
|
139
|
+
w.snapshot, w.created_at,
|
|
140
|
+
(unixepoch(?) >= unixepoch(w.expires_at)
|
|
141
|
+
AND o.node_id IS NULL) AS expired
|
|
142
|
+
FROM watch w
|
|
143
|
+
JOIN node n ON n.id = w.node_id
|
|
144
|
+
JOIN pr ON pr.node_id = w.node_id
|
|
145
|
+
LEFT JOIN outcome o ON o.node_id = w.node_id
|
|
146
|
+
WHERE w.state = 'watching'
|
|
147
|
+
AND pr.repo IS NOT NULL AND pr.pr_number IS NOT NULL
|
|
148
|
+
AND ((unixepoch(?) >= unixepoch(w.expires_at)
|
|
149
|
+
AND o.node_id IS NULL)
|
|
150
|
+
OR w.checked_at IS NULL
|
|
151
|
+
OR unixepoch(?) - unixepoch(w.checked_at) >= w.interval_s)
|
|
152
|
+
ORDER BY expired DESC, w.checked_at IS NOT NULL, w.checked_at,
|
|
153
|
+
n.external_id
|
|
154
|
+
LIMIT ?`,
|
|
155
|
+
[now, now, now, limit]
|
|
156
|
+
)
|
|
157
|
+
.map((row) => ({
|
|
158
|
+
node: String(row.node),
|
|
159
|
+
repo: String(row.repo),
|
|
160
|
+
prNumber: Number(row.pr_number),
|
|
161
|
+
// The column is TEXT, so sqlite hands back a string or null; the
|
|
162
|
+
// typing is `unknown` and the guard is what narrows it.
|
|
163
|
+
snapshot:
|
|
164
|
+
typeof row.snapshot === 'string'
|
|
165
|
+
? (JSON.parse(row.snapshot) as PrSnapshot)
|
|
166
|
+
: null,
|
|
167
|
+
createdAt: String(row.created_at),
|
|
168
|
+
expired: Number(row.expired) === 1,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Record one poll's snapshot and the events it produced, and fire the watch
|
|
174
|
+
* if the poll says so — all in one transaction. Splitting them lets a crash
|
|
175
|
+
* land events for a wait that never fired, which the next tick would then
|
|
176
|
+
* re-derive from the same unchanged snapshot and record a second time.
|
|
177
|
+
*
|
|
178
|
+
* `expires_at` belongs to the wait, not to the observation, so it is not
|
|
179
|
+
* written here: a poll that extends the deadline makes it unreachable for
|
|
180
|
+
* exactly the quiet PRs it exists to rescue.
|
|
181
|
+
*
|
|
182
|
+
* A row replaced mid-poll (`createdAt` differs) is left alone, events and
|
|
183
|
+
* all: the observation belongs to a wait that no longer exists.
|
|
184
|
+
*/
|
|
185
|
+
async observe(input: {
|
|
186
|
+
node: string;
|
|
187
|
+
snapshot: PrSnapshot;
|
|
188
|
+
at: string;
|
|
189
|
+
createdAt: string;
|
|
190
|
+
fire: boolean;
|
|
191
|
+
intervalSeconds: number;
|
|
192
|
+
events: readonly Observation[];
|
|
193
|
+
}): Promise<'recorded' | 'fired' | 'stale'> {
|
|
194
|
+
assertInstant(input.at, 'at');
|
|
195
|
+
return this.#db.transaction(() => {
|
|
196
|
+
const row = this.#db.get(
|
|
197
|
+
`SELECT w.node_id, w.session_id FROM watch w
|
|
198
|
+
JOIN node n ON n.id = w.node_id
|
|
199
|
+
WHERE n.external_id = ? AND w.state = 'watching' AND w.created_at = ?`,
|
|
200
|
+
[input.node, input.createdAt]
|
|
201
|
+
);
|
|
202
|
+
if (row === undefined) return 'stale';
|
|
203
|
+
const nodeId = Number(row.node_id);
|
|
204
|
+
for (const event of input.events) {
|
|
205
|
+
this.#db.run(
|
|
206
|
+
`INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
|
|
207
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
208
|
+
[
|
|
209
|
+
nodeId,
|
|
210
|
+
event.kind,
|
|
211
|
+
event.summary,
|
|
212
|
+
JSON.stringify(event.meta),
|
|
213
|
+
typeof row.session_id === 'string' ? row.session_id : null,
|
|
214
|
+
input.at,
|
|
215
|
+
]
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
this.#db.run(
|
|
219
|
+
`UPDATE watch SET snapshot = ?, checked_at = ?, state = ?,
|
|
220
|
+
interval_s = ?
|
|
221
|
+
WHERE node_id = ?`,
|
|
222
|
+
[
|
|
223
|
+
JSON.stringify(input.snapshot),
|
|
224
|
+
input.at,
|
|
225
|
+
input.fire ? 'fired' : 'watching',
|
|
226
|
+
input.intervalSeconds,
|
|
227
|
+
nodeId,
|
|
228
|
+
]
|
|
229
|
+
);
|
|
230
|
+
return input.fire ? 'fired' : 'recorded';
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Fire a watch outright (expiry); same generation guard as `observe`.
|
|
236
|
+
*
|
|
237
|
+
* The `watch_expired` event is what makes the deadline mean anything: a
|
|
238
|
+
* fired row is no longer polled, and a yielded worker whose session is live
|
|
239
|
+
* keeps the item out of the queue, so a silent fire strands it. The event
|
|
240
|
+
* carries the watch's session, which routes it to that worker.
|
|
241
|
+
*
|
|
242
|
+
* A parked item is not fired at all: any event revives it, and a deadline is
|
|
243
|
+
* not the answer a park waits for. The guard sits on the generation read, so
|
|
244
|
+
* a park landing between `due` and here leaves the row watching rather than
|
|
245
|
+
* fired-with-no-event — which is neither polled nor revivable.
|
|
246
|
+
*/
|
|
247
|
+
async fire(
|
|
248
|
+
node: string,
|
|
249
|
+
at: string,
|
|
250
|
+
createdAt: string
|
|
251
|
+
): Promise<'fired' | 'stale'> {
|
|
252
|
+
assertInstant(at, 'at');
|
|
253
|
+
return this.#db.transaction(() => {
|
|
254
|
+
const row = this.#db.get(
|
|
255
|
+
`SELECT w.node_id, w.session_id FROM watch w
|
|
256
|
+
JOIN node n ON n.id = w.node_id
|
|
257
|
+
WHERE n.external_id = ? AND w.state = 'watching' AND w.created_at = ?
|
|
258
|
+
AND NOT EXISTS (SELECT 1 FROM outcome o WHERE o.node_id = w.node_id)`,
|
|
259
|
+
[node, createdAt]
|
|
260
|
+
);
|
|
261
|
+
if (row === undefined) return 'stale';
|
|
262
|
+
const nodeId = Number(row.node_id);
|
|
263
|
+
this.#db.run(
|
|
264
|
+
`UPDATE watch SET state = 'fired', checked_at = ? WHERE node_id = ?`,
|
|
265
|
+
[at, nodeId]
|
|
266
|
+
);
|
|
267
|
+
this.#db.run(
|
|
268
|
+
`INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
|
|
269
|
+
VALUES (?, 'watch_expired', ?, '{}', ?, ?)`,
|
|
270
|
+
[
|
|
271
|
+
nodeId,
|
|
272
|
+
'The watch reached its deadline with nothing changed on the forge. Look for a signal the snapshot cannot see.',
|
|
273
|
+
typeof row.session_id === 'string' ? row.session_id : null,
|
|
274
|
+
at,
|
|
275
|
+
]
|
|
276
|
+
);
|
|
277
|
+
return 'fired';
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Push a failed poll's retry out by one interval; same generation guard as
|
|
283
|
+
* `observe`, so a failure observed against a replaced watch cannot delay
|
|
284
|
+
* the replacement's first poll.
|
|
285
|
+
*/
|
|
286
|
+
async touch(node: string, at: string, createdAt: string): Promise<void> {
|
|
287
|
+
assertInstant(at, 'at');
|
|
288
|
+
this.#db.run(
|
|
289
|
+
`UPDATE watch SET checked_at = ?
|
|
290
|
+
WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
|
|
291
|
+
AND created_at = ?`,
|
|
292
|
+
[at, node, createdAt]
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The last snapshot a poll stored for a node, or null. */
|
|
297
|
+
async latestSnapshot(node: string): Promise<PrSnapshot | null> {
|
|
298
|
+
const row = this.#db.get(
|
|
299
|
+
`SELECT w.snapshot FROM watch w
|
|
300
|
+
JOIN node n ON n.id = w.node_id
|
|
301
|
+
WHERE n.external_id = ?`,
|
|
302
|
+
[node]
|
|
303
|
+
);
|
|
304
|
+
return typeof row?.snapshot === 'string'
|
|
305
|
+
? (JSON.parse(row.snapshot) as PrSnapshot)
|
|
306
|
+
: null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async clear(node: string): Promise<boolean> {
|
|
310
|
+
return (
|
|
311
|
+
this.#db.run(
|
|
312
|
+
`DELETE FROM watch
|
|
313
|
+
WHERE node_id = (SELECT id FROM node WHERE external_id = ?)`,
|
|
314
|
+
[node]
|
|
315
|
+
) > 0
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Open a watch on every PR item that has a PR and is not already watched and
|
|
321
|
+
* has not concluded. A PR moves whether or not a worker asked anyone to
|
|
322
|
+
* look, and an unwatched item is exactly the one whose change goes
|
|
323
|
+
* unnoticed.
|
|
324
|
+
*
|
|
325
|
+
* A `human-blocked` outcome counts as unconcluded: the item is waiting on a
|
|
326
|
+
* person, and the answer usually arrives on the PR. This is also what picks
|
|
327
|
+
* up an item parked before the watch survived its report — without it, a
|
|
328
|
+
* park that predates that behaviour stays unwatched for good.
|
|
329
|
+
*/
|
|
330
|
+
async ensureForLiveItems(at: string, expirySeconds: number): Promise<number> {
|
|
331
|
+
assertInstant(at, 'at');
|
|
332
|
+
const expiresAt = new Date(
|
|
333
|
+
Date.parse(at) + expirySeconds * 1_000
|
|
334
|
+
).toISOString();
|
|
335
|
+
return this.#db.run(
|
|
336
|
+
`INSERT INTO watch (node_id, state, snapshot, interval_s, session_id, created_at, expires_at)
|
|
337
|
+
SELECT pr.node_id, 'watching', NULL, 60, NULL, ?, ?
|
|
338
|
+
FROM pr
|
|
339
|
+
LEFT JOIN watch w ON w.node_id = pr.node_id
|
|
340
|
+
LEFT JOIN outcome o ON o.node_id = pr.node_id
|
|
341
|
+
WHERE pr.repo IS NOT NULL AND pr.pr_number IS NOT NULL
|
|
342
|
+
AND w.node_id IS NULL
|
|
343
|
+
AND (o.node_id IS NULL OR o.outcome = 'human-blocked')`,
|
|
344
|
+
[at, expiresAt]
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async get(node: string): Promise<{state: 'watching' | 'fired'} | null> {
|
|
349
|
+
const row = this.#db.get(
|
|
350
|
+
`SELECT w.state FROM watch w
|
|
351
|
+
JOIN node n ON n.id = w.node_id
|
|
352
|
+
WHERE n.external_id = ?`,
|
|
353
|
+
[node]
|
|
354
|
+
);
|
|
355
|
+
if (row === undefined) return null;
|
|
356
|
+
return {state: row.state as 'watching' | 'fired'};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,121 @@
|
|
|
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 {findNode} 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
|
+
* Where a node's worker can be reached. The orchestrate session records the
|
|
11
|
+
* agent ref it got back from a launch; the tick stamps it onto events for the
|
|
12
|
+
* node so the session can relay instead of cold-starting a resume pass.
|
|
13
|
+
*
|
|
14
|
+
* A row lives from launch to outcome — not to yield. A yielded worker has
|
|
15
|
+
* returned but is resumable with its context intact, and waking it with the
|
|
16
|
+
* event that ends its wait is the whole point of routing. Death is covered
|
|
17
|
+
* twice over: the row cascades with its session, and a stale ref relayed to a
|
|
18
|
+
* gone agent is a no-op the resume pass then catches.
|
|
19
|
+
*/
|
|
20
|
+
export class WorkerStore {
|
|
21
|
+
readonly #db: Database;
|
|
22
|
+
|
|
23
|
+
constructor(db: Database) {
|
|
24
|
+
this.#db = db;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async set(input: {
|
|
28
|
+
node: string;
|
|
29
|
+
session: string;
|
|
30
|
+
agentRef: string;
|
|
31
|
+
at: string;
|
|
32
|
+
}): Promise<void> {
|
|
33
|
+
assertInstant(input.at, 'at');
|
|
34
|
+
const agentRef = input.agentRef.trim();
|
|
35
|
+
ensure(
|
|
36
|
+
agentRef !== '',
|
|
37
|
+
() =>
|
|
38
|
+
new DataError('an empty agent ref is not an address', {
|
|
39
|
+
hint: 'pass the ref the launch returned.',
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
await this.#db.transaction(() => {
|
|
43
|
+
const node = findNode(this.#db, input.node);
|
|
44
|
+
ensure(
|
|
45
|
+
node !== null,
|
|
46
|
+
() =>
|
|
47
|
+
new DataError(`no node "${input.node}" to register a worker on`, {
|
|
48
|
+
hint: 'a worker is recorded for a node the graph already holds.',
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
// The address belongs to a dispatched worker, so the recorder must
|
|
52
|
+
// still hold the claim its launch took. This is also what closes the
|
|
53
|
+
// fast-worker race: an outcome recorded before the address deletes the
|
|
54
|
+
// claim, and the late `worker set` is then refused instead of
|
|
55
|
+
// recreating a row for an agent that already finished.
|
|
56
|
+
const claim = this.#db.get(
|
|
57
|
+
'SELECT session_id FROM claim WHERE node_id = ?',
|
|
58
|
+
[node.id]
|
|
59
|
+
);
|
|
60
|
+
ensure(
|
|
61
|
+
claim?.session_id === input.session,
|
|
62
|
+
() =>
|
|
63
|
+
new DataError(
|
|
64
|
+
`this session holds no claim on "${input.node}", so there is no dispatched worker to address`,
|
|
65
|
+
{
|
|
66
|
+
hint: 'record the address right after the launch, before anything else; if the worker already reported, there is nothing to route to.',
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
this.#db.run(
|
|
71
|
+
`INSERT INTO worker (node_id, session_id, agent_ref, launched_at)
|
|
72
|
+
VALUES (?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
74
|
+
session_id = excluded.session_id,
|
|
75
|
+
agent_ref = excluded.agent_ref,
|
|
76
|
+
launched_at = excluded.launched_at`,
|
|
77
|
+
[node.id, input.session, agentRef, input.at]
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The agent ref holding a node, for this session only: another session's
|
|
83
|
+
* worker is not addressable from here. */
|
|
84
|
+
async refFor(node: string, session: string): Promise<string | null> {
|
|
85
|
+
const row = this.#db.get(
|
|
86
|
+
`SELECT w.agent_ref FROM worker w
|
|
87
|
+
JOIN node n ON n.id = w.node_id
|
|
88
|
+
WHERE n.external_id = ? AND w.session_id = ?`,
|
|
89
|
+
[node, session]
|
|
90
|
+
);
|
|
91
|
+
return typeof row?.agent_ref === 'string' ? row.agent_ref : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hand a node from warm relay to cold recovery: drop the caller's own
|
|
96
|
+
* address and release its claim in one transaction. While either existed
|
|
97
|
+
* the item could not queue; with both gone the scheduler re-serves it as a
|
|
98
|
+
* `resume` pass. Scoped to the owning session — another session's address
|
|
99
|
+
* is not this caller's to revoke.
|
|
100
|
+
*/
|
|
101
|
+
async remove(node: string, session: string): Promise<boolean> {
|
|
102
|
+
return this.#db.transaction(() => {
|
|
103
|
+
const removed = this.#db.run(
|
|
104
|
+
`DELETE FROM worker
|
|
105
|
+
WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
|
|
106
|
+
AND session_id = ?`,
|
|
107
|
+
[node, session]
|
|
108
|
+
);
|
|
109
|
+
if (removed === 0) return false;
|
|
110
|
+
this.#db.run(
|
|
111
|
+
`DELETE FROM claim
|
|
112
|
+
WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
|
|
113
|
+
AND session_id = ?`,
|
|
114
|
+
[node, session]
|
|
115
|
+
);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
|
|
4
|
+
import {withDatabase} from '../db/index.mts';
|
|
5
|
+
import {nowIso} from '../db/time.mts';
|
|
6
|
+
import type {Logger} from '../logger/index.mts';
|
|
7
|
+
import {PrStore} from '../stores/index.mts';
|
|
8
|
+
import {findNode} from '../stores/materialize.mts';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
export interface OpenPr {
|
|
13
|
+
readonly number: number;
|
|
14
|
+
readonly headRefName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PrLister = (repo: string) => Promise<OpenPr[]>;
|
|
18
|
+
|
|
19
|
+
/** Open PRs authored by this identity, via gh. */
|
|
20
|
+
export const githubLister: PrLister = async (repo) => {
|
|
21
|
+
const {stdout} = await run(
|
|
22
|
+
'gh',
|
|
23
|
+
[
|
|
24
|
+
'pr',
|
|
25
|
+
'list',
|
|
26
|
+
'--repo',
|
|
27
|
+
repo,
|
|
28
|
+
'--author',
|
|
29
|
+
'@me',
|
|
30
|
+
'--state',
|
|
31
|
+
'open',
|
|
32
|
+
'--json',
|
|
33
|
+
'number,headRefName',
|
|
34
|
+
],
|
|
35
|
+
{timeout: 30_000, maxBuffer: 8 * 1024 * 1024}
|
|
36
|
+
);
|
|
37
|
+
return JSON.parse(stdout) as OpenPr[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Adopt agent-authored PRs the graph does not know.
|
|
42
|
+
*
|
|
43
|
+
* Registered PR items are runtime state: a database rebuild loses them, and a
|
|
44
|
+
* worker crash can leave a PR open that nothing ever registered. An orphaned
|
|
45
|
+
* PR has no watch and no worker, so it goes stale silently. This runs on the
|
|
46
|
+
* tick cadence rather than as a one-off reconciliation: every open PR this
|
|
47
|
+
* identity authored, in every repo the graph already knows, gets a PR item —
|
|
48
|
+
* `origin adopted`, ticket-linked when the branch leads with a ticket id the
|
|
49
|
+
* graph holds — and the existing machinery watches and schedules it like
|
|
50
|
+
* anything else.
|
|
51
|
+
*
|
|
52
|
+
* Repos come from existing PR items, so adoption never reaches into a repo
|
|
53
|
+
* the project has not already touched.
|
|
54
|
+
*/
|
|
55
|
+
export async function adoptOrphans(
|
|
56
|
+
env: NodeJS.ProcessEnv,
|
|
57
|
+
opts: {
|
|
58
|
+
list?: PrLister;
|
|
59
|
+
dbPath?: string | undefined;
|
|
60
|
+
log?: Logger | undefined;
|
|
61
|
+
} = {}
|
|
62
|
+
): Promise<number> {
|
|
63
|
+
const list = opts.list ?? githubLister;
|
|
64
|
+
return withDatabase(opts.dbPath, env, async (db) => {
|
|
65
|
+
const prs = new PrStore(db);
|
|
66
|
+
const repos = db
|
|
67
|
+
.all('SELECT DISTINCT repo FROM pr WHERE repo IS NOT NULL')
|
|
68
|
+
.map((row) => String(row.repo));
|
|
69
|
+
let adopted = 0;
|
|
70
|
+
for (const repo of repos) {
|
|
71
|
+
let open: OpenPr[];
|
|
72
|
+
try {
|
|
73
|
+
open = await list(repo);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
opts.log?.warn('adoption listing failed', {
|
|
76
|
+
repo,
|
|
77
|
+
error: error instanceof Error ? error.message : String(error),
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// What "known" means, and why numbers and branches are scoped
|
|
82
|
+
// differently. A number is a PR's permanent identity: it is never reused
|
|
83
|
+
// for a different PR, and re-adopting one can never help — upsert only
|
|
84
|
+
// rewrites the pr row, it does not clear a stale outcome — so every
|
|
85
|
+
// numbered row contributes its number, concluded or not. Re-adopting a
|
|
86
|
+
// reopened-but-still-concluded PR would just churn a phantom adoption
|
|
87
|
+
// each tick without ever making it live again.
|
|
88
|
+
//
|
|
89
|
+
// A branch is not an identity key. Two open PRs can share a head name
|
|
90
|
+
// (same-named branches across forks), so a numbered row's branch must
|
|
91
|
+
// not suppress a distinct fork PR that reuses it — the number already
|
|
92
|
+
// guards that row. Only a row with no number yet contributes its branch:
|
|
93
|
+
// a ticket-worker's registration made before its PR opens, which
|
|
94
|
+
// adoption must not race. And only while that row is live — once it
|
|
95
|
+
// concludes, its branch is free for later work to reuse, and a stale row
|
|
96
|
+
// must not block adopting the new PR that inherits the name.
|
|
97
|
+
const known = new Set<string>();
|
|
98
|
+
for (const row of db.all(
|
|
99
|
+
`SELECT p.branch AS branch, p.pr_number AS pr_number,
|
|
100
|
+
o.node_id AS outcome_node
|
|
101
|
+
FROM pr p
|
|
102
|
+
LEFT JOIN outcome o ON o.node_id = p.node_id
|
|
103
|
+
WHERE p.repo = ?`,
|
|
104
|
+
[repo]
|
|
105
|
+
)) {
|
|
106
|
+
if (typeof row.pr_number === 'number')
|
|
107
|
+
known.add(`#${String(row.pr_number)}`);
|
|
108
|
+
else if (
|
|
109
|
+
row.outcome_node === null &&
|
|
110
|
+
typeof row.branch === 'string' &&
|
|
111
|
+
row.branch !== ''
|
|
112
|
+
)
|
|
113
|
+
known.add(row.branch);
|
|
114
|
+
}
|
|
115
|
+
for (const pr of open) {
|
|
116
|
+
// A listing with no number or head ref is malformed; skip it rather
|
|
117
|
+
// than register a nameless item.
|
|
118
|
+
if (!Number.isInteger(pr.number) || pr.headRefName === '') continue;
|
|
119
|
+
if (known.has(`#${String(pr.number)}`) || known.has(pr.headRefName)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Remember the number so the same PR is not re-adopted; not the head
|
|
123
|
+
// name, since a different fork PR can share it and still deserves a
|
|
124
|
+
// row of its own.
|
|
125
|
+
known.add(`#${String(pr.number)}`);
|
|
126
|
+
// A branch led by a ticket id the graph holds is that ticket's work.
|
|
127
|
+
const match = /^([a-z]+-\d+)/iu.exec(pr.headRefName);
|
|
128
|
+
const ticketId = match?.[1]?.toUpperCase();
|
|
129
|
+
const ticket =
|
|
130
|
+
ticketId !== undefined && findNode(db, ticketId)?.kind === 'ticket'
|
|
131
|
+
? ticketId
|
|
132
|
+
: null;
|
|
133
|
+
await prs.upsertPr({
|
|
134
|
+
id: `${repo}#${String(pr.number)}`,
|
|
135
|
+
ticket,
|
|
136
|
+
origin: 'adopted',
|
|
137
|
+
repo,
|
|
138
|
+
prNumber: pr.number,
|
|
139
|
+
url: `https://github.com/${repo}/pull/${String(pr.number)}`,
|
|
140
|
+
branch: pr.headRefName,
|
|
141
|
+
title: `Adopted open PR #${String(pr.number)} (${pr.headRefName})`,
|
|
142
|
+
injected: false,
|
|
143
|
+
priority: null,
|
|
144
|
+
updatedAt: nowIso(),
|
|
145
|
+
});
|
|
146
|
+
adopted += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return adopted;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {Database} from '../db/database.mts';
|
|
2
|
+
import {WatchStore} from '../stores/index.mts';
|
|
3
|
+
import type {Logger} from '../logger/index.mts';
|
|
4
|
+
import {cadenceFor, EXPIRY_SECONDS} from './cadence.mts';
|
|
5
|
+
import type {PrSnapshot, Snapshotter} from './snapshot.mts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hand an item back to the server: record the PR as it stands now and release
|
|
9
|
+
* the worker's claim, in one transaction.
|
|
10
|
+
*
|
|
11
|
+
* The baseline is what the next poll diffs against. A failed read degrades to
|
|
12
|
+
* priming on the first successful poll, which widens the window in which a
|
|
13
|
+
* change goes unreported — taken over failing the handoff.
|
|
14
|
+
*/
|
|
15
|
+
export async function armWatch(
|
|
16
|
+
db: Database,
|
|
17
|
+
input: {
|
|
18
|
+
node: string;
|
|
19
|
+
repo: string;
|
|
20
|
+
prNumber: number;
|
|
21
|
+
at: string;
|
|
22
|
+
snapshot: Snapshotter;
|
|
23
|
+
session: string | null;
|
|
24
|
+
releaseClaimFor?: string | null;
|
|
25
|
+
log?: Logger | undefined;
|
|
26
|
+
}
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
let baseline: PrSnapshot | null = null;
|
|
29
|
+
try {
|
|
30
|
+
baseline = await input.snapshot(input.repo, input.prNumber);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
input.log?.warn('watch armed without a baseline snapshot', {
|
|
33
|
+
node: input.node,
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await new WatchStore(db).set({
|
|
38
|
+
node: input.node,
|
|
39
|
+
intervalSeconds: cadenceFor(baseline),
|
|
40
|
+
at: input.at,
|
|
41
|
+
expiresAt: new Date(
|
|
42
|
+
Date.parse(input.at) + EXPIRY_SECONDS * 1_000
|
|
43
|
+
).toISOString(),
|
|
44
|
+
snapshot: baseline,
|
|
45
|
+
session: input.session,
|
|
46
|
+
releaseClaimFor: input.releaseClaimFor ?? null,
|
|
47
|
+
});
|
|
48
|
+
}
|