merge-steward 0.32.4 → 0.32.6

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.
@@ -51,7 +51,6 @@ async function readQueueSnapshot(config, eventLimit) {
51
51
  lastTickOutcome: "idle",
52
52
  lastTickError: null,
53
53
  },
54
- queueBlock: null,
55
54
  entries,
56
55
  recentEvents: store.listRecentEvents(config.repoId, { limit: eventLimit }),
57
56
  },
@@ -92,16 +91,6 @@ export function formatQueueStatusText(source, snapshot) {
92
91
  ...(firstLine(snapshot.runtime.lastTickError) ? [`Last error: ${firstLine(snapshot.runtime.lastTickError)}`] : []),
93
92
  ]
94
93
  : []),
95
- ...(snapshot.queueBlock
96
- ? [
97
- `Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
98
- `Missing required: ${snapshot.queueBlock.missingRequiredChecks.length > 0 ? snapshot.queueBlock.missingRequiredChecks.join(", ") : "(none)"}`,
99
- `Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
100
- ...(snapshot.queueBlock.pendingChecks.length > 0
101
- ? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
102
- : []),
103
- ]
104
- : []),
105
94
  "",
106
95
  "Entries:",
107
96
  ...(snapshot.entries.length > 0
@@ -0,0 +1,11 @@
1
+ export interface ShutdownLogger {
2
+ info(bindings: Record<string, unknown>, message: string): void;
3
+ warn(bindings: Record<string, unknown>, message: string): void;
4
+ error(bindings: Record<string, unknown>, message: string): void;
5
+ }
6
+ export declare function createGracefulShutdown(options: {
7
+ service: string;
8
+ logger: ShutdownLogger;
9
+ cleanup: () => Promise<void>;
10
+ terminate?: (code: number) => void;
11
+ }): (trigger: string) => Promise<void>;
@@ -0,0 +1,29 @@
1
+ export function createGracefulShutdown(options) {
2
+ let shutdownPromise;
3
+ let firstTrigger;
4
+ const terminate = options.terminate ?? ((code) => {
5
+ process.exitCode = code;
6
+ setImmediate(() => process.exit(code));
7
+ });
8
+ return (trigger) => {
9
+ if (shutdownPromise) {
10
+ options.logger.warn({ service: options.service, trigger, firstTrigger }, "Shutdown already in progress");
11
+ return shutdownPromise;
12
+ }
13
+ firstTrigger = trigger;
14
+ options.logger.info({ service: options.service, trigger }, "Shutdown requested");
15
+ shutdownPromise = options.cleanup()
16
+ .then(() => {
17
+ options.logger.info({ service: options.service, trigger }, "Shutdown complete");
18
+ })
19
+ .catch((error) => {
20
+ options.logger.error({
21
+ service: options.service,
22
+ trigger,
23
+ error: error instanceof Error ? error.message : String(error),
24
+ }, "Shutdown failed");
25
+ terminate(1);
26
+ });
27
+ return shutdownPromise;
28
+ };
29
+ }
package/dist/server.js CHANGED
@@ -11,6 +11,7 @@ import { buildMultiRepoHttpServer } from "./http-multi.js";
11
11
  import { loadAllRepoConfigs } from "./install.js";
12
12
  import { parseHomeConfigObject } from "./steward-home.js";
13
13
  import { getMergeStewardPathLayout } from "./runtime-paths.js";
14
+ import { createGracefulShutdown } from "./graceful-shutdown.js";
14
15
  import { createGitHubAppTokenManager, generateJwt, resolveGitHubAuthConfig, resolveAppSlug } from "./github-auth.js";
15
16
  import { discoverRepoSettings } from "./github-repo-discovery.js";
16
17
  import { resolveSecretWithSource } from "./resolve-secret.js";
@@ -193,24 +194,26 @@ export async function startMultiServer() {
193
194
  },
194
195
  logger,
195
196
  });
