patchrelay 0.24.0 → 0.24.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.24.0",
4
- "commit": "1bc0f4083571",
5
- "builtAt": "2026-03-26T16:28:13.654Z"
3
+ "version": "0.24.2",
4
+ "commit": "0e5b887df632",
5
+ "builtAt": "2026-03-26T16:49:14.393Z"
6
6
  }
@@ -59,7 +59,20 @@ function RunLine({ run, index }) {
59
59
  ? formatDuration(run.startedAt, run.endedAt)
60
60
  : undefined;
61
61
  const isActive = run.status === "running";
62
- return (_jsxs(Box, { children: [_jsxs(Text, { color: runStatusColor(run.status), children: [runStatusSymbol(run.status), " "] }), _jsxs(Text, { dimColor: true, children: ["run #", index + 1, " "] }), _jsxs(Text, { children: ["(", label, ")"] }), dur && _jsxs(Text, { dimColor: true, children: [" ", dur] }), isActive && _jsx(Text, { dimColor: true, children: " ..." })] }));
62
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: runStatusColor(run.status), children: [runStatusSymbol(run.status), " "] }), _jsxs(Text, { dimColor: true, children: ["#", index + 1, " "] }), _jsxs(Text, { children: ["(", label, ")"] }), dur && _jsxs(Text, { dimColor: true, children: [" ", dur] }), isActive && _jsx(Text, { dimColor: true, children: " ..." })] }));
63
+ }
64
+ function RunSummary({ runs }) {
65
+ const completed = runs.filter((r) => r.status === "completed").length;
66
+ const failed = runs.filter((r) => r.status === "failed").length;
67
+ const running = runs.filter((r) => r.status === "running").length;
68
+ const parts = [];
69
+ if (completed > 0)
70
+ parts.push(`${completed} completed`);
71
+ if (failed > 0)
72
+ parts.push(`${failed} failed`);
73
+ if (running > 0)
74
+ parts.push(`${running} active`);
75
+ return _jsxs(Text, { dimColor: true, children: [runs.length, " runs: ", parts.join(", ")] });
63
76
  }
