omp-conductor 0.19.7 → 0.20.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/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -1
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only projections: everything that answers "what is the fleet doing"
|
|
3
|
+
* without changing it.
|
|
4
|
+
*
|
|
5
|
+
* `StatusSnapshot` is the whole reason this is one module. It is the single
|
|
6
|
+
* shape the CLI, the dashboard, the board and `/healthz` all render, and it is
|
|
7
|
+
* built here from the store rather than assembled per caller — three renderers
|
|
8
|
+
* projecting three nearly-identical shapes is how a status display starts
|
|
9
|
+
* disagreeing with itself. The `format*` helpers beside it are the shared
|
|
10
|
+
* fragments those renderers reach for.
|
|
11
|
+
*
|
|
12
|
+
* The rule that keeps the boundary honest: nothing here writes. A projection
|
|
13
|
+
* that also reconciled would make every caller of a display a caller of a
|
|
14
|
+
* mutation — which is why the historical-infra backfill lives in
|
|
15
|
+
* `settle-pass.ts` and only its results are read from here.
|
|
16
|
+
*
|
|
17
|
+
* `prepareConductor` is the one exception, and a deliberate one: it is setup's
|
|
18
|
+
* "create the store and hold dispatch" barrier, and it lives beside the snapshot
|
|
19
|
+
* because holding and then reading back the held state is one operator gesture.
|
|
20
|
+
*/
|
|
21
|
+
import { startOfToday } from "../admission.ts";
|
|
22
|
+
import { availabilityState, type AvailabilityState } from "../availability.ts";
|
|
23
|
+
import { configPath, findProject, loadConfig, resolveCaps, resolveReleaseGrants, resolveReview, stateDir } from "../config.ts";
|
|
24
|
+
import { digestScheduleState, type DigestScheduleState } from "../digest-schedule.ts";
|
|
25
|
+
import { SPEND_SAMPLE_ROWS, SPEND_SAMPLE_RUNS } from "../doctor.ts";
|
|
26
|
+
import type { CodeGraphHealth } from "../graph-health.ts";
|
|
27
|
+
import { isPaused, pauseProvenance, setPaused } from "../pause.ts";
|
|
28
|
+
import { branchName, route } from "../routing.ts";
|
|
29
|
+
import { judgeSpendTelemetry, type SpendTelemetryVerdict } from "../spend-telemetry.ts";
|
|
30
|
+
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
31
|
+
import { makeTracker, type RateLimitStatus } from "../tracker/github.ts";
|
|
32
|
+
import { DEFAULT_REPORT_POLICY, RELEASE_SHAPES, type BaseFreeze, type BaseHealth, type Caps, type DigestBacklog, type DispatchSummary, type HandoffWithdrawal, type InstallSurfaceObservation, type InterruptCategory, type OrchestratorIncident, type ProjectConfig, type ReportRecord, type ReportScope, type ReportingPolicy, type ResolvedGrants, type ReviewAdjudicationRecord, type ReviewCorrectionRound, type ReviewHeadBlocker, type ReviewPolicy, type RunRecord, type Store, type TurnOverride, type VerbLedgerEntry } from "../types.ts";
|
|
33
|
+
import { readUpgradeJournal } from "../upgrade-journal.ts";
|
|
34
|
+
import { upgradeRecoveryStatus, type UpgradeRecoveryStatus } from "../upgrade-verify.ts";
|
|
35
|
+
import type { PlanUsageStatus } from "../usage.ts";
|
|
36
|
+
import { STATUS_LEDGER_SCAN } from "../verbs/ledger.ts";
|
|
37
|
+
import type { WorkerPausePhase } from "../worker.ts";
|
|
38
|
+
import { UNROUTABLE_TEXT, type WorkerControlRegistry } from "./deps.ts";
|
|
39
|
+
import { readDrain } from "./drain.ts";
|
|
40
|
+
|
|
41
|
+
export interface DaemonHealthSnapshot {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
paused: boolean;
|
|
44
|
+
activeRuns: number;
|
|
45
|
+
project: string;
|
|
46
|
+
/** One-shot issue ceilings waiting for the next claim. */
|
|
47
|
+
turnOverrides: TurnOverride[];
|
|
48
|
+
dispatch?: DispatchSummary;
|
|
49
|
+
codeGraph?: CodeGraphHealth;
|
|
50
|
+
/** Live workers in a non-running pause phase; absent/empty = nothing paused.
|
|
51
|
+
* `source`/`pausedAtMs` carry the pause provenance when one was recorded (#997). */
|
|
52
|
+
workers?: { issue: number; runId: string; phase: WorkerPausePhase; source?: string; pausedAtMs?: number }[];
|
|
53
|
+
/**
|
|
54
|
+
* The live orchestrator surface, attested by the running daemon (#832):
|
|
55
|
+
* which mode this project's fleet is in, and — when the daemon hosts the
|
|
56
|
+
* orchestrator session itself — the extension version that session *loaded*
|
|
57
|
+
* and the transcript it resumed. This is the read the upgrade's
|
|
58
|
+
* session-reload verification checks: a restarted daemon whose orchestrator
|
|
59
|
+
* child came up on the installed release answers `loaded` equal to that
|
|
60
|
+
* release; one that never reloaded answers an older version; one that
|
|
61
|
+
* failed to start answers `mode: "failed"`. `external` means the pane owns
|
|
62
|
+
* the session and the daemon cannot attest it from here.
|
|
63
|
+
*/
|
|
64
|
+
orchestrator?: {
|
|
65
|
+
mode: "embedded" | "external" | "failed";
|
|
66
|
+
loaded?: string;
|
|
67
|
+
sessionFile?: string;
|
|
68
|
+
alive?: boolean;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DaemonHealth {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
/** Resident set of this daemon; workers are in-process omp sessions. */
|
|
75
|
+
rssBytes: number;
|
|
76
|
+
projects: DaemonHealthSnapshot[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function daemonHealthSnapshot(
|
|
80
|
+
store: Store,
|
|
81
|
+
project: string,
|
|
82
|
+
paused = isPaused(project),
|
|
83
|
+
codeGraph?: CodeGraphHealth,
|
|
84
|
+
workerControls?: WorkerControlRegistry,
|
|
85
|
+
orchestrator?: DaemonHealthSnapshot["orchestrator"],
|
|
86
|
+
): DaemonHealthSnapshot {
|
|
87
|
+
const dispatch = store.latestDispatch(project);
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
paused,
|
|
91
|
+
activeRuns: store.activeRuns(project).length,
|
|
92
|
+
turnOverrides: store.listTurnOverrides(project),
|
|
93
|
+
project,
|
|
94
|
+
...(dispatch === undefined ? {} : { dispatch }),
|
|
95
|
+
...(codeGraph?.configured === true ? { codeGraph } : {}),
|
|
96
|
+
...(workerControls === undefined ? {} : { workers: workerControls.snapshot(project) }),
|
|
97
|
+
...(orchestrator === undefined ? {} : { orchestrator }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function daemonHealth(
|
|
102
|
+
projects: DaemonHealthSnapshot[],
|
|
103
|
+
rssBytes = process.memoryUsage().rss,
|
|
104
|
+
): DaemonHealth {
|
|
105
|
+
return { ok: projects.every((project) => project.ok), rssBytes, projects };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The effective reporting surface, derived from the configured policy — never
|
|
110
|
+
* from the legacy preset name alone: an explicit policy without the
|
|
111
|
+
* `scopePreset` back-annotation must present the same truth from `interruptOn`
|
|
112
|
+
* and the digest cadence (#633).
|
|
113
|
+
*/
|
|
114
|
+
export interface ReportingSummary {
|
|
115
|
+
/** The legacy preset this policy came from, when the config back-annotates one. */
|
|
116
|
+
scopePreset?: ReportScope;
|
|
117
|
+
/** Categories allowed to interrupt the operator's phone. */
|
|
118
|
+
interruptOn: InterruptCategory[];
|
|
119
|
+
/** Where non-interrupting outcomes accumulate: the effective digest config. */
|
|
120
|
+
digest: ReportingPolicy["digest"];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The effective reporting summary. The loader always materialises the policy,
|
|
125
|
+
* so `undefined` means the default (`DEFAULT_REPORT_POLICY`); the preset name
|
|
126
|
+
* is carried only when the policy actually back-annotates one, so a rendered
|
|
127
|
+
* status names the preset without ever letting a stale or absent preset
|
|
128
|
+
* misstate what the policy does (#633).
|
|
129
|
+
*/
|
|
130
|
+
export function reportingSummary(policy: ReportingPolicy | undefined): ReportingSummary {
|
|
131
|
+
const effective = policy ?? DEFAULT_REPORT_POLICY;
|
|
132
|
+
return {
|
|
133
|
+
...(effective.scopePreset === undefined ? {} : { scopePreset: effective.scopePreset }),
|
|
134
|
+
interruptOn: [...effective.interruptOn],
|
|
135
|
+
digest: { ...effective.digest },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The active drain as structured status exposes it: the creation instant, the
|
|
141
|
+
* absolute deadline, the purpose, and the live-run count the drain is waiting
|
|
142
|
+
* to reach zero. Only present on the snapshot while the record is fresh.
|
|
143
|
+
*/
|
|
144
|
+
export interface DrainStatus {
|
|
145
|
+
/** Epoch-ms instant the drain intent was recorded. */
|
|
146
|
+
since: number;
|
|
147
|
+
/** Absolute epoch-ms deadline — admission resumes automatically after it. */
|
|
148
|
+
expiresAt: number;
|
|
149
|
+
/** Purpose recorded at creation. */
|
|
150
|
+
reason?: string;
|
|
151
|
+
/** Runs still in the active set (live workers plus pushed-state PRs) — the
|
|
152
|
+
* count a drain waits to reach zero, matching the `runs-settled` release
|
|
153
|
+
* gate (#776 review #2). */
|
|
154
|
+
remainingRuns: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface StatusSnapshot {
|
|
158
|
+
project: string;
|
|
159
|
+
configPath: string;
|
|
160
|
+
stateDir: string;
|
|
161
|
+
paused: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Why the fleet is paused, when whatever paused it said. Rendered beside
|
|
164
|
+
* `(PAUSED)` so a pause an operator did not issue names itself instead of
|
|
165
|
+
* reading like a mistake (#220).
|
|
166
|
+
*/
|
|
167
|
+
pauseReason?: string;
|
|
168
|
+
/** Unresolved host-wide upgrade recovery, rendered identically for every project. */
|
|
169
|
+
upgradeRecovery?: UpgradeRecoveryStatus;
|
|
170
|
+
/**
|
|
171
|
+
* The project's active self-expiring drain (#484): present only while a
|
|
172
|
+
* fresh, valid drain record exists. Paused and drained are deliberately two
|
|
173
|
+
* fields — a drain is bounded and self-clearing where a pause is not, and
|
|
174
|
+
* the structured surface has to tell them apart without reading the pass
|
|
175
|
+
* history. Human CLI wording over it is a later #484 child.
|
|
176
|
+
*/
|
|
177
|
+
drain?: DrainStatus;
|
|
178
|
+
/** Mechanical operator availability at the moment this snapshot was read. */
|
|
179
|
+
availability?: AvailabilityState;
|
|
180
|
+
/** Next digest opportunity under the same predicate that gates submission. */
|
|
181
|
+
digestSchedule?: DigestScheduleState;
|
|
182
|
+
/**
|
|
183
|
+
* The effective reporting policy: what interrupts, where everything else
|
|
184
|
+
* goes, and the legacy preset name when one is back-annotated. On the
|
|
185
|
+
* snapshot so the human `status`, the dashboard API and the CLI all read
|
|
186
|
+
* the same truth about the reporting surface without opening config — a
|
|
187
|
+
* routine outcome that reads as "Telegram is broken" unless the policy says
|
|
188
|
+
* it is digest-only (#633).
|
|
189
|
+
*/
|
|
190
|
+
reporting?: ReportingSummary;
|
|
191
|
+
caps: Caps;
|
|
192
|
+
/**
|
|
193
|
+
* The effective per-shape release grants. On the snapshot rather than re-read
|
|
194
|
+
* by each renderer because #122 began with a grant nobody had looked at in
|
|
195
|
+
* weeks: `status` names them so a stale one is visible without opening either
|
|
196
|
+
* the config or the brief.
|
|
197
|
+
*/
|
|
198
|
+
releaseGrants: ResolvedGrants;
|
|
199
|
+
/**
|
|
200
|
+
* The effective review policy (#678). On the snapshot for the reason
|
|
201
|
+
* `releaseGrants` is: the orchestrator's Duty 1 has to act on it every
|
|
202
|
+
* tick, and a stale level or ceiling sitting only in a config file nobody
|
|
203
|
+
* opens is exactly the drift this field exists to surface. The loader always
|
|
204
|
+
* materialises it, so this is never absent on a real daemon.
|
|
205
|
+
*/
|
|
206
|
+
review: ReviewPolicy;
|
|
207
|
+
/** Occupied issues: live workers plus green PRs awaiting a human merge. Both
|
|
208
|
+
* lifecycle kinds, told apart by {@link StatusSnapshot.leasedRunIds}. */
|
|
209
|
+
activeRuns: RunRecord[];
|
|
210
|
+
/**
|
|
211
|
+
* The subset of {@link StatusSnapshot.activeRuns} holding an **active
|
|
212
|
+
* mutation lease** (#898): a live worker, or a dispatched unsettled review
|
|
213
|
+
* revision. Everything else in `activeRuns` is a worker-free preserved
|
|
214
|
+
* artifact — durable work awaiting review, merge or recovery, with nothing
|
|
215
|
+
* writing to it.
|
|
216
|
+
*
|
|
217
|
+
* Carried as ids rather than a second run list so the two renderings cannot
|
|
218
|
+
* disagree about the rows themselves, and derived from `Store.leasedRuns` —
|
|
219
|
+
* the same query admission's file-lane gate and `conductor_pr_recover` read,
|
|
220
|
+
* so what status calls a lease is exactly what those two enforce (#899,
|
|
221
|
+
* #925). A plain array so the dashboard's JSON round-trip preserves it.
|
|
222
|
+
*/
|
|
223
|
+
leasedRunIds?: readonly string[];
|
|
224
|
+
/**
|
|
225
|
+
* The review-ceiling adjudications an operator needs to see (#874): every
|
|
226
|
+
* non-terminal one, plus the terminal one for each active run's PR head — so a
|
|
227
|
+
* cleared or rejected verdict stays visible for exactly as long as the PR it
|
|
228
|
+
* decided is still in flight, and disappears with it rather than accumulating.
|
|
229
|
+
*
|
|
230
|
+
* Keyed by nothing: the records carry their own `prUrl` and `headSha`, which is
|
|
231
|
+
* how the renderer matches them to a run without inventing a second identity
|
|
232
|
+
* for a PR.
|
|
233
|
+
*/
|
|
234
|
+
reviewAdjudications?: readonly ReviewAdjudicationRecord[];
|
|
235
|
+
/**
|
|
236
|
+
* runId → live review-revision round, for runs whose revision worker is
|
|
237
|
+
* currently dispatched (#692). Derived from the durable `review_revisions`
|
|
238
|
+
* rows, so the rendered `review-revision N` state rests on the same facts
|
|
239
|
+
* the restart recovery reads — never on a label or a guess. A plain object
|
|
240
|
+
* (not a Map) so the dashboard's JSON round-trip of the snapshot preserves
|
|
241
|
+
* it byte for byte.
|
|
242
|
+
*/
|
|
243
|
+
/**
|
|
244
|
+
* The live review round per run: its number AND the instant it was dispatched
|
|
245
|
+
* (#802). The instant is what makes the line honest — a resumed session's
|
|
246
|
+
* `turns` and `startedAt` are cumulative over the whole attempt, so without a
|
|
247
|
+
* phase boundary a fresh revision reads as though it had been running for
|
|
248
|
+
* hours.
|
|
249
|
+
*/
|
|
250
|
+
reviewRounds?: Readonly<Record<string, { round: number; dispatchedAt: number }>>;
|
|
251
|
+
/**
|
|
252
|
+
* runId → the durable review evidence blocking a merge of the run's
|
|
253
|
+
* pushed-green PR at its exact recorded head (#888). Derived from the same
|
|
254
|
+
* `review_revisions` rows `conductor_pr_merge` refuses on, so status can
|
|
255
|
+
* never present a green PR as merge-ready while the privileged verb will
|
|
256
|
+
* refuse it. An absent entry means nothing blocks that run's head.
|
|
257
|
+
*/
|
|
258
|
+
mergeBlockers?: Readonly<Record<string, ReviewHeadBlocker>>;
|
|
259
|
+
/**
|
|
260
|
+
* runId → every review-correction round recorded for that run, ascending by
|
|
261
|
+
* round, carrying #1045's durable launch provenance (#1048).
|
|
262
|
+
*
|
|
263
|
+
* Separate from {@link StatusSnapshot.reviewRounds} and
|
|
264
|
+
* {@link StatusSnapshot.mergeBlockers} on purpose: those two answer "what
|
|
265
|
+
* state does this run render as" and "what stands between this head and a
|
|
266
|
+
* merge", and both are deliberately narrow (one live round; one blocking
|
|
267
|
+
* round). This is the round *history* an operator reads to answer the #1035
|
|
268
|
+
* question — did conductor resume the diagnosed transcript or open a fresh
|
|
269
|
+
* correction, and under which model — so it includes settled rounds, which
|
|
270
|
+
* neither of the other two can see once they stop blocking anything.
|
|
271
|
+
*
|
|
272
|
+
* Every provenance field is copied off the row and never derived from the
|
|
273
|
+
* current config: rendering today's `workerModel` for a round dispatched
|
|
274
|
+
* under yesterday's is the exact silent fake #1048 names. A pre-#1045 row
|
|
275
|
+
* therefore projects with no provenance at all, which the renderer prints as
|
|
276
|
+
* an explicit unknown.
|
|
277
|
+
*/
|
|
278
|
+
reviewCorrections?: Readonly<Record<string, readonly ReviewCorrectionRound[]>>;
|
|
279
|
+
/** Newest attempts holding a preserved WIP tip, or a tree that is still the
|
|
280
|
+
* only copy of work the daemon could not save. */
|
|
281
|
+
salvagedRuns: RunRecord[];
|
|
282
|
+
/** Retained trees whose object store could not be made sound, so the daemon
|
|
283
|
+
* refused to fetch into them and their commits cannot be verified against
|
|
284
|
+
* any remote (#737). Distinguished from ordinary retention on purpose —
|
|
285
|
+
* quarantine is "potentially stranded work", not routine housekeeping. */
|
|
286
|
+
quarantinedRuns: RunRecord[];
|
|
287
|
+
/** One-shot issue ceilings waiting for the next claim. */
|
|
288
|
+
turnOverrides: TurnOverride[];
|
|
289
|
+
/** Reports the operator has not provably received: pending, in-flight with an
|
|
290
|
+
* unknown outcome, or written off. An empty list is the only honest way to
|
|
291
|
+
* say "everything authored this cycle actually went out" (#123). */
|
|
292
|
+
openReports: ReportRecord[];
|
|
293
|
+
/** Recent successful report/notice withdrawals, newest first. */
|
|
294
|
+
handoffWithdrawals?: HandoffWithdrawal[];
|
|
295
|
+
/** Ordinary outcomes and deferred escalations not yet associated with an
|
|
296
|
+
* accepted digest report. */
|
|
297
|
+
digestBacklog: DigestBacklog;
|
|
298
|
+
/**
|
|
299
|
+
* The most recent conductor-verb calls and how the daemon decided them
|
|
300
|
+
* (#126). On `status` rather than only behind `omp-conductor ledger` because
|
|
301
|
+
* a refused merge is news: it means a session tried to do something the
|
|
302
|
+
* config does not let it, and an operator who has to know to go looking is an
|
|
303
|
+
* operator who finds out from the tracker instead.
|
|
304
|
+
*/
|
|
305
|
+
/** Current live-head push-workflow verdict per recently merged repository. */
|
|
306
|
+
baseHealth: BaseHealth[];
|
|
307
|
+
/** Per-repo base-red merge freezes, active first (#283). */
|
|
308
|
+
freezes: BaseFreeze[];
|
|
309
|
+
verbLedger: VerbLedgerEntry[];
|
|
310
|
+
/** Runs backed by a worker process — the number capacity compares against. */
|
|
311
|
+
liveWorkers: number;
|
|
312
|
+
runsToday: number;
|
|
313
|
+
spendTodayUsd: number;
|
|
314
|
+
/** The install identities the last dispatch pass recorded (#919). Absent
|
|
315
|
+
* means no pass has looked yet, which the renderer says out loud rather
|
|
316
|
+
* than presenting as agreement. */
|
|
317
|
+
installSurfaces?: InstallSurfaceObservation;
|
|
318
|
+
/** What live runs have reserved out of today's budget but not yet spent
|
|
319
|
+
* (#851). Optional so a caller that built a snapshot before reservations
|
|
320
|
+
* existed renders no figure rather than a fabricated zero. */
|
|
321
|
+
reservedSpendUsd?: number;
|
|
322
|
+
/**
|
|
323
|
+
* Whether the figure above is built on runs that actually reported cost
|
|
324
|
+
* (#970). Absent when the caller did not judge it; the renderer then says
|
|
325
|
+
* nothing rather than implying the telemetry is sound.
|
|
326
|
+
*/
|
|
327
|
+
spendTelemetry?: SpendTelemetryVerdict;
|
|
328
|
+
dispatch?: DispatchSummary;
|
|
329
|
+
/**
|
|
330
|
+
* Latest plan-allowance verdict, when the caller read one. Optional because
|
|
331
|
+
* this snapshot is built synchronously off the store while the provider read
|
|
332
|
+
* is I/O: a renderer that did not do that work must say "not read" rather
|
|
333
|
+
* than print a percentage nobody measured (#110).
|
|
334
|
+
*/
|
|
335
|
+
planUsage?: PlanUsageStatus;
|
|
336
|
+
/**
|
|
337
|
+
* The GitHub API rate-limit budget, when the caller read one. Optional for
|
|
338
|
+
* the same reason as `planUsage` — the read is I/O and the snapshot is
|
|
339
|
+
* synchronous — and a broken `gh` must cost one status row, not the report
|
|
340
|
+
* (#188).
|
|
341
|
+
*/
|
|
342
|
+
github?: RateLimitStatus;
|
|
343
|
+
/**
|
|
344
|
+
* Observed GitHub rate-limit refusals within the last five minutes, and the
|
|
345
|
+
* daemon's tracked per-source `gh` call counts for the UTC day. Unlike
|
|
346
|
+
* `github`, which a caller polls, these are written by the tracker's hooks —
|
|
347
|
+
* the polled budget sat beside what actually happened (#198).
|
|
348
|
+
*/
|
|
349
|
+
ghRefusals?: { count: number; latestAt?: number };
|
|
350
|
+
ghCallsToday?: readonly { source: string; calls: number }[];
|
|
351
|
+
/**
|
|
352
|
+
* Label-projection ops still owed to the tracker (#201). Present only while
|
|
353
|
+
* one is pending: GitHub has not yet converged on what the store decided —
|
|
354
|
+
* a refused or deferred label write is exactly the state an operator should
|
|
355
|
+
* see rather than a silent gap.
|
|
356
|
+
*/
|
|
357
|
+
labelOps?: { pending: number; oldestAgeMs: number };
|
|
358
|
+
/**
|
|
359
|
+
* The orchestrator-down incident, when the embedded orchestrator is down:
|
|
360
|
+
* mode, since-moment and the tier-1 escalations diverted to issue comments
|
|
361
|
+
* so far. Absent when the orchestrator is healthy (or external), so recovery
|
|
362
|
+
* drops the degrade row from `status` (#288).
|
|
363
|
+
*/
|
|
364
|
+
orchestratorDown?: OrchestratorIncident;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Builds a status reading from an already-open store. Long-lived operator
|
|
368
|
+
* surfaces use this path so a one-second refresh does not repeatedly open and
|
|
369
|
+
* initialise SQLite connections. */
|
|
370
|
+
export function statusSnapshotFromStore(
|
|
371
|
+
p: ProjectConfig,
|
|
372
|
+
caps: Caps,
|
|
373
|
+
store: Store,
|
|
374
|
+
planUsage?: PlanUsageStatus,
|
|
375
|
+
): StatusSnapshot {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const lastDigestKey = store.lastDigestDedupeKey(p.name);
|
|
378
|
+
const lastDigestDay =
|
|
379
|
+
lastDigestKey === undefined ? undefined : lastDigestKey.slice("digest:".length);
|
|
380
|
+
const since = startOfToday();
|
|
381
|
+
const dispatch = store.latestDispatch(p.name);
|
|
382
|
+
const labelOpsPending = store.countPendingLabelOps(p.name);
|
|
383
|
+
const oldestLabelOpAt = store.oldestPendingLabelOpAt(p.name);
|
|
384
|
+
// Read once: `status` renders the degrade row off this while it is down.
|
|
385
|
+
const orchestratorDown = store.orchestratorIncident(p.name);
|
|
386
|
+
// The journal is the authoritative recovery lifecycle. Render unresolved
|
|
387
|
+
// recovery even when an operator/project pause currently wins provenance,
|
|
388
|
+
// or before the verifier has re-created a missing global fence.
|
|
389
|
+
const pause = pauseProvenance(p.name);
|
|
390
|
+
const reason = pause?.reason;
|
|
391
|
+
const upgradeRecovery = upgradeRecoveryStatus(readUpgradeJournal(stateDir()), stateDir());
|
|
392
|
+
// Same cost discipline as the pause read: the drain record is a file read,
|
|
393
|
+
// and the active-drain view is only built when one is actually fresh. The
|
|
394
|
+
// read is observational — it never mutates the record — so a status read
|
|
395
|
+
// cannot consume a malformed marker the next dispatch pass still has to fail
|
|
396
|
+
// closed on; the dispatch consume owns cleanup (#776 review #2).
|
|
397
|
+
const drain = readDrain(p.name);
|
|
398
|
+
// The live run list feeds the worker-capacity row; the drain's
|
|
399
|
+
// remaining-runs count waits on the ACTIVE set (live workers plus
|
|
400
|
+
// pushed-state PRs), the same population the `runs-settled` release gate
|
|
401
|
+
// reads, so the two can never disagree about when a batch is finished
|
|
402
|
+
// (#776 review #2).
|
|
403
|
+
const live = store.liveRuns(p.name);
|
|
404
|
+
const active = store.activeRuns(p.name);
|
|
405
|
+
// Which of those active rows something is actually writing through (#898).
|
|
406
|
+
// The same query admission's file-lane gate and `conductor_pr_recover` read,
|
|
407
|
+
// so status cannot call a row a lease that those two treat as released, or
|
|
408
|
+
// vice versa (#899, #925).
|
|
409
|
+
const leasedRunIds = store.leasedRuns(p.name).map((r) => r.id);
|
|
410
|
+
// Adjudications worth rendering (#874): the open ones always, plus a terminal
|
|
411
|
+
// verdict for a head an active run is still sitting on. Bounded by the active
|
|
412
|
+
// set rather than by a history window, so a fleet with a thousand settled
|
|
413
|
+
// adjudications renders the handful that still describe live work.
|
|
414
|
+
const openAdjudications = store.openReviewAdjudications(p.name);
|
|
415
|
+
const adjudicationIds = new Set(openAdjudications.map((a) => a.id));
|
|
416
|
+
const reviewAdjudications = [...openAdjudications];
|
|
417
|
+
for (const r of active) {
|
|
418
|
+
if (r.prUrl === undefined || r.headSha === undefined) continue;
|
|
419
|
+
const decided = store.reviewAdjudicationForHead(p.name, r.prUrl, r.headSha);
|
|
420
|
+
if (decided !== undefined && !adjudicationIds.has(decided.id)) {
|
|
421
|
+
adjudicationIds.add(decided.id);
|
|
422
|
+
reviewAdjudications.push(decided);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// The live review-revision rounds, read from the same durable rows the
|
|
426
|
+
// restart recovery uses: a run whose revision is dispatched is read as
|
|
427
|
+
// `review-revision N` while its worker is live (#692).
|
|
428
|
+
const reviewRounds: Record<string, { round: number; dispatchedAt: number }> = {};
|
|
429
|
+
for (const revision of store.unsettledReviewRevisions(p.name)) {
|
|
430
|
+
if (revision.dispatchedAt !== undefined) {
|
|
431
|
+
reviewRounds[revision.runId] = { round: revision.round, dispatchedAt: revision.dispatchedAt };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// The exact-head merge blockers (#888), read from the same durable rows the
|
|
435
|
+
// merge verb consults: a pushed-green run whose PR stands at a head carrying
|
|
436
|
+
// unresolved review evidence — a round queued there, crashed mid-review
|
|
437
|
+
// there, or settled `failed` there.
|
|
438
|
+
const mergeBlockers: Record<string, ReviewHeadBlocker> = {};
|
|
439
|
+
for (const r of active) {
|
|
440
|
+
if (r.prUrl === undefined || r.headSha === undefined) continue;
|
|
441
|
+
const blocker = store.mergeBlockingReviews(p.name, r.prUrl, r.headSha)[0];
|
|
442
|
+
if (blocker !== undefined) {
|
|
443
|
+
mergeBlockers[r.id] = {
|
|
444
|
+
round: blocker.round,
|
|
445
|
+
state:
|
|
446
|
+
blocker.settledAt !== undefined ? "failed" : blocker.dispatchedAt !== undefined ? "crashed" : "pending",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// The review-correction round history per active run, with #1045's durable
|
|
451
|
+
// launch provenance (#1048). Read from the rows keyed by the run's own PR and
|
|
452
|
+
// then narrowed to the run: a continuation that inherited the PR must not
|
|
453
|
+
// show its predecessor's rounds as its own. Bounded by the active set for the
|
|
454
|
+
// same reason the adjudication projection is — a fleet with a thousand
|
|
455
|
+
// settled rounds renders the handful that still describe live work.
|
|
456
|
+
//
|
|
457
|
+
// Every provenance value is copied off the row verbatim, and an absent column
|
|
458
|
+
// stays absent: the renderer prints "unknown" for a pre-#1045 round, which is
|
|
459
|
+
// the truth, where filling it from `project.workerModel` would be an
|
|
460
|
+
// invention (#1048's named silent fake).
|
|
461
|
+
const reviewCorrections: Record<string, ReviewCorrectionRound[]> = {};
|
|
462
|
+
for (const r of active) {
|
|
463
|
+
if (r.prUrl === undefined) continue;
|
|
464
|
+
const rounds = store
|
|
465
|
+
.reviewRevisionsForPr(p.name, r.prUrl)
|
|
466
|
+
.filter((revision) => revision.runId === r.id)
|
|
467
|
+
.sort((a, b) => a.round - b.round)
|
|
468
|
+
.map((revision) => ({
|
|
469
|
+
round: revision.round,
|
|
470
|
+
state:
|
|
471
|
+
revision.settledAt !== undefined
|
|
472
|
+
? ("settled" as const)
|
|
473
|
+
: revision.dispatchedAt !== undefined
|
|
474
|
+
? ("dispatched" as const)
|
|
475
|
+
: ("pending" as const),
|
|
476
|
+
...(revision.outcome === undefined ? {} : { outcome: revision.outcome }),
|
|
477
|
+
...(revision.dispatchedAt === undefined ? {} : { dispatchedAt: revision.dispatchedAt }),
|
|
478
|
+
...(revision.launchMode === undefined ? {} : { launchMode: revision.launchMode }),
|
|
479
|
+
...(revision.requestedModel === undefined ? {} : { requestedModel: revision.requestedModel }),
|
|
480
|
+
...(revision.resolvedModel === undefined ? {} : { resolvedModel: revision.resolvedModel }),
|
|
481
|
+
...(revision.originSessionRef === undefined ? {} : { originSessionRef: revision.originSessionRef }),
|
|
482
|
+
...(revision.correctionSessionRef === undefined
|
|
483
|
+
? {}
|
|
484
|
+
: { correctionSessionRef: revision.correctionSessionRef }),
|
|
485
|
+
}));
|
|
486
|
+
if (rounds.length > 0) reviewCorrections[r.id] = rounds;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
project: p.name,
|
|
490
|
+
configPath: configPath(),
|
|
491
|
+
stateDir: stateDir(),
|
|
492
|
+
paused: isPaused(p.name),
|
|
493
|
+
...(reason === undefined ? {} : { pauseReason: reason }),
|
|
494
|
+
...(upgradeRecovery === undefined ? {} : { upgradeRecovery }),
|
|
495
|
+
...(drain.kind === "active"
|
|
496
|
+
? {
|
|
497
|
+
drain: {
|
|
498
|
+
since: Date.parse(drain.drain.createdAt),
|
|
499
|
+
expiresAt: Date.parse(drain.drain.expiresAt),
|
|
500
|
+
...(drain.drain.reason === undefined ? {} : { reason: drain.drain.reason }),
|
|
501
|
+
remainingRuns: active.length,
|
|
502
|
+
} satisfies DrainStatus,
|
|
503
|
+
}
|
|
504
|
+
: {}),
|
|
505
|
+
availability: availabilityState(p.reporting, now),
|
|
506
|
+
digestSchedule: digestScheduleState(p.reporting ?? DEFAULT_REPORT_POLICY, lastDigestDay, now),
|
|
507
|
+
reporting: reportingSummary(p.reporting ?? DEFAULT_REPORT_POLICY),
|
|
508
|
+
caps,
|
|
509
|
+
releaseGrants: resolveReleaseGrants(p),
|
|
510
|
+
review: resolveReview(p),
|
|
511
|
+
activeRuns: active,
|
|
512
|
+
leasedRunIds,
|
|
513
|
+
reviewAdjudications,
|
|
514
|
+
reviewRounds,
|
|
515
|
+
reviewCorrections,
|
|
516
|
+
salvagedRuns: store.salvagedRuns(p.name),
|
|
517
|
+
quarantinedRuns: store.quarantinedRuns(p.name),
|
|
518
|
+
turnOverrides: store.listTurnOverrides(p.name),
|
|
519
|
+
openReports: store.openReports(p.name),
|
|
520
|
+
handoffWithdrawals: store.handoffWithdrawals(p.name),
|
|
521
|
+
digestBacklog: store.digestBacklog(p.name),
|
|
522
|
+
verbLedger: store.verbLedger(p.name, { limit: STATUS_LEDGER_SCAN }),
|
|
523
|
+
liveWorkers: live.length,
|
|
524
|
+
runsToday: store.runsStartedSince(p.name, since),
|
|
525
|
+
spendTodayUsd: store.spendSince(p.name, since),
|
|
526
|
+
reservedSpendUsd: store.reservedSpendUsd(p.name),
|
|
527
|
+
// Judged from the store on the same read as the figure above (#970), so the
|
|
528
|
+
// row that qualifies the spend number cannot disagree with it, and nothing
|
|
529
|
+
// is probed at render time.
|
|
530
|
+
spendTelemetry: judgeSpendTelemetry(
|
|
531
|
+
store.recentSpendSamples?.(p.name, SPEND_SAMPLE_ROWS) ?? [],
|
|
532
|
+
SPEND_SAMPLE_RUNS,
|
|
533
|
+
{
|
|
534
|
+
// Config and an already-read status only — this snapshot probes nothing
|
|
535
|
+
// at render time, which is the property the figure above depends on
|
|
536
|
+
// (#970). The declaration alone is enough to know a dollar cap cannot
|
|
537
|
+
// fire; the window name rides along when a configured plan cap has
|
|
538
|
+
// already resolved one, and `doctor` (which may probe) reads it live.
|
|
539
|
+
declaredSubscription: (p.requireOauthProviders ?? []).length > 0,
|
|
540
|
+
...(planUsage?.window === undefined
|
|
541
|
+
? {}
|
|
542
|
+
: {
|
|
543
|
+
allowanceWindow:
|
|
544
|
+
planUsage.window.label === undefined
|
|
545
|
+
? planUsage.window.id
|
|
546
|
+
: `${planUsage.window.id} (${planUsage.window.label})`,
|
|
547
|
+
}),
|
|
548
|
+
},
|
|
549
|
+
),
|
|
550
|
+
// Read, never probed: the recorded row is the whole point (#919).
|
|
551
|
+
...(() => {
|
|
552
|
+
const observed = store.installSurfaces();
|
|
553
|
+
return observed === undefined ? {} : { installSurfaces: observed };
|
|
554
|
+
})(),
|
|
555
|
+
...(dispatch === undefined ? {} : { dispatch }),
|
|
556
|
+
...(planUsage === undefined ? {} : { planUsage }),
|
|
557
|
+
// Written by the tracker's hooks rather than polled, so the renderer does
|
|
558
|
+
// not re-read GitHub to know it is being refused (#198).
|
|
559
|
+
ghRefusals: store.ghRefusalsSince?.(now - 5 * 60_000),
|
|
560
|
+
ghCallsToday: store.ghCallsToday?.(utcDay()),
|
|
561
|
+
...(labelOpsPending === 0 || oldestLabelOpAt === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { labelOps: { pending: labelOpsPending, oldestAgeMs: now - oldestLabelOpAt } }),
|
|
564
|
+
baseHealth: store.baseHealth(p.name),
|
|
565
|
+
freezes: store.freezes(p.name),
|
|
566
|
+
mergeBlockers,
|
|
567
|
+
...(orchestratorDown === undefined ? {} : { orchestratorDown }),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Opens and closes its own store handle so one-shot CLI and plugin readers can
|
|
572
|
+
* read status while a daemon in another process is writing (the store runs in
|
|
573
|
+
* WAL mode for exactly this). */
|
|
574
|
+
export function statusSnapshot(project?: string): StatusSnapshot {
|
|
575
|
+
const cfg = loadConfig();
|
|
576
|
+
const p = findProject(cfg, project);
|
|
577
|
+
const store = openStore(dbPath());
|
|
578
|
+
try {
|
|
579
|
+
return statusSnapshotFromStore(p, resolveCaps(p, cfg.defaults), store);
|
|
580
|
+
} finally {
|
|
581
|
+
store.close();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function formatDispatchSummary(summary?: DispatchSummary): string {
|
|
586
|
+
if (summary === undefined) return "last dispatch (none recorded)";
|
|
587
|
+
const lines = [
|
|
588
|
+
`last dispatch ${new Date(summary.completedAt).toISOString()}${summary.degraded ? " DEGRADED" : ""}`,
|
|
589
|
+
];
|
|
590
|
+
if (summary.paused === true) {
|
|
591
|
+
// A held pass never routed the queue: printing zero candidates here would
|
|
592
|
+
// read as "the queue was empty". Say what the pass was and attribute what
|
|
593
|
+
// it did instead (#497).
|
|
594
|
+
lines.push(` pass held — nothing admitted`);
|
|
595
|
+
} else {
|
|
596
|
+
lines.push(
|
|
597
|
+
` candidates ${summary.ready} ready / ${summary.claimed ?? 0} in flight / ${summary.routed} spare`,
|
|
598
|
+
` admitted ${summary.admitted}`,
|
|
599
|
+
);
|
|
600
|
+
// The operator's park label, counted from the same eligibility read the
|
|
601
|
+
// claim gate uses (#507): "0 claimable, 12 parked" and "0 claimable,
|
|
602
|
+
// nothing to do" demand opposite orchestrator responses and must not
|
|
603
|
+
// render alike. Omitted at zero so an empty queue stays the old shape.
|
|
604
|
+
const parked = summary.parked ?? 0;
|
|
605
|
+
if (parked > 0) {
|
|
606
|
+
lines.push(` parked ${parked} — operator-held, never claimed`);
|
|
607
|
+
}
|
|
608
|
+
if (summary.holds.length === 0) {
|
|
609
|
+
lines.push(" held 0");
|
|
610
|
+
} else {
|
|
611
|
+
lines.push(" held");
|
|
612
|
+
for (const hold of summary.holds) {
|
|
613
|
+
const sample =
|
|
614
|
+
hold.issues.length === 0
|
|
615
|
+
? ""
|
|
616
|
+
: ` (#${hold.issues.join(", #")}${hold.count > hold.issues.length ? ", …" : ""})`;
|
|
617
|
+
lines.push(` ${hold.reason} ${hold.count}${sample}`);
|
|
618
|
+
// A `file-lane` hold groups several issues, each blocked by a different
|
|
619
|
+
// file and holder; the grouped line says how many, this says which.
|
|
620
|
+
//
|
|
621
|
+
// Deduplicated for display only, never in the record: a fleet-wide hold
|
|
622
|
+
// (`credential-class`, #852) gives every held candidate the *same*
|
|
623
|
+
// sentence, and printing one reason five times reads as five problems.
|
|
624
|
+
// The persisted details stay index-aligned with `issues` — a reader that
|
|
625
|
+
// needs "which issue got which detail" still has it.
|
|
626
|
+
const details = [...new Set(hold.details ?? [])];
|
|
627
|
+
if (details.length > 0) {
|
|
628
|
+
lines.push(` ${details.join(" | ")}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if ((summary.settled ?? 0) > 0) lines.push(` settled ${summary.settled}`);
|
|
634
|
+
return lines.join("\n");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The grant table, named shape by shape.
|
|
639
|
+
*
|
|
640
|
+
* One renderer for both status surfaces (this one and the fleet view), because
|
|
641
|
+
* #122 began with a POLICY grant that no longer matched anyone's intent and went
|
|
642
|
+
* unnoticed: a stale grant has to be visible from `status` alone, and two
|
|
643
|
+
* renderers would eventually show it in only one of them.
|
|
644
|
+
*/
|
|
645
|
+
export function formatReleaseGrants(grants: ResolvedGrants): string[] {
|
|
646
|
+
const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
|
|
647
|
+
return [
|
|
648
|
+
granted.length === 0
|
|
649
|
+
? "release no shape granted — every release/deploy tool call is blocked"
|
|
650
|
+
: `release granted to the orchestrator: ${granted.join(", ")}`,
|
|
651
|
+
...RELEASE_SHAPES.map((shape) => ` ${shape.padEnd(19)}${grants[shape]}`),
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function formatBaseHealth(rows: readonly BaseHealth[]): string[] {
|
|
656
|
+
return rows.map((row) => {
|
|
657
|
+
const head = row.headSha.slice(0, 8);
|
|
658
|
+
if (row.verdict === "green") {
|
|
659
|
+
return `base ${row.repo}/${row.branch} green (${row.runsCount} run(s)) at ${head}`;
|
|
660
|
+
}
|
|
661
|
+
if (row.verdict === "red") {
|
|
662
|
+
return `base ${row.repo}/${row.branch} RED — ${row.detail ?? `workflow failed at ${head}`}`;
|
|
663
|
+
}
|
|
664
|
+
if (row.verdict === "pending") {
|
|
665
|
+
return `base ${row.repo}/${row.branch} pending (${row.runsCount} run(s)) at ${head}`;
|
|
666
|
+
}
|
|
667
|
+
return (
|
|
668
|
+
`base ${row.repo}/${row.branch} unknown — ` +
|
|
669
|
+
(row.detail ?? `no push-triggered workflow run for ${head}`)
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The active base-red freezes as status lines — merges refused until the base
|
|
676
|
+
* is green again or the operator overrides. Active freezes only: a cleared
|
|
677
|
+
* freeze is history the ledger and digest already told an operator about.
|
|
678
|
+
*/
|
|
679
|
+
export function formatFreezes(freezes: readonly BaseFreeze[]): string[] {
|
|
680
|
+
const active = freezes.filter((f) => f.clearedAt === undefined);
|
|
681
|
+
if (active.length === 0) return [];
|
|
682
|
+
return [
|
|
683
|
+
"frozen repos (merges refused until base green)",
|
|
684
|
+
...active.map(
|
|
685
|
+
(f) =>
|
|
686
|
+
` ${f.repo} base red at ${f.culpritSha.slice(0, 8)}` +
|
|
687
|
+
(f.detail === undefined ? "" : ` ${f.detail}`) +
|
|
688
|
+
` — override: omp-conductor unfreeze ${f.repo}`,
|
|
689
|
+
),
|
|
690
|
+
];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
export interface QueuePreview {
|
|
695
|
+
project: string;
|
|
696
|
+
configPath: string;
|
|
697
|
+
queueDescription: string;
|
|
698
|
+
paused: boolean;
|
|
699
|
+
ready: { number: number; title: string; repo: string; branch: string }[];
|
|
700
|
+
unroutable: { number: number; title: string; reason: string; labels: string[] }[];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Exactly what the next tick would pick up, computed without touching a single
|
|
705
|
+
* label, run row or worktree. This is what makes `omp-conductor setup` honest: the
|
|
706
|
+
* dry run is the same routing code the loop uses, not a description of it.
|
|
707
|
+
*/
|
|
708
|
+
export async function previewProject(
|
|
709
|
+
p: ProjectConfig,
|
|
710
|
+
path: string = configPath(),
|
|
711
|
+
): Promise<QueuePreview> {
|
|
712
|
+
const { routed, unroutable } = route(await makeTracker(p).listReady(), p);
|
|
713
|
+
const states = Object.values(p.stateLabels).join(", ");
|
|
714
|
+
return {
|
|
715
|
+
project: p.name,
|
|
716
|
+
configPath: path,
|
|
717
|
+
queueDescription:
|
|
718
|
+
`open issues in ${p.tracker.repo} labelled "${p.queueLabel}", ` +
|
|
719
|
+
`minus anything already labelled ${states}, ` +
|
|
720
|
+
`routed by one "${p.routing.labelPrefix}<repo>" label`,
|
|
721
|
+
paused: isPaused(p.name),
|
|
722
|
+
ready: routed.map((r) => ({
|
|
723
|
+
number: r.issue.number,
|
|
724
|
+
title: r.issue.title,
|
|
725
|
+
repo: r.repo.name,
|
|
726
|
+
branch: branchName(r.issue),
|
|
727
|
+
})),
|
|
728
|
+
unroutable: unroutable.map((u) => ({
|
|
729
|
+
number: u.issue.number,
|
|
730
|
+
title: u.issue.title,
|
|
731
|
+
reason: UNROUTABLE_TEXT[u.reason],
|
|
732
|
+
labels: u.labels,
|
|
733
|
+
})),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export async function previewQueue(project?: string): Promise<QueuePreview> {
|
|
738
|
+
const cfg = loadConfig();
|
|
739
|
+
return previewProject(findProject(cfg, project));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Creates the state store and holds dispatch while setup verifies the host.
|
|
744
|
+
*
|
|
745
|
+
* Setup calls this immediately after consent. Every later setup error therefore
|
|
746
|
+
* leaves the fleet paused instead of exposing a partially written runtime.
|
|
747
|
+
*/
|
|
748
|
+
export function prepareConductor(project?: string): void {
|
|
749
|
+
openStore(dbPath()).close();
|
|
750
|
+
setPaused(true, { source: "setup" }, project);
|
|
751
|
+
}
|