patchrelay 0.35.10 → 0.35.12

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.
Files changed (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +275 -74
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
package/dist/cli/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { getBuildInfo } from "../build-info.js";
3
3
  import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args.js";
4
- import { handleFeedCommand } from "./commands/feed.js";
5
4
  import { handleIssueCommand, } from "./commands/issues.js";
6
5
  import { handleLinearCommand } from "./commands/linear.js";
7
6
  import { handleRepoCommand } from "./commands/repo.js";
@@ -19,7 +18,6 @@ function getCommandConfigProfile(command) {
19
18
  case "service":
20
19
  return "doctor";
21
20
  case "linear":
22
- case "feed":
23
21
  case "dashboard":
24
22
  return "operator_cli";
25
23
  case "repo":
@@ -133,9 +131,6 @@ function validateFlags(command, commandArgs, parsed) {
133
131
  }
134
132
  assertKnownFlags(parsed, "service", []);
135
133
  return;
136
- case "feed":
137
- assertKnownFlags(parsed, command, ["follow", "limit", "issue", "repo", "kind", "stage", "status", "workflow", "json"]);
138
- return;
139
134
  case "dashboard":
140
135
  assertKnownFlags(parsed, command, ["issue"]);
141
136
  return;
@@ -323,21 +318,6 @@ export async function runCli(argv, options) {
323
318
  writeOutput(stderr, `${command} has been removed. Use \`patchrelay linear ...\` and \`patchrelay repo ...\` instead.\n`);
324
319
  return 1;
325
320
  }
326
- if (command === "feed") {
327
- const operatorData = parsed.flags.get("follow") === true
328
- ? await ensureFeedFollowDataAccess(data, config)
329
- : await ensureFeedListDataAccess(data, config);
330
- if (!data) {
331
- data = operatorData;
332
- ownsData = true;
333
- }
334
- return await handleFeedCommand({
335
- parsed,
336
- json,
337
- stdout,
338
- data: operatorData,
339
- });
340
- }
341
321
  if (command === "dashboard") {
342
322
  const { handleWatchCommand } = await import("./commands/watch.js");
343
323
  return await handleWatchCommand({ config, parsed });
@@ -362,10 +342,6 @@ async function createCliDataAccess(config) {
362
342
  const { CliDataAccess } = await import("./data.js");
363
343
  return new CliDataAccess(config);
364
344
  }
365
- async function createCliOperatorDataAccess(config) {
366
- const { CliOperatorApiClient } = await import("./operator-client.js");
367
- return new CliOperatorApiClient(config);
368
- }
369
345
  async function ensureIssueDataAccess(data, config) {
370
346
  if (data) {
371
347
  if (isIssueDataAccess(data)) {
@@ -378,27 +354,3 @@ async function ensureIssueDataAccess(data, config) {
378
354
  function isIssueDataAccess(data) {
379
355
  return !!data && typeof data === "object" && "inspect" in data && typeof data.inspect === "function";
380
356
  }
381
- async function ensureFeedListDataAccess(data, config) {
382
- if (data) {
383
- if (hasFeedListDataAccess(data)) {
384
- return data;
385
- }
386
- throw new Error("The feed command requires listOperatorFeed() data access.");
387
- }
388
- return await createCliOperatorDataAccess(config);
389
- }
390
- async function ensureFeedFollowDataAccess(data, config) {
391
- if (data) {
392
- if (hasFeedFollowDataAccess(data)) {
393
- return data;
394
- }
395
- throw new Error("The feed --follow command requires followOperatorFeed() data access.");
396
- }
397
- return await createCliOperatorDataAccess(config);
398
- }
399
- function hasFeedListDataAccess(data) {
400
- return !!data && typeof data === "object" && "listOperatorFeed" in data && typeof data.listOperatorFeed === "function";
401
- }
402
- function hasFeedFollowDataAccess(data) {
403
- return !!data && typeof data === "object" && "followOperatorFeed" in data && typeof data.followOperatorFeed === "function";
404
- }
@@ -29,88 +29,6 @@ export class CliOperatorApiClient {
29
29
  async disconnectLinearWorkspace(workspace) {
30
30
  return await this.requestJson(`/api/linear/workspaces/${encodeURIComponent(workspace)}`, undefined, { method: "DELETE" });
31
31
  }
32
- async listOperatorFeed(options) {
33
- return await this.requestJson("/api/feed", {
34
- ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
35
- ...(options?.issueKey ? { issue: options.issueKey } : {}),
36
- ...(options?.projectId ? { project: options.projectId } : {}),
37
- ...(options?.kind ? { kind: options.kind } : {}),
38
- ...(options?.stage ? { stage: options.stage } : {}),
39
- ...(options?.status ? { status: options.status } : {}),
40
- ...(options?.workflowId ? { workflow: options.workflowId } : {}),
41
- });
42
- }
43
- async followOperatorFeed(onEvent, options) {
44
- const url = new URL("/api/feed", this.getOperatorBaseUrl());
45
- url.searchParams.set("follow", "1");
46
- if (options?.limit && options.limit > 0) {
47
- url.searchParams.set("limit", String(options.limit));
48
- }
49
- if (options?.issueKey) {
50
- url.searchParams.set("issue", options.issueKey);
51
- }
52
- if (options?.projectId) {
53
- url.searchParams.set("project", options.projectId);
54
- }
55
- if (options?.kind) {
56
- url.searchParams.set("kind", options.kind);
57
- }
58
- if (options?.stage) {
59
- url.searchParams.set("stage", options.stage);
60
- }
61
- if (options?.status) {
62
- url.searchParams.set("status", options.status);
63
- }
64
- if (options?.workflowId) {
65
- url.searchParams.set("workflow", options.workflowId);
66
- }
67
- const response = await fetch(url, {
68
- method: "GET",
69
- headers: {
70
- accept: "text/event-stream",
71
- ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
72
- },
73
- });
74
- if (!response.ok || !response.body) {
75
- const body = await response.text().catch(() => "");
76
- const message = this.readErrorMessage(body);
77
- throw new Error(message ?? `Request failed: ${response.status}`);
78
- }
79
- const reader = response.body.getReader();
80
- const decoder = new TextDecoder();
81
- let buffer = "";
82
- let dataLines = [];
83
- while (true) {
84
- const { done, value } = await reader.read();
85
- if (done) {
86
- break;
87
- }
88
- buffer += decoder.decode(value, { stream: true });
89
- let newlineIndex = buffer.indexOf("\n");
90
- while (newlineIndex !== -1) {
91
- const rawLine = buffer.slice(0, newlineIndex);
92
- buffer = buffer.slice(newlineIndex + 1);
93
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
94
- if (!line) {
95
- if (dataLines.length > 0) {
96
- const parsed = JSON.parse(dataLines.join("\n"));
97
- onEvent(parsed);
98
- dataLines = [];
99
- }
100
- newlineIndex = buffer.indexOf("\n");
101
- continue;
102
- }
103
- if (line.startsWith(":")) {
104
- newlineIndex = buffer.indexOf("\n");
105
- continue;
106
- }
107
- if (line.startsWith("data:")) {
108
- dataLines.push(line.slice(5).trimStart());
109
- }
110
- newlineIndex = buffer.indexOf("\n");
111
- }
112
- }
113
- }
114
32
  getOperatorBaseUrl() {
115
33
  const host = this.normalizeLocalHost(this.config.server.bind);
116
34
  return `http://${host}:${this.config.server.port}/`;
@@ -4,10 +4,8 @@ import { Box, Text, useApp, useInput } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
- import { useFeedStream } from "./use-feed-stream.js";
8
7
  import { IssueListView } from "./IssueListView.js";
9
8
  import { IssueDetailView } from "./IssueDetailView.js";
10
- import { FeedView } from "./FeedView.js";
11
9
  async function postPrompt(baseUrl, issueKey, text, bearerToken) {
12
10
  const headers = { "content-type": "application/json" };
13
11
  if (bearerToken)
@@ -71,7 +69,6 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
71
69
  const [frozen, setFrozen] = useState(false);
72
70
  useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
73
71
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
74
- useFeedStream({ baseUrl, bearerToken, active: state.view === "feed" && !frozen, dispatch });
75
72
  const [promptMode, setPromptMode] = useState(false);
76
73
  const [promptBuffer, setPromptBuffer] = useState("");
77
74
  const handleRetry = useCallback(() => {
@@ -154,9 +151,6 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
154
151
  else if (key.tab) {
155
152
  dispatch({ type: "cycle-filter" });
156
153
  }
157
- else if (input === "F" || input === "f") {
158
- dispatch({ type: "enter-feed" });
159
- }
160
154
  }
161
155
  else if (state.view === "detail") {
162
156
  if (key.escape || key.backspace || key.delete) {
@@ -193,11 +187,6 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
193
187
  dispatch({ type: "detail-navigate", direction: "prev", filtered });
194
188
  }
195
189
  }
196
- else if (state.view === "feed") {
197
- if (key.escape || key.backspace || key.delete) {
198
- dispatch({ type: "exit-feed" });
199
- }
200
- }
201
190
  });
202
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
191
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
203
192
  }
@@ -9,10 +9,10 @@ export function HelpBar({ view, follow, detailTab }) {
9
9
  .join(" ");
10
10
  }
11
11
  else if (view === "feed") {
12
- text = "";
12
+ text = "Legacy feed view Esc: back";
13
13
  }
14
14
  else {
15
- text = "F: feed Tab: filter";
15
+ text = "Enter: detail Tab: filter";
16
16
  }
17
17
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
18
18
  }
@@ -7,9 +7,6 @@ import { buildStateHistory } from "./history-builder.js";
7
7
  import { HelpBar } from "./HelpBar.js";
8
8
  import { planStepSymbol, planStepColor } from "./plan-helpers.js";
9
9
  import { progressBar } from "./format-utils.js";
10
- import { FactoryStateGraph } from "./FactoryStateGraph.js";
11
- import { QueueObservationView } from "./QueueObservationView.js";
12
- import { buildPatchRelayQueueObservations, buildPatchRelayStateGraph } from "./state-visualization.js";
13
10
  import { FreshnessBadge } from "./FreshnessBadge.js";
14
11
  function formatTokens(n) {
15
12
  if (n >= 1_000_000)
@@ -46,46 +43,74 @@ function formatCheckState(checkState) {
46
43
  return null;
47
44
  }
48
45
  }
49
- const STATE_DISPLAY = {
50
- blocked: { label: "blocked", color: "yellow" },
51
- ready: { label: "ready", color: "blueBright" },
52
- delegated: { label: "delegated", color: "cyan" },
53
- implementing: { label: "implementing", color: "cyan" },
54
- pr_open: { label: "PR open", color: "cyan" },
55
- changes_requested: { label: "review changes", color: "yellow" },
56
- repairing_ci: { label: "repairing CI", color: "yellow" },
57
- awaiting_queue: { label: "queued for merge", color: "cyan" },
58
- repairing_queue: { label: "repairing queue", color: "yellow" },
59
- done: { label: "merged", color: "green" },
46
+ const SESSION_DISPLAY = {
47
+ idle: { label: "idle", color: "blueBright" },
48
+ running: { label: "running", color: "cyan" },
49
+ waiting_input: { label: "needs input", color: "yellow" },
50
+ done: { label: "done", color: "green" },
60
51
  failed: { label: "failed", color: "red" },
61
- escalated: { label: "escalated", color: "red" },
62
- awaiting_input: { label: "awaiting input", color: "yellow" },
52
+ };
53
+ const STAGE_DISPLAY = {
54
+ blocked: "blocked",
55
+ ready: "ready",
56
+ delegated: "delegated",
57
+ implementing: "implementing",
58
+ pr_open: "PR open",
59
+ changes_requested: "review changes",
60
+ repairing_ci: "repairing CI",
61
+ awaiting_queue: "waiting downstream",
62
+ repairing_queue: "repairing queue",
63
+ done: "merged",
64
+ failed: "failed",
65
+ escalated: "escalated",
66
+ awaiting_input: "needs input",
63
67
  };
64
68
  function effectiveState(issue) {
69
+ if (issue.sessionState === "done")
70
+ return "done";
71
+ if (issue.sessionState === "failed")
72
+ return "failed";
65
73
  if (issue.blockedByCount > 0 && !issue.activeRunType)
66
74
  return "blocked";
67
75
  if (issue.readyForExecution && !issue.activeRunType)
68
76
  return "ready";
77
+ if (issue.sessionState === "waiting_input")
78
+ return "awaiting_input";
69
79
  return issue.factoryState;
70
80
  }
81
+ function sessionDisplay(issue) {
82
+ const state = issue.sessionState ?? "unknown";
83
+ return SESSION_DISPLAY[state] ?? { label: state, color: "white" };
84
+ }
85
+ function stageDisplay(issue) {
86
+ const state = effectiveState(issue);
87
+ return STAGE_DISPLAY[state] ?? issue.factoryState;
88
+ }
71
89
  function blockerText(issue, issueContext) {
90
+ const rereviewNeeded = issue.prReviewState === "changes_requested"
91
+ && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
92
+ && !issue.activeRunType;
93
+ if (issue.sessionState === "waiting_input")
94
+ return issue.waitingReason ?? "Waiting for input";
95
+ if (issue.waitingReason && !issue.activeRunType)
96
+ return issue.waitingReason;
72
97
  if (issue.blockedByCount > 0)
73
98
  return `Waiting on ${issue.blockedByKeys.join(", ")}`;
74
- if (issue.factoryState === "repairing_queue")
99
+ if (effectiveState(issue) === "repairing_queue")
75
100
  return "Merge queue conflict, repairing branch";
76
- if (issue.factoryState === "repairing_ci") {
101
+ if (effectiveState(issue) === "repairing_ci") {
77
102
  const check = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName ?? "CI";
78
103
  return `Repairing ${check}`;
79
104
  }
80
- if (issue.factoryState === "awaiting_queue")
81
- return "Waiting for merge queue";
82
105
  if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
83
106
  const check = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName ?? "checks";
84
107
  return `${check} failed`;
85
108
  }
109
+ if (rereviewNeeded)
110
+ return "Awaiting re-review after requested changes";
86
111
  if (issue.prReviewState === "changes_requested")
87
112
  return "Review changes requested";
88
- if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done")
113
+ if (issue.prNumber !== undefined && !issue.prReviewState && effectiveState(issue) !== "done")
89
114
  return "Awaiting review";
90
115
  return null;
91
116
  }
@@ -102,7 +127,7 @@ function ElapsedTime({ startedAt }) {
102
127
  }
103
128
  export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, }) {
104
129
  if (!issue) {
105
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
130
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Loading issue\u2026" }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
106
131
  }
107
132
  const key = issue.issueKey ?? issue.projectId;
108
133
  const meta = [];
@@ -112,19 +137,25 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
112
137
  meta.push(`${diffSummary.filesChanged}f +${diffSummary.linesAdded} -${diffSummary.linesRemoved}`);
113
138
  if (issueContext?.runCount)
114
139
  meta.push(`${issueContext.runCount} runs`);
115
- const state = STATE_DISPLAY[effectiveState(issue)] ?? { label: issue.factoryState, color: "white" };
140
+ const session = sessionDisplay(issue);
141
+ const stage = stageDisplay(issue);
116
142
  const blocker = blockerText(issue, issueContext);
117
143
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
118
- const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
119
- const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
120
144
  // Build compact facts for the header
121
145
  const facts = [];
146
+ const rereviewNeeded = issue.prReviewState === "changes_requested"
147
+ && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
148
+ && !issue.activeRunType;
122
149
  if (issue.prNumber !== undefined)
123
150
  facts.push(`PR #${issue.prNumber}`);
124
151
  if (issue.prReviewState === "approved")
125
152
  facts.push("approved");
153
+ else if (rereviewNeeded)
154
+ facts.push("re-review needed");
126
155
  else if (issue.prReviewState === "changes_requested")
127
156
  facts.push("changes requested");
157
+ if (issue.waitingReason && issue.sessionState === "waiting_input")
158
+ facts.push(issue.waitingReason);
128
159
  if (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
129
160
  facts.push("checks passed");
130
161
  else if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
@@ -134,5 +165,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
134
165
  else if (issue.prChecksSummary?.total) {
135
166
  facts.push(`checks ${issue.prChecksSummary.completed}/${issue.prChecksSummary.total}`);
136
167
  }
137
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: state.color, children: state.label }), facts.length > 0 && _jsx(Text, { dimColor: true, children: facts.join(" \u00b7 ") }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), blocker && _jsx(Text, { color: "yellow", children: blocker }), issueContext?.latestFailureSummary && (_jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), 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}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab }) })] }));
168
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: session.color, children: session.label }), _jsx(Text, { dimColor: true, children: ` debug stage ${stage}` }), facts.length > 0 && _jsx(Text, { dimColor: true, children: facts.join(" \u00b7 ") }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), blocker && _jsx(Text, { color: "yellow", children: blocker }), issue.statusNote && issue.statusNote !== blocker && (_jsx(Text, { dimColor: true, wrap: "wrap", children: issue.statusNote })), issueContext?.latestFailureSummary && (_jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), 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}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow }) })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "PatchRelay activity history." }), _jsx(Text, { dimColor: true, children: "Runs, waits, and wake-ups are shown here in PatchRelay order." })] }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab }) })] }));
138
169
  }
@@ -3,48 +3,85 @@ import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  import { relativeTime, truncate } from "./format-utils.js";
5
5
  // ─── State display ──────────────────────────────────────────────
6
- const TERMINAL_STATES = new Set(["done", "failed", "escalated", "awaiting_input"]);
6
+ const TERMINAL_STATES = new Set(["done", "failed", "escalated"]);
7
7
  function effectiveState(issue) {
8
+ if (issue.sessionState === "done")
9
+ return "done";
10
+ if (issue.sessionState === "failed")
11
+ return "failed";
8
12
  if (issue.blockedByCount > 0 && !issue.activeRunType)
9
13
  return "blocked";
10
14
  if (issue.readyForExecution && !issue.activeRunType)
11
15
  return "ready";
16
+ if (issue.sessionState === "waiting_input")
17
+ return "awaiting_input";
12
18
  return issue.factoryState;
13
19
  }
14
- function stateDisplay(issue) {
20
+ function sessionDisplay(issue) {
21
+ switch (issue.sessionState) {
22
+ case "running":
23
+ return { label: "running", color: "cyan" };
24
+ case "idle":
25
+ return { label: "idle", color: "blueBright" };
26
+ case "waiting_input":
27
+ return { label: "needs input", color: "yellow" };
28
+ case "done":
29
+ return { label: "done", color: "green" };
30
+ case "failed":
31
+ return { label: "failed", color: "red" };
32
+ default:
33
+ return { label: "unknown", color: "white" };
34
+ }
35
+ }
36
+ function stageLabel(issue) {
15
37
  const state = effectiveState(issue);
16
38
  switch (state) {
17
- case "blocked": return { label: "blocked", color: "yellow" };
18
- case "ready": return { label: "ready", color: "blueBright" };
19
- case "delegated": return { label: "delegated", color: "cyan" };
20
- case "implementing": return { label: "implementing", color: "cyan" };
21
- case "pr_open": return { label: "PR open", color: "cyan" };
22
- case "changes_requested": return { label: "review changes", color: "yellow" };
23
- case "repairing_ci": return { label: "repairing CI", color: "yellow" };
24
- case "awaiting_queue": return { label: "queued for merge", color: "cyan" };
25
- case "repairing_queue": return { label: "repairing queue", color: "yellow" };
26
- case "done": return { label: "merged", color: "green" };
27
- case "failed": return { label: "failed", color: "red" };
28
- case "escalated": return { label: "escalated", color: "red" };
29
- case "awaiting_input": return { label: "awaiting input", color: "yellow" };
30
- default: return { label: state, color: "white" };
39
+ case "blocked": return "blocked";
40
+ case "ready": return "ready";
41
+ case "delegated": return "delegated";
42
+ case "implementing": return "implementing";
43
+ case "pr_open": return "PR open";
44
+ case "changes_requested": return "review changes";
45
+ case "repairing_ci": return "repairing CI";
46
+ case "awaiting_queue": return "waiting downstream";
47
+ case "repairing_queue": return "repairing queue";
48
+ case "done": return "merged";
49
+ case "failed": return "failed";
50
+ case "escalated": return "escalated";
51
+ case "awaiting_input": return "needs input";
52
+ default: return state;
31
53
  }
32
54
  }
33
55
  // ─── Context facts (what matters right now) ─────────────────────
34
- function buildFacts(issue) {
56
+ function buildFacts(issue, selected) {
35
57
  const facts = [];
58
+ const rereviewNeeded = issue.prReviewState === "changes_requested"
59
+ && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
60
+ && !issue.activeRunType;
36
61
  // PR number
37
62
  if (issue.prNumber !== undefined) {
38
63
  facts.push({ text: `PR #${issue.prNumber}` });
39
64
  }
65
+ if (!issue.sessionState) {
66
+ facts.push({ text: `stage ${stageLabel(issue)}` });
67
+ }
68
+ else if (selected) {
69
+ facts.push({ text: `internal stage ${stageLabel(issue)}` });
70
+ }
71
+ if (issue.waitingReason && issue.sessionState === "waiting_input") {
72
+ facts.push({ text: issue.waitingReason, color: "yellow" });
73
+ }
40
74
  // Review state — only show when it matters (not yet approved, or changes requested)
41
75
  if (issue.prReviewState === "approved") {
42
76
  facts.push({ text: "approved", color: "green" });
43
77
  }
78
+ else if (rereviewNeeded) {
79
+ facts.push({ text: "re-review needed", color: "yellow" });
80
+ }
44
81
  else if (issue.prReviewState === "changes_requested") {
45
82
  facts.push({ text: "changes requested", color: "yellow" });
46
83
  }
47
- else if (issue.prNumber !== undefined && !issue.prReviewState && !TERMINAL_STATES.has(issue.factoryState)) {
84
+ else if (issue.prNumber !== undefined && !issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))) {
48
85
  facts.push({ text: "awaiting review", color: "yellow" });
49
86
  }
50
87
  // Check status — compact
@@ -74,20 +111,27 @@ function buildFacts(issue) {
74
111
  }
75
112
  // ─── What's blocking progress ───────────────────────────────────
76
113
  function blockerText(issue) {
114
+ const rereviewNeeded = issue.prReviewState === "changes_requested"
115
+ && (issue.prCheckStatus === "passed" || issue.prCheckStatus === "success")
116
+ && !issue.activeRunType;
117
+ if (issue.sessionState === "waiting_input")
118
+ return issue.waitingReason ?? "Waiting for input";
119
+ if (issue.waitingReason && !issue.activeRunType)
120
+ return issue.waitingReason;
77
121
  if (issue.blockedByCount > 0)
78
122
  return `Waiting on ${issue.blockedByKeys.join(", ")}`;
79
- if (issue.factoryState === "repairing_queue")
123
+ if (effectiveState(issue) === "repairing_queue")
80
124
  return "Merge queue conflict, repairing branch";
81
- if (issue.factoryState === "repairing_ci") {
125
+ if (effectiveState(issue) === "repairing_ci") {
82
126
  const check = issue.latestFailureCheckName ?? "CI";
83
127
  return `Repairing ${check}`;
84
128
  }
85
- if (issue.factoryState === "awaiting_queue")
86
- return "Waiting for merge queue";
87
129
  if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
88
130
  const check = issue.latestFailureCheckName ?? "checks";
89
131
  return `${check} failed`;
90
132
  }
133
+ if (rereviewNeeded)
134
+ return "Awaiting re-review after requested changes";
91
135
  if (issue.prReviewState === "changes_requested")
92
136
  return "Review changes requested";
93
137
  return null;
@@ -98,13 +142,13 @@ export function IssueRow({ issue, selected, titleWidth }) {
98
142
  const tw = titleWidth ?? 60;
99
143
  const title = issue.title ? truncate(issue.title, tw) : "";
100
144
  const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
101
- const state = stateDisplay(issue);
102
- const facts = buildFacts(issue);
145
+ const session = sessionDisplay(issue);
146
+ const facts = buildFacts(issue, selected);
103
147
  const blocker = selected ? blockerText(issue) : null;
104
- const isTerminal = TERMINAL_STATES.has(issue.factoryState);
148
+ const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
105
149
  // Terminal issues: compact single line
106
150
  if (isTerminal && !selected) {
107
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: state.color, children: state.label })] }));
151
+ return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label })] }));
108
152
  }
109
- return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: state.color, children: state.label }), facts.length > 0 && (_jsx(Text, { dimColor: true, children: ` \u00b7 ` })), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` \u00b7 ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }), title ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: title }) })) : null, blocker ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", children: blocker }) })) : null, detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
153
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && (_jsx(Text, { dimColor: true, children: ` \u00b7 ` })), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` \u00b7 ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }), title ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: title }) })) : null, blocker ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", children: blocker }) })) : null, detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null, selected && issue.factoryState && issue.sessionState ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: `Debug stage: ${stageLabel(issue)}` }) })) : null] }));
110
154
  }
@@ -9,8 +9,11 @@ const FILTER_LABELS = {
9
9
  };
10
10
  export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, }) {
11
11
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
- const agg = computeAggregates(allIssues);
13
- const withPr = allIssues.filter((i) => i.prNumber !== undefined).length;
14
- const awaitingInput = allIssues.filter((i) => i.factoryState === "awaiting_input").length;
15
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
12
+ const aggregateSource = filter === "all" ? allIssues : issues;
13
+ const agg = computeAggregates(aggregateSource);
14
+ const withPr = aggregateSource.filter((i) => i.prNumber !== undefined).length;
15
+ const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
16
+ const running = aggregateSource.filter((i) => i.sessionState === "running").length;
17
+ const idle = aggregateSource.filter((i) => i.sessionState === "idle").length;
18
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), running > 0 && _jsxs(Text, { color: "cyan", children: [running, " running"] }), idle > 0 && _jsxs(Text, { color: "blueBright", children: [idle, " idle"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), waitingInput > 0 && _jsxs(Text, { color: "yellow", children: [waitingInput, " needs input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
16
19
  }