@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,45 @@
|
|
|
1
|
+
import type {PrSnapshot} from './snapshot.mts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long to wait before reading a PR again, derived from where the PR
|
|
5
|
+
* actually is rather than from a reason the worker declared.
|
|
6
|
+
*
|
|
7
|
+
* A worker cannot reliably say what it is waiting for — it may be waiting for
|
|
8
|
+
* several things at once, and whatever it declared goes stale the moment the
|
|
9
|
+
* PR moves. The PR's own state is the better signal and the server already
|
|
10
|
+
* has it, which is also what makes the interval data-driven rather than a
|
|
11
|
+
* constant table.
|
|
12
|
+
*/
|
|
13
|
+
export const CADENCE_SECONDS = {
|
|
14
|
+
/** Checks are running: the next transition is close. */
|
|
15
|
+
ciActive: 60,
|
|
16
|
+
/** Checks have reported and someone must look: a person's timescale. */
|
|
17
|
+
awaitingReview: 300,
|
|
18
|
+
/** Nothing is pending; watch only for an out-of-band change. */
|
|
19
|
+
idle: 900,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export function cadenceFor(snapshot: PrSnapshot | null): number {
|
|
23
|
+
if (snapshot === null) return CADENCE_SECONDS.ciActive;
|
|
24
|
+
if (snapshot.checks.some((check) => check.conclusion === null)) {
|
|
25
|
+
return CADENCE_SECONDS.ciActive;
|
|
26
|
+
}
|
|
27
|
+
// Out of draft with the forge still asking for review, or a reviewer who
|
|
28
|
+
// has not returned a verdict.
|
|
29
|
+
const awaiting =
|
|
30
|
+
!snapshot.draft &&
|
|
31
|
+
(snapshot.reviewDecision === 'REVIEW_REQUIRED' ||
|
|
32
|
+
snapshot.reviews.some((review) => review.state === 'PENDING'));
|
|
33
|
+
return awaiting ? CADENCE_SECONDS.awaitingReview : CADENCE_SECONDS.idle;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How long a watch runs before firing regardless of the diff. The snapshot
|
|
38
|
+
* sees only the forge, so an approval given on the ticket or a reaction never
|
|
39
|
+
* reaches it; expiry sends the worker to look for itself.
|
|
40
|
+
*
|
|
41
|
+
* Measured from arming, never from the last poll: a deadline a poll can push
|
|
42
|
+
* out is one a quiet PR never reaches, and a quiet PR is the only kind that
|
|
43
|
+
* needs it. The price is a wasted look at every unchanged PR once a window.
|
|
44
|
+
*/
|
|
45
|
+
export const EXPIRY_SECONDS = 21_600;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type {PrSnapshot} from './snapshot.mts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event kinds, four of them the channel-server event catalog's PR/CI triggers.
|
|
5
|
+
*
|
|
6
|
+
* `pr_conflicted` and `pr_head_changed` are additions: the catalog has no
|
|
7
|
+
* event for "the base moved and this no longer merges" or "someone else
|
|
8
|
+
* pushed to the branch", and a worker waiting on merge must react to both.
|
|
9
|
+
* Adding kinds is allowed; renaming the catalog's four would be breaking.
|
|
10
|
+
*/
|
|
11
|
+
export const OBSERVATION_KINDS = [
|
|
12
|
+
'ci_finished',
|
|
13
|
+
'pr_review',
|
|
14
|
+
'pr_comment',
|
|
15
|
+
'pr_state_change',
|
|
16
|
+
'pr_conflicted',
|
|
17
|
+
'pr_head_changed',
|
|
18
|
+
// Not a PR observation: written by `ticket set` when a tracker write
|
|
19
|
+
// reveals a status transition. Same queue, same delivery.
|
|
20
|
+
'ticket_changed',
|
|
21
|
+
// Also not a diff: written when a watch reaches its deadline. It reports
|
|
22
|
+
// that nothing the snapshot can see changed, which is the one thing a diff
|
|
23
|
+
// can never say — and the signal it stands in for (an approval on the
|
|
24
|
+
// ticket, a reaction, a go-ahead out of band) is outside the forge.
|
|
25
|
+
'watch_expired',
|
|
26
|
+
] as const;
|
|
27
|
+
export type ObservationKind = (typeof OBSERVATION_KINDS)[number];
|
|
28
|
+
|
|
29
|
+
export interface Observation {
|
|
30
|
+
readonly kind: ObservationKind;
|
|
31
|
+
/**
|
|
32
|
+
* Per-kind channel meta, beyond the `repo`/`pr` the pusher adds. Values are
|
|
33
|
+
* stringified at push time; the runner drops a non-string.
|
|
34
|
+
*/
|
|
35
|
+
readonly meta: Readonly<Record<string, string>>;
|
|
36
|
+
/** One line for a human reading the log. The pushed body is `pr-status`. */
|
|
37
|
+
readonly summary: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check conclusions that mean "this check is OK". Anything terminal and not
|
|
42
|
+
* in this set counts as a failure, so a conclusion GitHub adds later reads as
|
|
43
|
+
* failing rather than silently passing.
|
|
44
|
+
*/
|
|
45
|
+
const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
46
|
+
|
|
47
|
+
function failing(snapshot: PrSnapshot): string[] {
|
|
48
|
+
return snapshot.checks
|
|
49
|
+
.filter(
|
|
50
|
+
(check) => check.conclusion !== null && !PASSING.has(check.conclusion)
|
|
51
|
+
)
|
|
52
|
+
.map((check) => check.name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function settled(snapshot: PrSnapshot): boolean {
|
|
56
|
+
return (
|
|
57
|
+
snapshot.checks.length > 0 &&
|
|
58
|
+
snapshot.checks.every((check) => check.conclusion !== null)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The forge's word for "this no longer merges cleanly". */
|
|
63
|
+
const CONFLICTED = 'DIRTY';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What changed between two observations of one PR.
|
|
67
|
+
*
|
|
68
|
+
* Authorship is judged by this agent's machine marker, never by the account:
|
|
69
|
+
* under shared credentials the agent posts as the operator, so filtering by
|
|
70
|
+
* login would suppress the operator's own review — the one signal a waiting
|
|
71
|
+
* worker most needs. The marker says "this agent wrote it" whatever account
|
|
72
|
+
* carried it. Waking a worker to report its own comment is the noise that
|
|
73
|
+
* would make server-side waiting worse than the polling it replaces.
|
|
74
|
+
*
|
|
75
|
+
* A null `previous` is the first observation after arming, and yields
|
|
76
|
+
* nothing. The wait's own condition is evaluated separately by the caller,
|
|
77
|
+
* which is what catches a change that landed before the baseline was taken.
|
|
78
|
+
*/
|
|
79
|
+
export function diffSnapshots(
|
|
80
|
+
previous: PrSnapshot | null,
|
|
81
|
+
next: PrSnapshot
|
|
82
|
+
): Observation[] {
|
|
83
|
+
if (previous === null) return [];
|
|
84
|
+
const events: Observation[] = [];
|
|
85
|
+
|
|
86
|
+
if (next.merged && !previous.merged) {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
kind: 'pr_state_change',
|
|
90
|
+
meta: {state: 'merged'},
|
|
91
|
+
summary: 'The PR merged.',
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (next.state === 'CLOSED' && previous.state !== 'CLOSED') {
|
|
97
|
+
// Whether a closed-unmerged PR actually shipped is `pr-status`'s call — a
|
|
98
|
+
// squash or rebase can land the content without setting `merged`. The
|
|
99
|
+
// event says the lifecycle moved; the body tells the worker which way.
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
kind: 'pr_state_change',
|
|
103
|
+
meta: {state: 'closed'},
|
|
104
|
+
summary: 'The PR closed.',
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (next.draft !== previous.draft) {
|
|
110
|
+
events.push({
|
|
111
|
+
kind: 'pr_state_change',
|
|
112
|
+
meta: {state: next.draft ? 'draft' : 'ready'},
|
|
113
|
+
summary: next.draft
|
|
114
|
+
? 'The PR went back to draft.'
|
|
115
|
+
: 'The PR left draft and is ready for review.',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (next.head !== previous.head && next.head !== null) {
|
|
120
|
+
events.push({
|
|
121
|
+
kind: 'pr_head_changed',
|
|
122
|
+
meta: {head: next.head},
|
|
123
|
+
summary: `The head commit moved to ${next.head.slice(0, 8)}.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// CI is reported per rollup, not per check: `ci_finished` fires when the
|
|
128
|
+
// rollup reaches a terminal state. Naming the failures in meta is the
|
|
129
|
+
// detail the rollup verdict alone cannot carry. Comparing the failing set
|
|
130
|
+
// (not just settled-ness) is what catches a rerun that goes straight from
|
|
131
|
+
// failing to green between two polls.
|
|
132
|
+
const nowFailing = failing(next);
|
|
133
|
+
const rollupChanged =
|
|
134
|
+
settled(next) &&
|
|
135
|
+
(!settled(previous) ||
|
|
136
|
+
failing(previous).join(',') !== nowFailing.join(',') ||
|
|
137
|
+
previous.head !== next.head);
|
|
138
|
+
if (rollupChanged) {
|
|
139
|
+
events.push({
|
|
140
|
+
kind: 'ci_finished',
|
|
141
|
+
meta: {
|
|
142
|
+
rollup: nowFailing.length > 0 ? 'failure' : 'success',
|
|
143
|
+
...(nowFailing.length > 0 ? {failing: nowFailing.join(',')} : {}),
|
|
144
|
+
},
|
|
145
|
+
summary:
|
|
146
|
+
nowFailing.length > 0
|
|
147
|
+
? `CI finished with failures: ${nowFailing.join(', ')}.`
|
|
148
|
+
: 'CI finished green.',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A review is identified by (author, state, submittedAt): a reviewer walks
|
|
153
|
+
// pending -> verdict, and a re-request pushes them back to pending, so the
|
|
154
|
+
// author alone cannot tell a new verdict from an old one.
|
|
155
|
+
const seenReviews = new Set(
|
|
156
|
+
previous.reviews.map(
|
|
157
|
+
(review) => `${review.author} ${review.state} ${review.submittedAt ?? ''}`
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
for (const review of next.reviews) {
|
|
161
|
+
const key = `${review.author} ${review.state} ${review.submittedAt ?? ''}`;
|
|
162
|
+
if (seenReviews.has(key)) continue;
|
|
163
|
+
if (review.state === 'PENDING' || review.mine) continue;
|
|
164
|
+
events.push({
|
|
165
|
+
kind: 'pr_review',
|
|
166
|
+
meta: {state: reviewState(review.state), reviewer: review.author},
|
|
167
|
+
summary: `${review.author} left a ${reviewState(review.state)} review.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const before = new Map(previous.threads.map((thread) => [thread.id, thread]));
|
|
172
|
+
for (const thread of next.threads) {
|
|
173
|
+
const prior = before.get(thread.id);
|
|
174
|
+
const moved =
|
|
175
|
+
prior?.lastAt !== thread.lastAt || prior.resolved !== thread.resolved;
|
|
176
|
+
if (!moved || thread.resolved || thread.mine) continue;
|
|
177
|
+
events.push({
|
|
178
|
+
kind: 'pr_comment',
|
|
179
|
+
meta: {thread: thread.id},
|
|
180
|
+
summary:
|
|
181
|
+
prior === undefined
|
|
182
|
+
? `${thread.lastAuthor ?? 'someone'} opened a review thread.`
|
|
183
|
+
: `${thread.lastAuthor ?? 'someone'} replied on a review thread.`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// An id absent from the previous window is only new when that window held
|
|
188
|
+
// every comment. Past the cap the older entries fall out, and treating
|
|
189
|
+
// their reappearance as new would fire on comments from last week.
|
|
190
|
+
const windowComplete = previous.totals.comments <= previous.comments.length;
|
|
191
|
+
const known = new Set(previous.comments.map((comment) => comment.id));
|
|
192
|
+
for (const comment of next.comments) {
|
|
193
|
+
if (known.has(comment.id) || comment.mine) continue;
|
|
194
|
+
// Strictly older only. Timestamps are not a unique key, so a comment
|
|
195
|
+
// sharing the newest one's second would otherwise be dropped for good —
|
|
196
|
+
// the snapshot advances past it and no later tick can rediscover it.
|
|
197
|
+
if (!windowComplete && comment.createdAt < newestOf(previous.comments)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
events.push({
|
|
201
|
+
kind: 'pr_comment',
|
|
202
|
+
meta: {thread: comment.id},
|
|
203
|
+
summary: `${comment.author} commented on the PR.`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (next.mergeState === CONFLICTED && previous.mergeState !== CONFLICTED) {
|
|
208
|
+
events.push({
|
|
209
|
+
kind: 'pr_conflicted',
|
|
210
|
+
meta: {mergeState: next.mergeState},
|
|
211
|
+
summary: 'The PR now conflicts with its base branch.',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return coalesce(events);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function newestOf(comments: PrSnapshot['comments']): string {
|
|
219
|
+
return comments.reduce(
|
|
220
|
+
(newest, comment) =>
|
|
221
|
+
comment.createdAt > newest ? comment.createdAt : newest,
|
|
222
|
+
''
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function reviewState(state: string): string {
|
|
227
|
+
if (state === 'APPROVED') return 'approved';
|
|
228
|
+
if (state === 'CHANGES_REQUESTED') return 'changes';
|
|
229
|
+
return 'comment';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Everything one tick saw about one PR, as a single event.
|
|
234
|
+
*
|
|
235
|
+
* The alternative — one event per change — interrupts a worker mid-reaction:
|
|
236
|
+
* it is told CI failed, starts fixing, and is then told a reviewer replied,
|
|
237
|
+
* which it must handle as a second turn without the first one's context. The
|
|
238
|
+
* worker already reads one `pr-status` blob per tick and reacts to everything
|
|
239
|
+
* in it at once; the channel should not be worse than that.
|
|
240
|
+
*
|
|
241
|
+
* So the kind is a routing hint — the most significant thing that moved —
|
|
242
|
+
* `changed` lists every kind that fired, and the per-kind specifics ride
|
|
243
|
+
* along in meta. The body carries the state the worker acts on.
|
|
244
|
+
*/
|
|
245
|
+
const PRIORITY: readonly ObservationKind[] = [
|
|
246
|
+
'pr_state_change',
|
|
247
|
+
'pr_conflicted',
|
|
248
|
+
'ci_finished',
|
|
249
|
+
'pr_review',
|
|
250
|
+
'pr_head_changed',
|
|
251
|
+
'pr_comment',
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
function coalesce(events: readonly Observation[]): Observation[] {
|
|
255
|
+
if (events.length <= 1) return [...events];
|
|
256
|
+
const ranked = [...events].sort(
|
|
257
|
+
(a, b) => PRIORITY.indexOf(a.kind) - PRIORITY.indexOf(b.kind)
|
|
258
|
+
);
|
|
259
|
+
const [lead] = ranked;
|
|
260
|
+
if (lead === undefined) return [];
|
|
261
|
+
const meta: Record<string, string> = {};
|
|
262
|
+
// Least significant first, so the lead event's own keys win a collision.
|
|
263
|
+
for (const event of [...ranked].reverse()) {
|
|
264
|
+
for (const [key, value] of Object.entries(event.meta)) meta[key] = value;
|
|
265
|
+
}
|
|
266
|
+
meta.changed = [...new Set(ranked.map((event) => event.kind))].join(',');
|
|
267
|
+
return [
|
|
268
|
+
{
|
|
269
|
+
kind: lead.kind,
|
|
270
|
+
meta,
|
|
271
|
+
summary: ranked.map((event) => event.summary).join(' '),
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {armWatch} from './arm.mts';
|
|
2
|
+
export {diffSnapshots, OBSERVATION_KINDS} from './diff.mts';
|
|
3
|
+
export type {Observation, ObservationKind} from './diff.mts';
|
|
4
|
+
export {pollWatches} from './poll.mts';
|
|
5
|
+
export {githubSnapshot, SNAPSHOT_QUERY} from './snapshot.mts';
|
|
6
|
+
export type {PrSnapshot, Snapshotter} from './snapshot.mts';
|
|
7
|
+
export {AGENT_ID, writtenByThisAgent} from './marker.mts';
|
|
8
|
+
export {prStatusPayload, prStatusScript} from './payload.mts';
|
|
9
|
+
export {cadenceFor, CADENCE_SECONDS, EXPIRY_SECONDS} from './cadence.mts';
|
|
10
|
+
export {renderSnapshot} from './render.mts';
|
|
11
|
+
export {adoptOrphans, githubLister} from './adopt.mts';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent id this plugin writes into its machine markers. The wire format
|
|
3
|
+
* is `<!-- agent-reply:<agent-id> -->` on a post's first line.
|
|
4
|
+
*/
|
|
5
|
+
export const AGENT_ID = 'dispatch';
|
|
6
|
+
|
|
7
|
+
const MARKER = new RegExp(`^\\s*<!--\\s*agent-reply:${AGENT_ID}\\s*-->`, 'iu');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this agent wrote a post, judged by its own marker rather than by
|
|
11
|
+
* the authoring account.
|
|
12
|
+
*
|
|
13
|
+
* The account is the wrong test. Under shared credentials the agent posts as
|
|
14
|
+
* the operator, so filtering by login would suppress the operator's own
|
|
15
|
+
* review — the one signal a waiting worker most needs. The marker is written
|
|
16
|
+
* by this agent and by nothing else, so it identifies authorship regardless
|
|
17
|
+
* of which account carried it.
|
|
18
|
+
*
|
|
19
|
+
* Matching the agent id specifically, not a bare `agent-reply`, keeps another
|
|
20
|
+
* tool's marked post actionable.
|
|
21
|
+
*/
|
|
22
|
+
export function writtenByThisAgent(body: string | null | undefined): boolean {
|
|
23
|
+
return typeof body === 'string' && MARKER.test(body);
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
|
|
4
|
+
import type {Logger} from '../logger/index.mts';
|
|
5
|
+
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long one payload read may take before the tick moves on without it.
|
|
10
|
+
*
|
|
11
|
+
* This bounds a heartbeat, not just a read. Payload reads happen inside the
|
|
12
|
+
* tick, and a session whose heartbeat stops for the staleness window is swept
|
|
13
|
+
* — its claims cascade and its work is re-dispatched underneath it. The
|
|
14
|
+
* budget below keeps the worst case an order of magnitude short of that.
|
|
15
|
+
*/
|
|
16
|
+
const TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `pr-status` payload for a PR, which is what a PR/CI event body must
|
|
20
|
+
* carry: the worker then reacts to everything the tick saw in one turn, from
|
|
21
|
+
* the same document it would have read itself.
|
|
22
|
+
*
|
|
23
|
+
* `--repo` is what makes this reachable from the server, which stands in one
|
|
24
|
+
* directory and watches PRs across repos.
|
|
25
|
+
*
|
|
26
|
+
* A failed read returns null rather than failing the tick. The event still
|
|
27
|
+
* goes out with its summary, and the worker reads `pr-status` itself — a
|
|
28
|
+
* degraded wake-up beats a silent one.
|
|
29
|
+
*/
|
|
30
|
+
export async function prStatusPayload(
|
|
31
|
+
repo: string,
|
|
32
|
+
prNumber: number,
|
|
33
|
+
opts: {script: string; log?: Logger | undefined}
|
|
34
|
+
): Promise<string | null> {
|
|
35
|
+
try {
|
|
36
|
+
const {stdout} = await run(
|
|
37
|
+
opts.script,
|
|
38
|
+
['--repo', repo, String(prNumber)],
|
|
39
|
+
{timeout: TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024}
|
|
40
|
+
);
|
|
41
|
+
const payload = stdout.trim();
|
|
42
|
+
return payload === '' ? null : payload;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
opts.log?.warn('could not read the pr-status payload', {
|
|
45
|
+
repo,
|
|
46
|
+
pr: prNumber,
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The bundled `pr-status`, resolved against this file rather than the cwd. */
|
|
54
|
+
export function prStatusScript(): string {
|
|
55
|
+
return new URL('../../../bin/pr-status', import.meta.url).pathname;
|
|
56
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {nowIso} from '../db/time.mts';
|
|
2
|
+
import {withDatabase} from '../db/index.mts';
|
|
3
|
+
import type {Logger} from '../logger/index.mts';
|
|
4
|
+
import {WatchStore} from '../stores/index.mts';
|
|
5
|
+
import {cadenceFor, EXPIRY_SECONDS} from './cadence.mts';
|
|
6
|
+
import {diffSnapshots} from './diff.mts';
|
|
7
|
+
import type {Snapshotter} from './snapshot.mts';
|
|
8
|
+
|
|
9
|
+
/** Snapshot calls one pass will make; keeps a tick short. */
|
|
10
|
+
const MAX_POLLS_PER_PASS = 10;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One polling pass over the due watches: fire the expired ones outright,
|
|
14
|
+
* snapshot the rest, and record what changed as events.
|
|
15
|
+
*
|
|
16
|
+
* Short of its deadline, a watch fires only when the diff produced something
|
|
17
|
+
* — an unchanged PR, or one that changed in a way only the agent itself
|
|
18
|
+
* caused, leaves the row watching. That is the whole point of diffing
|
|
19
|
+
* structurally rather than hashing: a worker is woken for a reason it can be
|
|
20
|
+
* told. Expiry is the one wake with no reason to give, and says so.
|
|
21
|
+
*
|
|
22
|
+
* A parked item never reads as expired (`due`), so for it the diff is the
|
|
23
|
+
* only thing that fires: it is waiting on a person, and "your six hours are
|
|
24
|
+
* up" is not an answer.
|
|
25
|
+
*
|
|
26
|
+
* A failed snapshot costs only that row's interval: `touch` pushes the retry
|
|
27
|
+
* out so a broken PR is not hammered every tick, and the error goes to the
|
|
28
|
+
* log rather than failing the pass.
|
|
29
|
+
*/
|
|
30
|
+
export async function pollWatches(
|
|
31
|
+
env: NodeJS.ProcessEnv,
|
|
32
|
+
opts: {
|
|
33
|
+
snapshot: Snapshotter;
|
|
34
|
+
dbPath?: string | undefined;
|
|
35
|
+
now?: () => string;
|
|
36
|
+
log?: Logger | undefined;
|
|
37
|
+
}
|
|
38
|
+
): Promise<{fired: string[]}> {
|
|
39
|
+
const now = opts.now ?? nowIso;
|
|
40
|
+
return withDatabase(opts.dbPath, env, async (db) => {
|
|
41
|
+
const watches = new WatchStore(db);
|
|
42
|
+
// Every unconcluded PR item is watched — including one parked on an
|
|
43
|
+
// operator — whether or not a worker ever asked. A PR moves whether
|
|
44
|
+
// anyone is waiting on it, and an item nobody armed is exactly the one
|
|
45
|
+
// whose change would otherwise be missed.
|
|
46
|
+
await watches.ensureForLiveItems(now(), EXPIRY_SECONDS);
|
|
47
|
+
const fired: string[] = [];
|
|
48
|
+
|
|
49
|
+
for (const due of await watches.due(now(), MAX_POLLS_PER_PASS)) {
|
|
50
|
+
if (due.expired) {
|
|
51
|
+
if ((await watches.fire(due.node, now(), due.createdAt)) === 'fired') {
|
|
52
|
+
fired.push(due.node);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const taken = await opts.snapshot(due.repo, due.prNumber);
|
|
58
|
+
const observed = diffSnapshots(due.snapshot, taken);
|
|
59
|
+
// Changed or not changed is the only question. There is no predicate
|
|
60
|
+
// for "has the thing you were waiting for happened", because a worker
|
|
61
|
+
// cannot say what it is waiting for and any such predicate tests a
|
|
62
|
+
// persistent state — which fires again the moment the worker returns.
|
|
63
|
+
//
|
|
64
|
+
// One transaction: recording events and firing the watch must not be
|
|
65
|
+
// separable, or a crash between them re-emits the same events on the
|
|
66
|
+
// next tick against the same unchanged snapshot.
|
|
67
|
+
const outcome = await watches.observe({
|
|
68
|
+
node: due.node,
|
|
69
|
+
snapshot: taken,
|
|
70
|
+
at: now(),
|
|
71
|
+
createdAt: due.createdAt,
|
|
72
|
+
fire: observed.length > 0,
|
|
73
|
+
intervalSeconds: cadenceFor(taken),
|
|
74
|
+
events: observed,
|
|
75
|
+
});
|
|
76
|
+
if (outcome === 'fired') fired.push(due.node);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
await watches.touch(due.node, now(), due.createdAt);
|
|
79
|
+
opts.log?.error('watch poll failed', {
|
|
80
|
+
node: due.node,
|
|
81
|
+
error: error instanceof Error ? error.message : String(error),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {fired};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {PrSnapshot} from './snapshot.mts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render an event body from the snapshot the server already holds.
|
|
5
|
+
*
|
|
6
|
+
* This replaced shelling out to `pr-status` per event. That script makes
|
|
7
|
+
* several `gh` calls, writes cache files, and invokes `claude` to summarize —
|
|
8
|
+
* a deep read a worker runs for itself when it needs actionability and cached
|
|
9
|
+
* bodies. The event body's job is smaller: say where the PR stands, in the
|
|
10
|
+
* same XML vocabulary the worker already reads, so one wakeup carries
|
|
11
|
+
* everything one tick saw without costing a subprocess per event.
|
|
12
|
+
*/
|
|
13
|
+
export function renderSnapshot(
|
|
14
|
+
repo: string,
|
|
15
|
+
prNumber: number,
|
|
16
|
+
snapshot: PrSnapshot
|
|
17
|
+
): string {
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
const esc = (value: string): string =>
|
|
20
|
+
value
|
|
21
|
+
.replaceAll('&', '&')
|
|
22
|
+
.replaceAll('<', '<')
|
|
23
|
+
.replaceAll('"', '"');
|
|
24
|
+
|
|
25
|
+
lines.push(
|
|
26
|
+
`<pr-event repo="${esc(repo)}" pr="${String(prNumber)}" head="${esc(snapshot.head ?? '')}" state="${esc(snapshot.state ?? '')}" draft="${String(snapshot.draft)}" merged="${String(snapshot.merged)}">`
|
|
27
|
+
);
|
|
28
|
+
const rollup = snapshot.rollup ?? 'NONE';
|
|
29
|
+
lines.push(` <checks state="${esc(rollup)}">`);
|
|
30
|
+
for (const check of snapshot.checks) {
|
|
31
|
+
lines.push(
|
|
32
|
+
` <check name="${esc(check.name)}" conclusion="${esc(check.conclusion ?? 'PENDING')}"${check.url === null ? '' : ` url="${esc(check.url)}"`}/>`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
lines.push(' </checks>');
|
|
36
|
+
lines.push(
|
|
37
|
+
` <merge-conflicts present="${String(snapshot.mergeState === 'DIRTY')}"/>`
|
|
38
|
+
);
|
|
39
|
+
lines.push(
|
|
40
|
+
` <review-decision>${esc(snapshot.reviewDecision ?? 'NONE')}</review-decision>`
|
|
41
|
+
);
|
|
42
|
+
lines.push(' <reviews>');
|
|
43
|
+
for (const review of snapshot.reviews) {
|
|
44
|
+
lines.push(
|
|
45
|
+
` <review author="${esc(review.author)}" state="${esc(review.state)}" mine="${String(review.mine)}"/>`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
lines.push(' </reviews>');
|
|
49
|
+
lines.push(' <threads>');
|
|
50
|
+
for (const thread of snapshot.threads) {
|
|
51
|
+
lines.push(
|
|
52
|
+
` <thread id="${esc(thread.id)}" resolved="${String(thread.resolved)}" lastAuthor="${esc(thread.lastAuthor ?? '')}" mine="${String(thread.mine)}"/>`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
lines.push(' </threads>');
|
|
56
|
+
lines.push(
|
|
57
|
+
` <note>Snapshot rendered by the server. For actionability classification and cached bodies, run pr-status --repo ${esc(repo)} ${String(prNumber)} yourself.</note>`
|
|
58
|
+
);
|
|
59
|
+
lines.push('</pr-event>');
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|