196
- const shutdown = async () => {
197
- shuttingDown = true;
198
- logger.info("Shutting down...");
199
- githubAppTokenManager?.stop();
200
- setRuntimeGitHubAuthProvider(undefined);
201
- for (const record of repos.values()) {
202
- const inst = record.instance;
203
- if (!inst) {
204
- continue;
197
+ const shutdown = createGracefulShutdown({
198
+ service: "merge-steward",
199
+ logger,
200
+ cleanup: async () => {
201
+ shuttingDown = true;
202
+ githubAppTokenManager?.stop();
203
+ setRuntimeGitHubAuthProvider(undefined);
204
+ for (const record of repos.values()) {
205
+ const inst = record.instance;
206
+ if (!inst) {
207
+ continue;
208
+ }
209
+ await inst.service.stop();
210
+ inst.store.close();
205
211
  }
206
- await inst.service.stop();
207
- inst.store.close();
208
- }
209
- await app.close();
210
- process.exit(0);
211
- };
212
- process.on("SIGTERM", () => void shutdown());
213
- process.on("SIGINT", () => void shutdown());
212
+ await app.close();
213
+ },
214
+ });
215
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
216
+ process.once("SIGINT", () => void shutdown("SIGINT"));
214
217
  await app.listen({ host: bind, port });
215
218
  logger.info({ bind, port, repos: repos.size }, "merge-steward listening");
216
219
  const initializeRepo = async (record) => {
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
3
3
  import type { QueueStore } from "./store.ts";
4
- import type { QueueBlockState, QueueReconcileResult, QueueRuntimeStatus } from "./types.ts";
4
+ import type { QueueReconcileResult, QueueRuntimeStatus } from "./types.ts";
5
5
  import type { StewardConfig } from "./config.ts";
6
6
  import type { GitHubPolicyCache } from "./github-policy.ts";
7
7
  export declare class MergeStewardRuntime {
@@ -23,13 +23,11 @@ export declare class MergeStewardRuntime {
23
23
  private lastTickOutcome;
24
24
  private lastTickError;
25
25
  private lastReconcileEvent;
26
- private currentQueueBlock;
27
26
  constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
28
27
  start(): Promise<void>;
29
28
  stop(): Promise<void>;
30
29
  triggerReconcile(): Promise<QueueReconcileResult>;
31
30
  getRuntimeStatus(): QueueRuntimeStatus;
32
- getCurrentQueueBlock(): QueueBlockState | null;
33
31
  getGitHubPolicy(): import("./github-policy.ts").GitHubPolicySnapshot;
34
32
  private scheduleNextTick;
35
33
  private getTickAgeMs;
@@ -18,8 +18,6 @@ export class MergeStewardRuntime {
18
18
  lastTickOutcome = "idle";
19
19
  lastTickError = null;
20
20
  lastReconcileEvent = null;
21
- // The queue never blocks on main CI, so this stays null; kept for status-shape stability.
22
- currentQueueBlock = null;
23
21
  constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
24
22
  this.config = config;
25
23
  this.policy = policy;
@@ -71,9 +69,6 @@ export class MergeStewardRuntime {
71
69
  lastReconcileEvent: this.lastReconcileEvent,
72
70
  };
73
71
  }
74
- getCurrentQueueBlock() {
75
- return this.currentQueueBlock;
76
- }
77
72
  getGitHubPolicy() {
78
73
  return this.policy.getSnapshot();
79
74
  }
@@ -153,13 +148,10 @@ export class MergeStewardRuntime {
153
148
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
154
149
  },
155
150
  });
156
- // The queue never blocks on main CI — main health is information-only.
157
- this.currentQueueBlock = null;
158
151
  this.lastTickOutcome = "succeeded";
159
152
  }
160
153
  catch (error) {
161
154
  this.lastTickOutcome = "failed";
162
- this.currentQueueBlock = null;
163
155
  this.lastTickError = error instanceof Error
164
156
  ? `${error.message}${error.stack ? `\n${error.stack}` : ""}`
165
157
  : String(error);
@@ -29,7 +29,6 @@ export class MergeStewardWatchQueries {
29
29
  githubPolicy: this.runtime.getGitHubPolicy(),
30
30
  summary: buildQueueSummary(entries),
31
31
  runtime: this.runtime.getRuntimeStatus(),
32
- queueBlock: this.runtime.getCurrentQueueBlock(),
33
32
  entries,
34
33
  recentEvents: this.store.listRecentEvents(this.config.repoId, { limit: options?.eventLimit ?? 40 }),
35
34
  };
package/dist/types.d.ts CHANGED
@@ -181,17 +181,6 @@ export interface QueueStatusSummary {
181
181
  headEntryId: string | null;
182
182
  headPrNumber: number | null;
183
183
  }
184
- export interface QueueBlockState {
185
- reason: "main_broken";
186
- entryId: string;
187
- headPrNumber: number | null;
188
- baseBranch: string;
189
- baseSha: string | null;
190
- observedAt: string;
191
- failingChecks: CheckResult[];
192
- pendingChecks: CheckResult[];
193
- missingRequiredChecks: string[];
194
- }
195
184
  export interface GitHubPolicyState {
196
185
  requiredChecks: string[];
197
186
  requireAllChecksOnEmptyRequiredSet: boolean;
@@ -206,7 +195,6 @@ export interface QueueWatchSnapshot {
206
195
  githubPolicy: GitHubPolicyState;
207
196
  summary: QueueStatusSummary;
208
197
  runtime: QueueRuntimeStatus;
209
- queueBlock: QueueBlockState | null;
210
198
  entries: QueueEntry[];
211
199
  recentEvents: QueueEventSummary[];
212
200
  }
@@ -1,5 +1,5 @@
1
1
  import { TERMINAL_STATUSES } from "../types.js";
2
- import { mergeWaitState, queueBlockMatchesEntry } from "./format.js";
2
+ import { mergeWaitState } from "./format.js";
3
3
  const DECIDED_WINDOW_MS = 24 * 60 * 60 * 1000;
4
4
  const GLYPH = {
5
5
  running: "\u25cf",
@@ -61,13 +61,10 @@ function entryKind(entry) {
61
61
  return "cancelled";
62
62
  }
63
63
  }
64
- function entryPhrase(entry, opts) {
65
- if (opts.isHead && opts.queueBlocked) {
66
- return "main broken";
67
- }
64
+ function entryPhrase(entry, isHead) {
68
65
  switch (entry.status) {
69
66
  case "queued":
70
- return opts.isHead ? "queued" : "behind head";
67
+ return isHead ? "queued" : "behind head";
71
68
  case "preparing_head":
72
69
  return entry.lastFailedBaseSha ? "has conflicts" : "preparing";
73
70
  case "validating":
@@ -116,17 +113,11 @@ function tokenSortOrder(kind) {
116
113
  return 3;
117
114
  }
118
115
  }
119
- function overrideKind(kind, override) {
120
- if (override === "main_broken")
121
- return "error";
122
- return kind;
123
- }
124
116
  function repoEntriesFromSnapshot(snapshot, cutoff, now) {
125
117
  const latest = pickLatestPerPR(snapshot.entries);
126
118
  const head = latest
127
119
  .filter((entry) => isActive(entry.status))
128
120
  .sort((a, b) => a.position - b.position)[0] ?? null;
129
- const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
130
121
  // Resolve speculative stacking: an entry's spec can be built on top of
131
122
  // another entry's spec (specBasedOn -> that entry's id). Map ids to PR
132
123
  // numbers so the views can show "stacked on #N". Only surface the link
@@ -142,11 +133,10 @@ function repoEntriesFromSnapshot(snapshot, cutoff, now) {
142
133
  if (entry.status === "dequeued")
143
134
  continue;
144
135
  const isHead = head !== null && entry.id === head.id;
145
- const rawKind = entryKind(entry);
146
- const kind = overrideKind(rawKind, isHead && queueBlocked ? "main_broken" : null);
136
+ const kind = entryKind(entry);
147
137
  const glyph = GLYPH[kind];
148
138
  const color = COLOR[kind];
149
- const phrase = entryPhrase(entry, { isHead, queueBlocked });
139
+ const phrase = entryPhrase(entry, isHead);
150
140
  const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
151
141
  const stackedOnPr = parent && isActive(parent.status) ? parent.prNumber : null;
152
142
  // decidedAt is the terminal-transition time (never bumped by post-merge
@@ -1,9 +1,4 @@
1
- import type { CheckResult, PostMergeStatus, QueueBlockState, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
2
- export declare function isPendingMainVerification(block: QueueBlockState | null | undefined): boolean;
3
- export declare function queueBlockMatchesEntry(block: QueueBlockState | null | undefined, entry: {
4
- id: string;
5
- prNumber: number;
6
- } | null | undefined): block is QueueBlockState;
1
+ import type { CheckResult, PostMergeStatus, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
7
2
  export declare function shortSha(value: string | null | undefined): string;
8
3
  export declare function relativeTime(iso: string | number | null | undefined): string;
9
4
  export declare function formatTokenAge(eventAt: number): string;
@@ -71,4 +66,3 @@ export declare function ciStatusIcon(entry: {
71
66
  icon: string;
72
67
  color: string;
73
68
  };
74
- export declare function summarizeQueueBlock(block: QueueBlockState | null | undefined): string | null;
@@ -6,21 +6,6 @@ const QUEUE_SYMBOLS = {
6
6
  checkPending: "\u25cf",
7
7
  checkUnknown: "\u25cb",
8
8
  };
9
- export function isPendingMainVerification(block) {
10
- return Boolean(block
11
- && block.failingChecks.length === 0
12
- && block.pendingChecks.length > 0
13
- && block.missingRequiredChecks.length === 0);
14
- }
15
- export function queueBlockMatchesEntry(block, entry) {
16
- if (!block || !entry) {
17
- return false;
18
- }
19
- if (block.entryId) {
20
- return block.entryId === entry.id;
21
- }
22
- return block.headPrNumber === entry.prNumber;
23
- }
24
9
  export function shortSha(value) {
25
10
  if (!value) {
26
11
  return "-";
@@ -341,34 +326,3 @@ export function ciStatusIcon(entry) {
341
326
  return { icon: QUEUE_SYMBOLS.checkUnknown, color: "gray" };
342
327
  }
343
328
  }
344
- export function summarizeQueueBlock(block) {
345
- if (!block)
346
- return null;
347
- const missing = block.missingRequiredChecks.length > 0
348
- ? `operator fix needed on ${block.baseBranch}: missing required ${block.missingRequiredChecks.join(", ")}`
349
- : null;
350
- const failing = block.failingChecks.length > 0 ? `failing ${summarizeCheckNames(block.failingChecks)}` : null;
351
- const pending = block.pendingChecks.length > 0 ? `${summarizeCheckNames(block.pendingChecks)} pending` : null;
352
- if (missing && failing && pending) {
353
- return `${missing}; ${failing}; ${pending}`;
354
- }
355
- if (missing && failing) {
356
- return `${missing}; ${failing}`;
357
- }
358
- if (missing && pending) {
359
- return `${missing}; ${pending}`;
360
- }
361
- if (missing) {
362
- return missing;
363
- }
364
- if (failing && pending) {
365
- return `waiting for ${block.baseBranch}: ${failing}; ${pending}`;
366
- }
367
- if (failing) {
368
- return `waiting for ${block.baseBranch} recovery: ${failing}`;
369
- }
370
- if (pending) {
371
- return `waiting for ${block.baseBranch} verification: ${pending}`;
372
- }
373
- return `waiting for ${block.baseBranch} checks`;
374
- }
@@ -21,7 +21,7 @@ LoadCredentialEncrypted=merge-steward-webhook-secret:/etc/credstore.encrypted/me
21
21
  LoadCredentialEncrypted=merge-steward-github-app-pem:/etc/credstore.encrypted/merge-steward-github-app-pem.cred
22
22
 
23
23
  ExecStart=/usr/bin/env merge-steward serve
24
- Restart=on-failure
24
+ Restart=always
25
25
  RestartSec=5
26
26
 
27
27
  NoNewPrivileges=true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.32.4",
3
+ "version": "0.32.6",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,12 +0,0 @@
1
- import type { GitHubPolicyState, QueueBlockState, QueueEntryDetail } from "../types.ts";
2
- interface DetailViewProps {
3
- detail: QueueEntryDetail | null;
4
- isHead: boolean;
5
- activeIndex: number | null;
6
- activeCount: number;
7
- headPrNumber: number | null;
8
- queueBlock: QueueBlockState | null;
9
- githubPolicy: GitHubPolicyState;
10
- }
11
- export declare function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, githubPolicy }: DetailViewProps): React.JSX.Element;
12
- export {};
@@ -1,23 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { formatEntryEvent, humanStatus, nextStepLabel, relativeTime, shortSha, statusColor, summarizeQueueBlock } from "./format.js";
4
- import { EntryStateGraph } from "./EntryStateGraph.js";
5
- import { ExternalRepairObservation } from "./ExternalRepairObservation.js";
6
- import { buildEntryStateGraph, buildExternalRepairObservations } from "./state-visualization.js";
7
- export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, githubPolicy, }) {
8
- if (!detail) {
9
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading entry detail..." }) }));
10
- }
11
- const { entry, events, incidents } = detail;
12
- const graph = buildEntryStateGraph(detail);
13
- const observations = buildExternalRepairObservations(detail, {
14
- isHead,
15
- activeIndex,
16
- activeCount,
17
- headPrNumber,
18
- queueBlock,
19
- });
20
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status, entry), children: humanStatus(entry.status, entry) }), _jsx(Text, { dimColor: true, children: `· ${nextStepLabel(entry.status, entry)}` })] }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: entry.branch }), _jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "tested as" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: `(${shortSha(entry.specSha)} ← ${entry.specBasedOn ? "PR ahead" : "main"})` })] })), entry.retryAttempts > 0 && (_jsxs(Text, { color: "yellow", children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? `waiting for ${queueBlock.baseBranch} checks`, "."] }), _jsx(Text, { dimColor: true, children: queueBlock.missingRequiredChecks.length > 0
21
- ? `Fix ${queueBlock.baseBranch} first, then reconcile this repo to resume the queue.`
22
- : `Will resume automatically once ${queueBlock.baseBranch} is healthy.` })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "GitHub Policy" }), _jsxs(Text, { dimColor: true, children: ["Required checks: ", githubPolicy.requiredChecks.length > 0 ? githubPolicy.requiredChecks.join(", ") : "(none)"] }), githubPolicy.fetchedAt ? _jsxs(Text, { dimColor: true, children: ["Fetched: ", githubPolicy.fetchedAt] }) : null, githubPolicy.lastRefreshReason ? (_jsxs(Text, { dimColor: true, children: ["Last refresh: ", githubPolicy.lastRefreshReason, githubPolicy.lastRefreshChanged === null ? "" : githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"] })) : null] }), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), incidents.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4) }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4) }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
23
- }
@@ -1,7 +0,0 @@
1
- import type { VisualizationNode } from "./state-visualization.ts";
2
- interface EntryStateGraphProps {
3
- main: VisualizationNode[];
4
- exits: VisualizationNode[];
5
- }
6
- export declare function EntryStateGraph({ main, exits }: EntryStateGraphProps): React.JSX.Element;
7
- export {};
@@ -1,12 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- export function EntryStateGraph({ main, exits }) {
4
- const visibleExits = exits.filter((n) => n.status !== "upcoming");
5
- return (_jsxs(Box, { marginTop: 1, gap: 0, children: [main.map((node, i) => {
6
- const dot = node.status === "upcoming" ? "\u25cb" : "\u25cf"; // ○ or ●
7
- const color = node.status === "current" ? "cyan"
8
- : node.status === "visited" ? "green"
9
- : "gray";
10
- return (_jsxs(Box, { gap: 0, children: [i > 0 && _jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { color: color, bold: node.status === "current", children: dot }), _jsx(Text, { color: color, bold: node.status === "current", children: ` ${node.label}` })] }, node.state));
11
- }), visibleExits.map((node) => (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { color: "red", children: `\u25cf ${node.label}` })] }, node.state)))] }));
12
- }
@@ -1,6 +0,0 @@
1
- import type { ObservationLine } from "./state-visualization.ts";
2
- interface ExternalRepairObservationProps {
3
- observations: ObservationLine[];
4
- }
5
- export declare function ExternalRepairObservation({ observations }: ExternalRepairObservationProps): React.JSX.Element;
6
- export {};
@@ -1,15 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- function toneColor(tone) {
4
- switch (tone) {
5
- case "success":
6
- return "green";
7
- case "warn":
8
- return "yellow";
9
- case "info":
10
- return "cyan";
11
- }
12
- }
13
- export function ExternalRepairObservation({ observations }) {
14
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "External Repair" }), observations.map((observation, index) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: toneColor(observation.tone), children: "-" }), _jsx(Text, { children: observation.text })] }, `external-repair-${index}`)))] }));
15
- }
@@ -1,11 +0,0 @@
1
- import type { QueueBlockState, QueueEntry, QueueEventSummary } from "../types.ts";
2
- interface QueueListViewProps {
3
- entries: QueueEntry[];
4
- allEntries: QueueEntry[];
5
- selectedEntryId: string | null;
6
- recentEvents: QueueEventSummary[];
7
- headEntryId: string | null;
8
- queueBlock: QueueBlockState | null;
9
- }
10
- export declare function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock }: QueueListViewProps): React.JSX.Element;
11
- export {};
@@ -1,49 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
- import { Box, Text, useStdout } from "ink";
4
- import { TERMINAL_STATUSES } from "../types.js";
5
- import { buildChainEntries } from "./display-filter.js";
6
- import { ciStatusIcon, formatEventSummary, humanStatus, isPendingMainVerification, nextStepLabel, postMergeStatusLine, queueBlockMatchesEntry, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
7
- const CHROME_ROWS = 13;
8
- function QueueRow({ entry, selected, isHead, queueBlock, }) {
9
- const isTerminal = TERMINAL_STATUSES.includes(entry.status);
10
- if (isTerminal) {
11
- const icon = entry.status === "merged" ? "\u2713" : "\u2717";
12
- const iconColor = entry.status === "merged" ? "green" : "red";
13
- const statusSuffix = entry.status === "merged"
14
- ? ` \u00b7 ${postMergeStatusLine(entry)}`
15
- : "";
16
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { dimColor: true, children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: iconColor, children: `${icon} ${humanStatus(entry.status)}${statusSuffix}` })] }));
17
- }
18
- const blockedOnMain = isHead && queueBlockMatchesEntry(queueBlock, entry);
19
- const pendingMainVerification = blockedOnMain && isPendingMainVerification(queueBlock);
20
- const status = blockedOnMain
21
- ? pendingMainVerification ? "verifying main" : "waiting for main"
22
- : humanStatus(entry.status, entry);
23
- const color = blockedOnMain ? (pendingMainVerification ? "yellow" : "red")
24
- : entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
25
- : statusColor(entry.status);
26
- const nextStep = blockedOnMain
27
- ? summarizeQueueBlock(queueBlock) ?? "waiting for main checks to settle"
28
- : nextStepLabel(entry.status, entry);
29
- // Only show retry counter when retries have actually happened.
30
- const retryNote = entry.retryAttempts > 0 ? ` \u00b7 retry ${entry.retryAttempts}/${entry.maxRetries}` : "";
31
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? ">" : " " }), _jsx(Text, { ...(isHead ? { color: "green" } : {}), bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: color, children: status }), _jsx(Text, { dimColor: true, children: ` \u00b7 ${nextStep}${retryNote}` })] }));
32
- }
33
- export function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
34
- const { stdout } = useStdout();
35
- const rows = stdout?.rows ?? 24;
36
- // All entries are 1 row now.
37
- const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
38
- const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
39
- const effectiveQueueBlock = queueBlock && queueBlock.entryId === headEntryId ? queueBlock : null;
40
- const queueBlockLabel = summarizeQueueBlock(effectiveQueueBlock);
41
- // Chain header always shows the live queue, regardless of display filter.
42
- const chainEntries = useMemo(() => buildChainEntries(allEntries), [allEntries]);
43
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [chainEntries.length > 0 && (_jsxs(Box, { marginBottom: 1, gap: 0, children: [_jsx(Text, { dimColor: true, children: "main" }), chainEntries.map((entry) => {
44
- const ci = ciStatusIcon(entry);
45
- return (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " \u2500 " }), _jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), _jsx(Text, { color: ci.color, children: ci.icon })] }, entry.id));
46
- })] })), effectiveQueueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "waiting for main checks", effectiveQueueBlock.baseSha ? ` at ${truncate(effectiveQueueBlock.baseSha, 10)}` : "", "."] }), _jsx(Text, { dimColor: true, children: effectiveQueueBlock.missingRequiredChecks.length > 0
47
- ? `Head PR #${effectiveQueueBlock.headPrNumber ?? "?"} is waiting for an operator fix on ${effectiveQueueBlock.baseBranch}.`
48
- : `Head PR #${effectiveQueueBlock.headPrNumber ?? "?"} will resume automatically once main is healthy.` })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, isHead: entry.id === headEntryId, queueBlock: effectiveQueueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
49
- }
@@ -1,22 +0,0 @@
1
- import type { QueueBlockState, QueueEntryDetail } from "../types.ts";
2
- export type VisualizationNodeStatus = "current" | "visited" | "upcoming";
3
- export interface VisualizationNode {
4
- state: string;
5
- label: string;
6
- status: VisualizationNodeStatus;
7
- }
8
- export interface ObservationLine {
9
- tone: "info" | "warn" | "success";
10
- text: string;
11
- }
12
- export declare function buildEntryStateGraph(detail: QueueEntryDetail): {
13
- main: VisualizationNode[];
14
- exits: VisualizationNode[];
15
- };
16
- export declare function buildExternalRepairObservations(detail: QueueEntryDetail, options: {
17
- isHead: boolean;
18
- activeIndex: number | null;
19
- activeCount: number;
20
- headPrNumber: number | null;
21
- queueBlock: QueueBlockState | null;
22
- }): ObservationLine[];
@@ -1,120 +0,0 @@
1
- import { queueBlockMatchesEntry } from "./format.js";
2
- const MAIN_STATES = ["queued", "preparing_head", "validating", "merging", "merged"];
3
- const EXIT_STATES = ["evicted", "dequeued"];
4
- const STATE_LABELS = {
5
- queued: "queued",
6
- preparing_head: "preparing",
7
- validating: "testing",
8
- merging: "merging",
9
- merged: "merged",
10
- evicted: "evicted",
11
- dequeued: "removed",
12
- };
13
- function labelForState(state) {
14
- return STATE_LABELS[state] ?? state;
15
- }
16
- function buildNodes(states, visited, currentStatus) {
17
- return states.map((state) => ({
18
- state,
19
- label: labelForState(state),
20
- status: currentStatus === state
21
- ? "current"
22
- : visited.has(state)
23
- ? "visited"
24
- : "upcoming",
25
- }));
26
- }
27
- function collectVisitedStates(detail) {
28
- const visited = new Set([detail.entry.status]);
29
- for (const event of detail.events) {
30
- if (event.fromStatus) {
31
- visited.add(event.fromStatus);
32
- }
33
- visited.add(event.toStatus);
34
- }
35
- return visited;
36
- }
37
- export function buildEntryStateGraph(detail) {
38
- const visited = collectVisitedStates(detail);
39
- return {
40
- main: buildNodes(MAIN_STATES, visited, detail.entry.status),
41
- exits: buildNodes(EXIT_STATES, visited, detail.entry.status),
42
- };
43
- }
44
- export function buildExternalRepairObservations(detail, options) {
45
- const { entry, incidents } = detail;
46
- const observations = [];
47
- // What is this entry doing right now?
48
- if (entry.status === "merged") {
49
- observations.push({ tone: "success", text: "Landed on main." });
50
- }
51
- else if (entry.status === "dequeued") {
52
- observations.push({ tone: "info", text: "Removed from queue." });
53
- }
54
- else if (entry.status === "evicted") {
55
- observations.push({ tone: "warn", text: "Removed after failed retries. Branch needs repair before re-admission." });
56
- }
57
- else if (options.isHead && queueBlockMatchesEntry(options.queueBlock, entry)) {
58
- const failingNames = options.queueBlock.failingChecks.map((check) => check.name);
59
- const pendingNames = options.queueBlock.pendingChecks.map((check) => check.name);
60
- const missingNames = options.queueBlock.missingRequiredChecks;
61
- const detail = [
62
- missingNames.length > 0 ? `missing required ${missingNames.join(", ")}` : null,
63
- failingNames.length > 0 ? `failing ${failingNames.join(", ")}` : null,
64
- pendingNames.length > 0 ? `pending ${pendingNames.join(", ")}` : null,
65
- ].filter(Boolean).join("; ");
66
- observations.push({
67
- tone: "warn",
68
- text: missingNames.length > 0
69
- ? `Queue paused: ${options.queueBlock.baseBranch} is missing required checks${detail ? ` (${detail})` : ""}. Operator action is required on main before the queue can continue.`
70
- : pendingNames.length > 0 && failingNames.length === 0
71
- ? `Queue paused: ${options.queueBlock.baseBranch} is still verifying${detail ? ` (${detail})` : ""}. Will resume when checks settle.`
72
- : `Queue paused: ${options.queueBlock.baseBranch} is unhealthy${detail ? ` (${detail})` : ""}. Will resume when main is healthy.`,
73
- });
74
- }
75
- else if (options.isHead) {
76
- observations.push({ tone: "info", text: "First in queue. Will advance on the next tick." });
77
- }
78
- else if (options.activeIndex !== null && options.headPrNumber !== null) {
79
- observations.push({
80
- tone: "info",
81
- text: `Position ${options.activeIndex} of ${options.activeCount}. Being tested together with PRs ahead.`,
82
- });
83
- }
84
- else {
85
- observations.push({ tone: "info", text: "Waiting in queue." });
86
- }
87
- // What went wrong last time?
88
- const latestIncident = incidents[incidents.length - 1];
89
- if (latestIncident) {
90
- observations.push({
91
- tone: latestIncident.outcome === "open" ? "warn" : "info",
92
- text: `Last failure: ${latestIncident.failureClass} (${latestIncident.outcome}).`,
93
- });
94
- }
95
- // What is blocking progress?
96
- if (entry.lastFailedBaseSha) {
97
- observations.push({
98
- tone: "warn",
99
- text: "Conflicts with main. Will retry automatically when another PR merges and main advances.",
100
- });
101
- }
102
- else if (entry.status === "validating") {
103
- const cascadeNote = entry.specBasedOn
104
- ? " Tests pass → merges automatically when PRs ahead finish."
105
- : "";
106
- observations.push({
107
- tone: "info",
108
- text: `CI running on combined changes.${cascadeNote}`,
109
- });
110
- }
111
- else if (entry.status === "merging") {
112
- if (entry.waitDetail?.toLowerCase().includes("approval") || entry.waitDetail?.toLowerCase().includes("review")) {
113
- observations.push({ tone: "warn", text: "CI passed. Merge is blocked until GitHub approval returns." });
114
- }
115
- else {
116
- observations.push({ tone: "info", text: "CI passed. Landing on main." });
117
- }
118
- }
119
- return observations.slice(0, 4);
120
- }