64
77
  function PlanSteps({ plan }) {
65
78
  return (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`))) }));
@@ -74,7 +87,8 @@ function MainPathNode({ node, isLast, runOffset, plan, activeRunId, }) {
74
87
  const marker = node.isCurrent ? "\u25c9" : "\u25cb";
75
88
  const stateColor = node.isCurrent ? "green" : "white";
76
89
  const hasActiveRun = node.runs.some((r) => r.id === activeRunId);
77
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: stateColor, bold: node.isCurrent, children: [" ", marker, " "] }), _jsx(Text, { color: stateColor, bold: node.isCurrent, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(node.enteredAt)] })] }), node.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(Text, { dimColor: true, children: node.reason })] })), node.runs.map((run, ri) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(RunLine, { run: run, index: runOffset + ri })] }), run.id === activeRunId && plan && plan.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast ? " " : " \u2502 " }), _jsx(PlanSteps, { plan: plan })] }))] }, `run-${run.id}`))), node.sideTrips.length > 0 && (_jsx(Box, { flexDirection: "column", children: node.sideTrips.map((trip, ti) => {
90
+ const gutter = isLast && node.sideTrips.length === 0 ? " " : " \u2502 ";
91
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: stateColor, bold: node.isCurrent, children: [" ", marker, " "] }), _jsx(Text, { color: stateColor, bold: node.isCurrent, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(node.enteredAt)] })] }), node.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: gutter }), _jsx(Text, { dimColor: true, children: node.reason })] })), node.runs.length > 5 && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: gutter }), _jsx(RunSummary, { runs: node.runs })] })), node.runs.map((run, ri) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: gutter }), _jsx(RunLine, { run: run, index: runOffset + ri })] }), run.id === activeRunId && plan && plan.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: gutter }), _jsx(PlanSteps, { plan: plan })] }))] }, `run-${run.id}`))), node.sideTrips.length > 0 && (_jsx(Box, { flexDirection: "column", children: node.sideTrips.map((trip, ti) => {
78
92
  // Count runs before this side-trip for numbering
79
93
  const priorSideTripRuns = node.sideTrips.slice(0, ti).reduce((acc, st) => acc + st.runs.length, 0);
80
94
  const tripRunOffset = runOffset + node.runs.length + priorSideTripRuns;
@@ -15,7 +15,6 @@ function extractTransitions(feedEvents) {
15
15
  let reason;
16
16
  if (event.kind === "stage") {
17
17
  if (event.status === "starting") {
18
- // stage field is runType, map to factory state
19
18
  state = RUN_TYPE_TO_STATE[event.stage] ?? event.stage;
20
19
  reason = event.summary;
21
20
  }
@@ -25,7 +24,6 @@ function extractTransitions(feedEvents) {
25
24
  }
26
25
  }
27
26
  else if (event.kind === "github") {
28
- // stage field is the factory state AFTER the transition
29
27
  state = event.stage;
30
28
  reason = event.summary;
31
29
  }
@@ -39,52 +37,96 @@ function extractTransitions(feedEvents) {
39
37
  }
40
38
  return transitions;
41
39
  }
42
- // ─── Run matching ────────────────────────────────────────────────
43
- function buildRunQueue(runs) {
44
- // Group runs by their corresponding factory state, preserving chronological order.
45
- // Each call to consumeNextRun() pops from the front.
46
- const map = new Map();
40
+ // ─── Run helpers ─────────────────────────────────────────────────
41
+ function toRunInfo(run) {
42
+ return {
43
+ id: run.id,
44
+ runType: run.runType,
45
+ status: run.status,
46
+ startedAt: run.startedAt,
47
+ endedAt: run.endedAt,
48
+ };
49
+ }
50
+ function runToState(run) {
51
+ return RUN_TYPE_TO_STATE[run.runType] ?? run.runType;
52
+ }
53
+ // ─── Build from runs only (no feed events) ───────────────────────
54
+ function buildFromRuns(runs, currentFactoryState) {
55
+ if (runs.length === 0)
56
+ return [];
57
+ const nodes = [];
58
+ const earliest = runs[0];
59
+ // Seed with delegated
60
+ nodes.push({
61
+ state: "delegated",
62
+ enteredAt: earliest.startedAt,
63
+ isCurrent: false,
64
+ runs: [],
65
+ sideTrips: [],
66
+ });
67
+ // Group consecutive runs by their mapped state.
68
+ // When the state changes, create a new node.
69
+ let currentState = "";
70
+ let currentNode = null;
47
71
  for (const run of runs) {
48
- const state = RUN_TYPE_TO_STATE[run.runType] ?? run.runType;
49
- const info = {
50
- id: run.id,
51
- runType: run.runType,
52
- status: run.status,
53
- startedAt: run.startedAt,
54
- endedAt: run.endedAt,
55
- };
56
- const list = map.get(state);
57
- if (list) {
58
- list.push(info);
72
+ const state = runToState(run);
73
+ if (state !== currentState) {
74
+ currentState = state;
75
+ currentNode = {
76
+ state,
77
+ enteredAt: run.startedAt,
78
+ isCurrent: false,
79
+ runs: [toRunInfo(run)],
80
+ sideTrips: [],
81
+ };
82
+ nodes.push(currentNode);
59
83
  }
60
84
  else {
61
- map.set(state, [info]);
85
+ currentNode.runs.push(toRunInfo(run));
62
86
  }
63
87
  }
64
- return map;
88
+ // If the current factory state differs from the last node's state,
89
+ // add a final node (e.g., implementing → failed)
90
+ const lastNodeState = nodes[nodes.length - 1].state;
91
+ if (currentFactoryState !== lastNodeState && currentFactoryState !== "delegated") {
92
+ const lastRun = runs[runs.length - 1];
93
+ nodes.push({
94
+ state: currentFactoryState,
95
+ enteredAt: lastRun.endedAt ?? lastRun.startedAt,
96
+ isCurrent: false,
97
+ runs: [],
98
+ sideTrips: [],
99
+ });
100
+ }
101
+ return nodes;
65
102
  }
66
- // ─── Tree builder ────────────────────────────────────────────────
67
- export function buildStateHistory(runs, feedEvents, currentFactoryState, activeRunId) {
68
- const transitions = extractTransitions(feedEvents);
69
- const runQueue = buildRunQueue(runs);
103
+ // ─── Build from events + runs ────────────────────────────────────
104
+ function buildFromEvents(runs, transitions, currentFactoryState) {
105
+ // Build a chronological queue of runs per state
106
+ const runQueues = new Map();
107
+ for (const run of runs) {
108
+ const state = runToState(run);
109
+ const queue = runQueues.get(state);
110
+ if (queue) {
111
+ queue.push(toRunInfo(run));
112
+ }
113
+ else {
114
+ runQueues.set(state, [toRunInfo(run)]);
115
+ }
116
+ }
70
117
  function consumeNextRun(state) {
71
- const queue = runQueue.get(state);
118
+ const queue = runQueues.get(state);
72
119
  if (!queue || queue.length === 0)
73
120
  return [];
74
- const run = queue.shift();
75
- return run ? [run] : [];
121
+ return [queue.shift()];
76
122
  }
77
- // Walk transitions and build nodes
78
123
  const nodes = [];
79
124
  let currentMainNode = null;
80
125
  let currentSideTrip = null;
81
- for (let i = 0; i < transitions.length; i++) {
82
- const t = transitions[i];
126
+ for (const t of transitions) {
83
127
  const isSideTrip = SIDE_TRIP_STATES.has(t.state);
84
128
  if (isSideTrip) {
85
- // Start or continue a side-trip
86
129
  if (currentSideTrip) {
87
- // Close previous side-trip first (nested side-trip is rare but handle it)
88
130
  closeSideTrip(currentMainNode, currentSideTrip, t.state, t.at);
89
131
  }
90
132
  currentSideTrip = {
@@ -96,19 +138,15 @@ export function buildStateHistory(runs, feedEvents, currentFactoryState, activeR
96
138
  };
97
139
  }
98
140
  else {
99
- // Main-path state
100
141
  if (currentSideTrip && currentMainNode) {
101
- // Close the active side-trip — we're returning to the main path
102
- // Consume runs for the side-trip state now
103
142
  currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
104
143
  currentSideTrip.returnState = t.state;
105
144
  currentSideTrip.returnedAt = t.at;
106
145
  currentMainNode.sideTrips.push(currentSideTrip);
107
146
  currentSideTrip = null;
108
147
  }
109
- // Skip duplicate main-path nodes if returning to the same state (e.g., pr_open → changes_requested → pr_open)
148
+ // Skip duplicate when returning from a side-trip to the same state
110
149
  if (currentMainNode && currentMainNode.state === t.state) {
111
- // Same main-path state revisited — don't create a new node
112
150
  continue;
113
151
  }
114
152
  currentMainNode = {
@@ -122,40 +160,55 @@ export function buildStateHistory(runs, feedEvents, currentFactoryState, activeR
122
160
  nodes.push(currentMainNode);
123
161
  }
124
162
  }
125
- // If we ended in a side-trip (e.g., currently repairing_ci), close it
163
+ // Close any open side-trip
126
164
  if (currentSideTrip && currentMainNode) {
127
165
  currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
128
166
  currentSideTrip.returnState = currentFactoryState;
129
167
  currentMainNode.sideTrips.push(currentSideTrip);
130
168
  }
131
- // Handle edge case: no transitions extracted but we have runs
132
- if (nodes.length === 0 && runs.length > 0) {
133
- // Seed with delegated state from earliest run
134
- const earliest = runs[0];
135
- nodes.push({
136
- state: "delegated",
137
- enteredAt: earliest.startedAt,
138
- isCurrent: currentFactoryState === "delegated",
139
- runs: [],
140
- sideTrips: [],
141
- });
142
- const implState = RUN_TYPE_TO_STATE[earliest.runType] ?? "implementing";
143
- nodes.push({
144
- state: implState,
145
- enteredAt: earliest.startedAt,
146
- isCurrent: currentFactoryState === implState,
147
- runs: consumeNextRun(implState),
148
- sideTrips: [],
149
- });
169
+ // Distribute remaining unconsumed runs to matching nodes
170
+ for (const [state, remaining] of runQueues) {
171
+ if (remaining.length === 0)
172
+ continue;
173
+ // Find the last node (or side-trip) matching this state
174
+ let target;
175
+ for (let i = nodes.length - 1; i >= 0; i--) {
176
+ const node = nodes[i];
177
+ if (node.state === state) {
178
+ target = node;
179
+ break;
180
+ }
181
+ for (let j = node.sideTrips.length - 1; j >= 0; j--) {
182
+ if (node.sideTrips[j].state === state) {
183
+ target = node.sideTrips[j];
184
+ break;
185
+ }
186
+ }
187
+ if (target)
188
+ break;
189
+ }
190
+ if (target) {
191
+ target.runs.push(...remaining);
192
+ }
193
+ remaining.length = 0;
150
194
  }
151
- // Mark the current state
195
+ return nodes;
196
+ }
197
+ // ─── Main entry point ────────────────────────────────────────────
198
+ export function buildStateHistory(runs, feedEvents, currentFactoryState, activeRunId) {
199
+ const transitions = extractTransitions(feedEvents);
200
+ const nodes = transitions.length > 0
201
+ ? buildFromEvents(runs, transitions, currentFactoryState)
202
+ : buildFromRuns(runs, currentFactoryState);
203
+ if (nodes.length === 0)
204
+ return [];
152
205
  markCurrent(nodes, currentFactoryState);
153
- // Mark active run
154
206
  if (activeRunId !== null) {
155
207
  markActiveRun(nodes, activeRunId);
156
208
  }
157
209
  return nodes;
158
210
  }
211
+ // ─── Helpers ─────────────────────────────────────────────────────
159
212
  function closeSideTrip(mainNode, sideTrip, returnState, returnedAt) {
160
213
  if (!mainNode)
161
214
  return;
@@ -164,22 +217,18 @@ function closeSideTrip(mainNode, sideTrip, returnState, returnedAt) {
164
217
  mainNode.sideTrips.push(sideTrip);
165
218
  }
166
219
  function markCurrent(nodes, currentState) {
167
- // If current state is a side-trip state, mark the last main node as current
168
- // (the side-trip is "in progress" from that main node)
169
220
  if (SIDE_TRIP_STATES.has(currentState)) {
170
221
  if (nodes.length > 0) {
171
222
  nodes[nodes.length - 1].isCurrent = true;
172
223
  }
173
224
  return;
174
225
  }
175
- // Find the last node matching the current state
176
226
  for (let i = nodes.length - 1; i >= 0; i--) {
177
227
  if (nodes[i].state === currentState) {
178
228
  nodes[i].isCurrent = true;
179
229
  return;
180
230
  }
181
231
  }
182
- // Fallback: mark the last node
183
232
  if (nodes.length > 0) {
184
233
  nodes[nodes.length - 1].isCurrent = true;
185
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {