patchrelay 0.26.0 → 0.29.1

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 (57) hide show
  1. package/README.md +83 -31
  2. package/dist/agent-session-plan.js +0 -7
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/args.js +22 -18
  5. package/dist/cli/commands/feed.js +1 -1
  6. package/dist/cli/commands/issues.js +44 -4
  7. package/dist/cli/commands/linear.js +67 -0
  8. package/dist/cli/commands/repo.js +213 -0
  9. package/dist/cli/commands/setup.js +140 -21
  10. package/dist/cli/connect-flow.js +5 -3
  11. package/dist/cli/formatters/text.js +1 -1
  12. package/dist/cli/help.js +134 -63
  13. package/dist/cli/index.js +166 -188
  14. package/dist/cli/interactive.js +25 -0
  15. package/dist/cli/operator-client.js +11 -0
  16. package/dist/cli/service-commands.js +11 -4
  17. package/dist/cli/watch/App.js +1 -1
  18. package/dist/cli/watch/FactoryStateGraph.js +31 -0
  19. package/dist/cli/watch/FeedView.js +3 -2
  20. package/dist/cli/watch/FreshnessBadge.js +13 -0
  21. package/dist/cli/watch/IssueDetailView.js +9 -2
  22. package/dist/cli/watch/IssueListView.js +2 -2
  23. package/dist/cli/watch/IssueRow.js +9 -11
  24. package/dist/cli/watch/QueueObservationView.js +15 -0
  25. package/dist/cli/watch/StateHistoryView.js +0 -1
  26. package/dist/cli/watch/StatusBar.js +5 -2
  27. package/dist/cli/watch/format-utils.js +7 -0
  28. package/dist/cli/watch/freshness.js +30 -0
  29. package/dist/cli/watch/state-visualization.js +147 -0
  30. package/dist/cli/watch/theme.js +6 -7
  31. package/dist/cli/watch/use-watch-stream.js +5 -2
  32. package/dist/cli/watch/watch-state.js +9 -5
  33. package/dist/config.js +129 -36
  34. package/dist/db/linear-installation-store.js +23 -0
  35. package/dist/db/migrations.js +42 -0
  36. package/dist/db/repository-link-store.js +103 -0
  37. package/dist/db.js +61 -11
  38. package/dist/factory-state.js +1 -5
  39. package/dist/github-webhook-handler.js +115 -46
  40. package/dist/github-webhooks.js +4 -0
  41. package/dist/http.js +162 -0
  42. package/dist/install.js +93 -13
  43. package/dist/issue-query-service.js +34 -1
  44. package/dist/linear-client.js +80 -25
  45. package/dist/merge-queue-incident.js +104 -0
  46. package/dist/merge-queue-protocol.js +54 -0
  47. package/dist/preflight.js +28 -1
  48. package/dist/repository-linking.js +42 -0
  49. package/dist/run-orchestrator.js +197 -21
  50. package/dist/runtime-paths.js +0 -8
  51. package/dist/service.js +94 -49
  52. package/package.json +8 -7
  53. package/dist/cli/commands/connect.js +0 -54
  54. package/dist/cli/commands/project.js +0 -146
  55. package/dist/merge-queue.js +0 -200
  56. package/infra/patchrelay-reload.service +0 -6
  57. package/infra/patchrelay.path +0 -13
@@ -6,6 +6,11 @@ import { StateHistoryView } from "./StateHistoryView.js";
6
6
  import { buildStateHistory } from "./history-builder.js";
7
7
  import { HelpBar } from "./HelpBar.js";
8
8
  import { planStepSymbol, planStepColor } from "./plan-helpers.js";
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
+ import { FreshnessBadge } from "./FreshnessBadge.js";
9
14
  function formatTokens(n) {
10
15
  if (n >= 1_000_000)
11
16
  return `${(n / 1_000_000).toFixed(1)}M`;
@@ -24,7 +29,7 @@ function ElapsedTime({ startedAt }) {
24
29
  const seconds = elapsed % 60;
25
30
  return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
26
31
  }
27
- export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, timelineMode, rawRuns, rawFeedEvents, }) {
32
+ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, timelineMode, rawRuns, rawFeedEvents, connected, lastServerMessageAt, }) {
28
33
  if (!issue) {
29
34
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode })] }));
30
35
  }
@@ -37,5 +42,7 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
37
42
  if (issueContext?.runCount)
38
43
  meta.push(`${issueContext.runCount} runs`);
39
44
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, 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}`))) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
45
+ const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
+ const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), 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, mode: timelineMode }) })] })) : (_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, timelineMode: timelineMode }) })] }));
41
48
  }
@@ -7,7 +7,7 @@ import { HelpBar } from "./HelpBar.js";
7
7
  // selector(2) + key(10) + status(13) + pr(7) + ago(4) + gaps = ~36
8
8
  const FIXED_COLS = 40;
9
9
  const CHROME_ROWS = 4;
10
- export function IssueListView({ issues, allIssues, selectedIndex, connected, filter, totalCount }) {
10
+ export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, }) {
11
11
  const { stdout } = useStdout();
12
12
  const cols = stdout?.columns ?? 80;
13
13
  const rows = stdout?.rows ?? 24;
@@ -23,5 +23,5 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, fil
23
23
  const visible = issues.slice(startIndex, startIndex + maxVisible);
24
24
  const hiddenAbove = startIndex;
25
25
  const hiddenBelow = Math.max(0, issues.length - startIndex - maxVisible);
26
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, allIssues: allIssues }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
26
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
27
27
  }
@@ -2,32 +2,30 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  const STATE_COLORS = {
5
- delegated: "blue",
6
- preparing: "blue",
7
- implementing: "yellow",
5
+ delegated: "cyan",
6
+ implementing: "cyan",
8
7
  pr_open: "cyan",
9
- changes_requested: "magenta",
10
- repairing_ci: "magenta",
11
- awaiting_queue: "green",
12
- repairing_queue: "magenta",
8
+ changes_requested: "yellow",
9
+ repairing_ci: "cyan",
10
+ awaiting_queue: "cyan",
11
+ repairing_queue: "cyan",
13
12
  done: "green",
14
13
  failed: "red",
15
14
  escalated: "red",
16
15
  awaiting_input: "yellow",
17
16
  };
18
17
  const STATE_SHORT = {
19
- delegated: "queued",
20
- preparing: "prep",
18
+ delegated: "delegated",
21
19
  implementing: "impl",
22
20
  pr_open: "pr open",
23
21
  changes_requested: "review fix",
24
22
  repairing_ci: "ci fix",
25
- awaiting_queue: "merging",
23
+ awaiting_queue: "await queue",
26
24
  repairing_queue: "merge fix",
27
25
  done: "done",
28
26
  failed: "failed",
29
27
  escalated: "escalated",
30
- awaiting_input: "paused",
28
+ awaiting_input: "await input",
31
29
  };
32
30
  const STATUS_SHORT = {
33
31
  running: "\u25b8",
@@ -0,0 +1,15 @@
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 QueueObservationView({ observations }) {
14
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Queue Observation" }), observations.map((observation, index) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: toneColor(observation.tone), children: "-" }), _jsx(Text, { children: observation.text })] }, `queue-observation-${index}`)))] }));
15
+ }
@@ -40,7 +40,6 @@ function runStatusColor(status) {
40
40
  }
41
41
  const STATE_LABELS = {
42
42
  delegated: "delegated",
43
- preparing: "preparing",
44
43
  implementing: "implementing",
45
44
  pr_open: "pr open",
46
45
  changes_requested: "changes requested",
@@ -1,13 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { computeAggregates } from "./watch-state.js";
4
+ import { FreshnessBadge } from "./FreshnessBadge.js";
4
5
  const FILTER_LABELS = {
5
6
  "all": "all",
6
7
  "active": "active",
7
8
  "non-done": "in progress",
8
9
  };
9
- export function StatusBar({ issues, totalCount, filter, connected, allIssues }) {
10
+ export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, }) {
10
11
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
11
12
  const agg = computeAggregates(allIssues);
12
- 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: "yellow", children: [agg.active, " active"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(Text, { color: connected ? "green" : "red", children: connected ? "\u25cf connected" : "\u25cb disconnected" })] }));
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"] }), 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"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
13
16
  }
@@ -36,6 +36,13 @@ export function formatTokens(n) {
36
36
  return `${(n / 1_000).toFixed(1)}k`;
37
37
  return String(n);
38
38
  }
39
+ /** Render a progress bar: ████░░░░ */
40
+ export function progressBar(current, total, width) {
41
+ if (total <= 0 || width <= 0)
42
+ return "\u2591".repeat(width);
43
+ const filled = Math.min(width, Math.round((current / total) * width));
44
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
45
+ }
39
46
  /** Truncate text to max length with ellipsis. Collapses newlines. */
40
47
  export function truncate(text, max) {
41
48
  const line = text.replace(/\n/g, " ").trim();
@@ -0,0 +1,30 @@
1
+ const PATCHRELAY_FRESH_MS = 20_000;
2
+ const PATCHRELAY_STALE_MS = 40_000;
3
+ function formatAge(ms) {
4
+ const seconds = Math.max(0, Math.floor(ms / 1000));
5
+ if (seconds < 60)
6
+ return `${seconds}s`;
7
+ const minutes = Math.floor(seconds / 60);
8
+ const rem = seconds % 60;
9
+ return rem === 0 ? `${minutes}m` : `${minutes}m${String(rem).padStart(2, "0")}s`;
10
+ }
11
+ export function describePatchRelayFreshness(connected, lastServerMessageAt, now = Date.now()) {
12
+ if (lastServerMessageAt === null) {
13
+ return {
14
+ label: connected ? "connecting" : "waiting for first server update",
15
+ color: connected ? "yellow" : "red",
16
+ };
17
+ }
18
+ const ageMs = Math.max(0, now - lastServerMessageAt);
19
+ const age = formatAge(ageMs);
20
+ if (!connected) {
21
+ return { label: `disconnected · stale ${age}`, color: "red" };
22
+ }
23
+ if (ageMs > PATCHRELAY_STALE_MS) {
24
+ return { label: `stream stalled? last server update ${age} ago`, color: "red" };
25
+ }
26
+ if (ageMs > PATCHRELAY_FRESH_MS) {
27
+ return { label: `quiet ${age} since last server update`, color: "yellow" };
28
+ }
29
+ return { label: `fresh ${age}`, color: "green" };
30
+ }
@@ -0,0 +1,147 @@
1
+ const STATE_LABELS = {
2
+ delegated: "delegated",
3
+ implementing: "implementing",
4
+ pr_open: "pr_open",
5
+ changes_requested: "changes_requested",
6
+ repairing_ci: "repairing_ci",
7
+ awaiting_queue: "awaiting_queue",
8
+ repairing_queue: "repairing_queue",
9
+ awaiting_input: "awaiting_input",
10
+ escalated: "escalated",
11
+ done: "done",
12
+ failed: "failed",
13
+ };
14
+ const MAIN_STATES = ["delegated", "implementing", "pr_open", "awaiting_queue", "done"];
15
+ const PR_LOOP_STATES = ["changes_requested", "repairing_ci"];
16
+ const QUEUE_LOOP_STATES = ["repairing_queue"];
17
+ const EXIT_STATES = ["awaiting_input", "escalated", "failed"];
18
+ const QUEUE_EVENT_STATUSES = new Set([
19
+ "queue_label_requested",
20
+ "queue_label_applied",
21
+ "queue_label_failed",
22
+ "queue_repair_queued",
23
+ "pr_merged",
24
+ ]);
25
+ function labelForState(state) {
26
+ return STATE_LABELS[state] ?? state;
27
+ }
28
+ function collectVisitedStates(history, currentFactoryState) {
29
+ const visited = new Set([currentFactoryState]);
30
+ for (const node of history) {
31
+ visited.add(node.state);
32
+ for (const sideTrip of node.sideTrips) {
33
+ visited.add(sideTrip.state);
34
+ visited.add(sideTrip.returnState);
35
+ }
36
+ }
37
+ return visited;
38
+ }
39
+ function buildNodes(states, visited, currentFactoryState) {
40
+ return states.map((state) => ({
41
+ state,
42
+ label: labelForState(state),
43
+ status: currentFactoryState === state
44
+ ? "current"
45
+ : visited.has(state)
46
+ ? "visited"
47
+ : "upcoming",
48
+ }));
49
+ }
50
+ function isQueueCheckFailure(event) {
51
+ if (event.kind !== "github" || event.status !== "check_failed") {
52
+ return false;
53
+ }
54
+ const haystack = `${event.summary} ${event.detail ?? ""}`;
55
+ return haystack.includes("merge-steward/queue");
56
+ }
57
+ function latestQueueObservationEvent(feedEvents) {
58
+ const queueEvents = feedEvents.filter((event) => QUEUE_EVENT_STATUSES.has(event.status ?? "") || isQueueCheckFailure(event)
59
+ || (event.kind === "stage" && event.stage === "repairing_queue"));
60
+ return queueEvents[queueEvents.length - 1];
61
+ }
62
+ function describeObservationEvent(event) {
63
+ switch (event.status) {
64
+ case "queue_label_requested":
65
+ return { tone: "info", text: event.summary };
66
+ case "queue_label_applied":
67
+ return { tone: "success", text: event.summary };
68
+ case "queue_label_failed":
69
+ return { tone: "warn", text: event.summary };
70
+ case "queue_repair_queued":
71
+ return { tone: "warn", text: event.summary };
72
+ case "pr_merged":
73
+ return { tone: "success", text: "GitHub reports the PR was merged." };
74
+ default:
75
+ if (isQueueCheckFailure(event)) {
76
+ return {
77
+ tone: "warn",
78
+ text: `External queue reported failure via ${event.detail ?? "merge-steward/queue"}.`,
79
+ };
80
+ }
81
+ if (event.kind === "stage" && event.stage === "repairing_queue") {
82
+ const active = event.status === "starting";
83
+ return {
84
+ tone: active ? "warn" : "info",
85
+ text: active ? "PatchRelay is actively running queue repair." : event.summary,
86
+ };
87
+ }
88
+ return { tone: "info", text: event.summary };
89
+ }
90
+ }
91
+ export function buildPatchRelayStateGraph(history, currentFactoryState) {
92
+ const visited = collectVisitedStates(history, currentFactoryState);
93
+ return {
94
+ main: buildNodes(MAIN_STATES, visited, currentFactoryState),
95
+ prLoops: buildNodes(PR_LOOP_STATES, visited, currentFactoryState),
96
+ queueLoop: buildNodes(QUEUE_LOOP_STATES, visited, currentFactoryState),
97
+ exits: buildNodes(EXIT_STATES, visited, currentFactoryState),
98
+ };
99
+ }
100
+ export function buildPatchRelayQueueObservations(issue, feedEvents) {
101
+ const observations = [];
102
+ switch (issue.factoryState) {
103
+ case "awaiting_queue":
104
+ observations.push({
105
+ tone: "info",
106
+ text: "PatchRelay has finished branch work and is waiting for external queue progress.",
107
+ });
108
+ break;
109
+ case "repairing_queue":
110
+ observations.push({
111
+ tone: issue.activeRunType === "queue_repair" ? "warn" : "info",
112
+ text: issue.activeRunType === "queue_repair"
113
+ ? "PatchRelay is actively repairing a queue eviction."
114
+ : "PatchRelay is preparing or waiting to resume queue repair.",
115
+ });
116
+ break;
117
+ case "done":
118
+ observations.push({
119
+ tone: "success",
120
+ text: "PatchRelay is complete because GitHub reports the PR has merged.",
121
+ });
122
+ break;
123
+ default:
124
+ observations.push({
125
+ tone: "info",
126
+ text: "Queue hand-off has not started yet; PatchRelay still owns the issue workflow.",
127
+ });
128
+ break;
129
+ }
130
+ const latestEvent = latestQueueObservationEvent(feedEvents);
131
+ if (latestEvent) {
132
+ observations.push(describeObservationEvent(latestEvent));
133
+ }
134
+ else if (issue.factoryState === "awaiting_queue") {
135
+ observations.push({
136
+ tone: "info",
137
+ text: "No external queue signal has been observed yet.",
138
+ });
139
+ }
140
+ if (issue.prNumber !== undefined) {
141
+ observations.push({
142
+ tone: "info",
143
+ text: `Tracked PR: #${issue.prNumber}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
144
+ });
145
+ }
146
+ return observations.slice(0, 3);
147
+ }
@@ -1,14 +1,13 @@
1
1
  // ─── Factory State Colors ─────────────────────────────────────────
2
2
  export const FACTORY_STATE_COLORS = {
3
- delegated: "blue",
4
- preparing: "blue",
5
- implementing: "yellow",
3
+ delegated: "cyan",
4
+ implementing: "cyan",
6
5
  awaiting_input: "yellow",
7
6
  pr_open: "cyan",
8
- changes_requested: "magenta",
9
- repairing_ci: "magenta",
10
- repairing_queue: "magenta",
11
- awaiting_queue: "green",
7
+ changes_requested: "yellow",
8
+ repairing_ci: "cyan",
9
+ repairing_queue: "cyan",
10
+ awaiting_queue: "cyan",
12
11
  done: "green",
13
12
  failed: "red",
14
13
  escalated: "red",
@@ -49,6 +49,9 @@ export function useWatchStream(options) {
49
49
  continue;
50
50
  }
51
51
  if (line.startsWith(":")) {
52
+ if (line.includes("keepalive")) {
53
+ dispatch({ type: "stream-heartbeat", receivedAt: Date.now() });
54
+ }
52
55
  newlineIndex = buffer.indexOf("\n");
53
56
  continue;
54
57
  }
@@ -89,11 +92,11 @@ function processEvent(dispatch, eventType, data) {
89
92
  try {
90
93
  if (eventType === "issues") {
91
94
  const issues = JSON.parse(data);
92
- dispatch({ type: "issues-snapshot", issues });
95
+ dispatch({ type: "issues-snapshot", issues, receivedAt: Date.now() });
93
96
  }
94
97
  else if (eventType === "feed") {
95
98
  const event = JSON.parse(data);
96
- dispatch({ type: "feed-event", event });
99
+ dispatch({ type: "feed-event", event, receivedAt: Date.now() });
97
100
  }
98
101
  }
99
102
  catch {
@@ -21,6 +21,7 @@ const DETAIL_INITIAL = {
21
21
  };
22
22
  export const initialWatchState = {
23
23
  connected: false,
24
+ lastServerMessageAt: null,
24
25
  issues: [],
25
26
  selectedIndex: 0,
26
27
  view: "list",
@@ -71,14 +72,17 @@ export function watchReducer(state, action) {
71
72
  return { ...state, connected: true };
72
73
  case "disconnected":
73
74
  return { ...state, connected: false };
75
+ case "stream-heartbeat":
76
+ return { ...state, lastServerMessageAt: action.receivedAt };
74
77
  case "issues-snapshot":
75
78
  return {
76
79
  ...state,
80
+ lastServerMessageAt: action.receivedAt,
77
81
  issues: action.issues,
78
82
  selectedIndex: Math.min(state.selectedIndex, Math.max(0, action.issues.length - 1)),
79
83
  };
80
84
  case "feed-event":
81
- return applyFeedEvent(state, action.event);
85
+ return applyFeedEvent(state, action.event, action.receivedAt);
82
86
  case "select":
83
87
  return {
84
88
  ...state,
@@ -135,13 +139,13 @@ export function watchReducer(state, action) {
135
139
  }
136
140
  }
137
141
  // ─── Feed Event → Issue List + Timeline ───────────────────────────
138
- function applyFeedEvent(state, event) {
142
+ function applyFeedEvent(state, event, receivedAt) {
139
143
  if (!event.issueKey) {
140
- return state;
144
+ return { ...state, lastServerMessageAt: receivedAt };
141
145
  }
142
146
  const index = state.issues.findIndex((issue) => issue.issueKey === event.issueKey);
143
147
  if (index === -1) {
144
- return state;
148
+ return { ...state, lastServerMessageAt: receivedAt };
145
149
  }
146
150
  const updated = [...state.issues];
147
151
  const issue = { ...updated[index] };
@@ -172,7 +176,7 @@ function applyFeedEvent(state, event) {
172
176
  const rawFeedEvents = isActiveDetail
173
177
  ? capArray([...state.rawFeedEvents, event], MAX_RAW_FEED_EVENTS)
174
178
  : state.rawFeedEvents;
175
- return { ...state, issues: updated, timeline, rawFeedEvents };
179
+ return { ...state, lastServerMessageAt: receivedAt, issues: updated, timeline, rawFeedEvents };
176
180
  }
177
181
  // ─── Codex Notification → Timeline + Metadata ─────────────────────
178
182
  function applyCodexNotification(state, method, params) {