patchrelay 0.36.19 → 0.37.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.
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +25 -22
- package/dist/cli/data.js +1 -0
- package/dist/cli/formatters/text.js +5 -1
- package/dist/cli/watch/App.js +226 -27
- package/dist/cli/watch/HelpBar.js +18 -9
- package/dist/cli/watch/IssueDetailView.js +32 -14
- package/dist/cli/watch/IssueRow.js +4 -3
- package/dist/cli/watch/StatusBar.js +2 -1
- package/dist/cli/watch/detail-rows.js +5 -25
- package/dist/cli/watch/detail-status.js +38 -0
- package/dist/cli/watch/layout-measure.js +7 -0
- package/dist/cli/watch/pr-status.js +2 -1
- package/dist/cli/watch/prompt-layout.js +14 -0
- package/dist/cli/watch/state-visualization.js +5 -1
- package/dist/cli/watch/timeline-builder.js +169 -18
- package/dist/cli/watch/timeline-presentation.js +21 -1
- package/dist/cli/watch/transient-status.js +28 -0
- package/dist/cli/watch/watch-actions.js +76 -0
- package/dist/cli/watch/watch-state.js +2 -12
- package/dist/factory-state.js +1 -1
- package/dist/github-webhook-handler.js +26 -4
- package/dist/idle-reconciliation.js +19 -2
- package/dist/implementation-outcome-policy.js +3 -1
- package/dist/issue-overview-query.js +5 -0
- package/dist/linear-session-reporting.js +15 -6
- package/dist/linear-status-comment-sync.js +13 -1
- package/dist/pr-state.js +49 -0
- package/dist/service-issue-actions.js +5 -4
- package/dist/tracked-issue-list-query.js +3 -1
- package/dist/tracked-issue-projector.js +5 -0
- package/dist/waiting-reason.js +3 -2
- package/package.json +1 -1
- package/dist/cli/watch/ItemLine.js +0 -80
- package/dist/cli/watch/Timeline.js +0 -22
- package/dist/cli/watch/TimelineRow.js +0 -77
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
import { buildStateHistory } from "./history-builder.js";
|
|
2
3
|
import { buildTimelineRows } from "./timeline-presentation.js";
|
|
3
4
|
import { planStepColor, planStepSymbol } from "./plan-helpers.js";
|
|
4
5
|
import { progressBar } from "./format-utils.js";
|
|
5
|
-
import { describePatchRelayFreshness } from "./freshness.js";
|
|
6
6
|
import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
|
|
7
7
|
import { renderRichTextLines, renderTextLines } from "./render-rich-text.js";
|
|
8
8
|
const SESSION_DISPLAY = {
|
|
@@ -85,20 +85,10 @@ function buildHeaderLines(input, width) {
|
|
|
85
85
|
headerSegments.push({ text: " ", dimColor: true });
|
|
86
86
|
headerSegments.push(...joinFactSegments(facts));
|
|
87
87
|
}
|
|
88
|
-
if (input.activeRunStartedAt) {
|
|
89
|
-
headerSegments.push({ text: " ", dimColor: true });
|
|
90
|
-
headerSegments.push({ text: elapsedLabel(input.activeRunStartedAt), dimColor: true });
|
|
91
|
-
}
|
|
92
88
|
if (meta.length > 0) {
|
|
93
89
|
headerSegments.push({ text: " ", dimColor: true });
|
|
94
90
|
headerSegments.push({ text: meta.join(" "), dimColor: true });
|
|
95
91
|
}
|
|
96
|
-
headerSegments.push({ text: " ", dimColor: true });
|
|
97
|
-
headerSegments.push(...freshnessSegments(input.connected, input.lastServerMessageAt));
|
|
98
|
-
if (input.follow) {
|
|
99
|
-
headerSegments.push({ text: " " });
|
|
100
|
-
headerSegments.push({ text: "live", color: "yellow", bold: true });
|
|
101
|
-
}
|
|
102
92
|
const lines = renderTextLines(segmentsToText(headerSegments), {
|
|
103
93
|
key: "detail-header",
|
|
104
94
|
width,
|
|
@@ -255,7 +245,7 @@ function renderTimelineItemLines(key, item, width, indent) {
|
|
|
255
245
|
? { color: "white" }
|
|
256
246
|
: { dimColor: item.type !== "commandExecution" },
|
|
257
247
|
});
|
|
258
|
-
if (item.output && item.
|
|
248
|
+
if (item.output && item.type === "commandExecution") {
|
|
259
249
|
lines.push(...renderTextLines(lastNonEmptyLine(item.output), {
|
|
260
250
|
key: `${key}-output`,
|
|
261
251
|
width,
|
|
@@ -407,7 +397,7 @@ function buildFactSegments(issue, issueContext) {
|
|
|
407
397
|
facts.push([{ text: "re-review needed", color: "yellow" }]);
|
|
408
398
|
else if (isChangesRequestedReviewState(issue.prReviewState))
|
|
409
399
|
facts.push([{ text: "changes requested", color: "yellow" }]);
|
|
410
|
-
else if (issue.prNumber
|
|
400
|
+
else if (hasOpenPr(issue.prNumber, issue.prState)
|
|
411
401
|
&& (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
|
|
412
402
|
facts.push([{ text: "awaiting review", color: "yellow" }]);
|
|
413
403
|
if (issue.factoryState === "awaiting_queue")
|
|
@@ -462,12 +452,6 @@ function formatDuration(startedAt, endedAt) {
|
|
|
462
452
|
function formatTime(iso) {
|
|
463
453
|
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
464
454
|
}
|
|
465
|
-
function elapsedLabel(startedAt) {
|
|
466
|
-
const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
|
|
467
|
-
const minutes = Math.floor(elapsed / 60);
|
|
468
|
-
const seconds = elapsed % 60;
|
|
469
|
-
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
470
|
-
}
|
|
471
455
|
function itemSummary(item) {
|
|
472
456
|
switch (item.type) {
|
|
473
457
|
case "commandExecution": {
|
|
@@ -520,7 +504,7 @@ function effectiveState(issue) {
|
|
|
520
504
|
return "blocked";
|
|
521
505
|
if (issue.sessionState === "waiting_input")
|
|
522
506
|
return "awaiting_input";
|
|
523
|
-
if (issue.prNumber
|
|
507
|
+
if (hasOpenPr(issue.prNumber, issue.prState))
|
|
524
508
|
return issue.factoryState;
|
|
525
509
|
if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
|
|
526
510
|
return "ready";
|
|
@@ -563,7 +547,7 @@ function blockerText(issue, issueContext) {
|
|
|
563
547
|
return "Awaiting re-review after requested changes";
|
|
564
548
|
if (isChangesRequestedReviewState(issue.prReviewState))
|
|
565
549
|
return "Review changes requested";
|
|
566
|
-
if (issue.prNumber
|
|
550
|
+
if (hasOpenPr(issue.prNumber, issue.prState) && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
|
|
567
551
|
return "Awaiting review";
|
|
568
552
|
}
|
|
569
553
|
return null;
|
|
@@ -598,10 +582,6 @@ function feedGlyph(status) {
|
|
|
598
582
|
return "✓";
|
|
599
583
|
return "●";
|
|
600
584
|
}
|
|
601
|
-
function freshnessSegments(connected, lastServerMessageAt) {
|
|
602
|
-
const freshness = describePatchRelayFreshness(connected, lastServerMessageAt);
|
|
603
|
-
return [{ text: freshness.label, color: freshness.color, bold: true }];
|
|
604
|
-
}
|
|
605
585
|
function blankLine(key) {
|
|
606
586
|
return { key, segments: [] };
|
|
607
587
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describePatchRelayFreshness } from "./freshness.js";
|
|
2
|
+
export function buildDetailStatusSegments(input, now = Date.now()) {
|
|
3
|
+
const groups = [];
|
|
4
|
+
groups.push(input.follow
|
|
5
|
+
? [{ text: "live edge", color: "green", bold: true }]
|
|
6
|
+
: [{ text: "anchored review", color: "yellow", bold: true }]);
|
|
7
|
+
if (input.unreadBelow > 0) {
|
|
8
|
+
groups.push([{ text: `${input.unreadBelow} new below`, color: "yellow", bold: true }]);
|
|
9
|
+
}
|
|
10
|
+
if (input.activeRunStartedAt) {
|
|
11
|
+
groups.push([{ text: `run ${formatElapsed(input.activeRunStartedAt, now)}`, dimColor: true }]);
|
|
12
|
+
}
|
|
13
|
+
const freshness = describePatchRelayFreshness(input.connected, input.lastServerMessageAt, now);
|
|
14
|
+
groups.push([{ text: freshness.label, color: freshness.color, bold: true }]);
|
|
15
|
+
return joinGroups(groups);
|
|
16
|
+
}
|
|
17
|
+
export function buildDetailStatusText(input, now = Date.now()) {
|
|
18
|
+
return buildDetailStatusSegments(input, now).map((segment) => segment.text).join("");
|
|
19
|
+
}
|
|
20
|
+
function formatElapsed(startedAt, now) {
|
|
21
|
+
const startedMs = Date.parse(startedAt);
|
|
22
|
+
if (!Number.isFinite(startedMs))
|
|
23
|
+
return "0m 00s";
|
|
24
|
+
const elapsed = Math.max(0, Math.floor((now - startedMs) / 1000));
|
|
25
|
+
const minutes = Math.floor(elapsed / 60);
|
|
26
|
+
const seconds = elapsed % 60;
|
|
27
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
28
|
+
}
|
|
29
|
+
function joinGroups(groups) {
|
|
30
|
+
const segments = [];
|
|
31
|
+
for (const [index, group] of groups.entries()) {
|
|
32
|
+
if (index > 0) {
|
|
33
|
+
segments.push({ text: " ", dimColor: true });
|
|
34
|
+
}
|
|
35
|
+
segments.push(...group);
|
|
36
|
+
}
|
|
37
|
+
return segments;
|
|
38
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
function isPassingCheckStatus(status) {
|
|
2
3
|
return status === "passed" || status === "success";
|
|
3
4
|
}
|
|
@@ -72,7 +73,7 @@ export function prChecksFact(issue) {
|
|
|
72
73
|
return undefined;
|
|
73
74
|
}
|
|
74
75
|
export function hasDisplayPrBlocker(issue) {
|
|
75
|
-
if (issue.prNumber
|
|
76
|
+
if (!hasOpenPr(issue.prNumber, issue.prState) || issue.activeRunType) {
|
|
76
77
|
return false;
|
|
77
78
|
}
|
|
78
79
|
if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { measureRenderedTextRows } from "./layout-measure.js";
|
|
2
|
+
export const PROMPT_COMPOSER_HINT = "Enter: send Ctrl-N: newline Up/Down: history Esc: cancel";
|
|
3
|
+
export function buildPromptComposerDisplayLines(buffer, cursor) {
|
|
4
|
+
const withCursor = `${buffer.slice(0, cursor)}|${buffer.slice(cursor)}`;
|
|
5
|
+
const contentLines = withCursor.split("\n");
|
|
6
|
+
return [
|
|
7
|
+
...contentLines.map((line, index) => `${index === 0 ? "prompt> " : " "}${line}`),
|
|
8
|
+
PROMPT_COMPOSER_HINT,
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
export function measurePromptComposerRows(buffer, cursor, width) {
|
|
12
|
+
return buildPromptComposerDisplayLines(buffer, cursor)
|
|
13
|
+
.reduce((count, line) => count + measureRenderedTextRows(line, width), 0);
|
|
14
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
const STATE_LABELS = {
|
|
2
3
|
delegated: "delegated",
|
|
3
4
|
implementing: "implementing",
|
|
@@ -163,9 +164,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
if (issue.prNumber !== undefined) {
|
|
167
|
+
const prLabel = hasOpenPr(issue.prNumber, issue.prState)
|
|
168
|
+
? `Tracked PR: #${issue.prNumber}`
|
|
169
|
+
: `Tracked PR: #${issue.prNumber}${issue.prState ? ` (${issue.prState})` : ""}`;
|
|
166
170
|
observations.push({
|
|
167
171
|
tone: "info",
|
|
168
|
-
text:
|
|
172
|
+
text: `${prLabel}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
|
|
169
173
|
});
|
|
170
174
|
}
|
|
171
175
|
return observations.slice(0, 3);
|
|
@@ -2,6 +2,7 @@ import { getThreadTurns } from "../../codex-thread-utils.js";
|
|
|
2
2
|
// ─── Build Timeline from Rehydration Data ─────────────────────────
|
|
3
3
|
export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
|
|
4
4
|
const entries = [];
|
|
5
|
+
const activeRun = activeRunId ? runs.find((run) => run.id === activeRunId) : undefined;
|
|
5
6
|
// 1. Add run boundaries and items from reports
|
|
6
7
|
for (const run of runs) {
|
|
7
8
|
entries.push({
|
|
@@ -32,22 +33,28 @@ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activ
|
|
|
32
33
|
}
|
|
33
34
|
// 2. Items from live thread (active run)
|
|
34
35
|
if (liveThread && activeRunId) {
|
|
35
|
-
entries.push(...itemsFromThread(activeRunId, liveThread));
|
|
36
|
+
entries.push(...itemsFromThread(activeRunId, liveThread, activeRun?.startedAt));
|
|
36
37
|
}
|
|
37
38
|
// 3. Feed events → feed entries + CI check aggregation
|
|
38
39
|
entries.push(...feedEventsToEntries(feedEvents));
|
|
39
40
|
// 4. Sort by timestamp, then by entry order for stability
|
|
40
|
-
entries
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
return sortTimelineEntries(entries);
|
|
42
|
+
}
|
|
43
|
+
export function reconcileTimelineFromRehydration(previousTimeline, runs, feedEvents, liveThread, activeRunId) {
|
|
44
|
+
const rehydrated = buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId);
|
|
45
|
+
if (previousTimeline.length === 0) {
|
|
46
|
+
return rehydrated;
|
|
47
|
+
}
|
|
48
|
+
const previousById = new Map(previousTimeline.map((entry) => [entry.id, entry]));
|
|
49
|
+
const rehydratedIds = new Set(rehydrated.map((entry) => entry.id));
|
|
50
|
+
const liveUserMessages = collectUserMessageCounts(rehydrated, activeRunId);
|
|
51
|
+
const merged = rehydrated.map((entry) => mergeTimelineEntry(previousById.get(entry.id), entry));
|
|
52
|
+
const carriedForward = previousTimeline.filter((entry) => {
|
|
53
|
+
if (rehydratedIds.has(entry.id))
|
|
54
|
+
return false;
|
|
55
|
+
return shouldCarryForwardEntry(entry, activeRunId, liveUserMessages);
|
|
49
56
|
});
|
|
50
|
-
return
|
|
57
|
+
return sortTimelineEntries([...merged, ...carriedForward]);
|
|
51
58
|
}
|
|
52
59
|
function kindOrder(kind) {
|
|
53
60
|
switch (kind) {
|
|
@@ -131,27 +138,37 @@ function syntheticTimestamp(startMs, endMs, index, total) {
|
|
|
131
138
|
return new Date(startMs + fraction * (endMs - startMs)).toISOString();
|
|
132
139
|
}
|
|
133
140
|
// ─── Items from Live Thread ───────────────────────────────────────
|
|
134
|
-
function itemsFromThread(runId, thread) {
|
|
141
|
+
function itemsFromThread(runId, thread, runStartedAt) {
|
|
135
142
|
const entries = [];
|
|
143
|
+
let itemIndex = 0;
|
|
136
144
|
for (const turn of getThreadTurns(thread)) {
|
|
137
145
|
for (const item of turn.items) {
|
|
138
146
|
entries.push({
|
|
139
147
|
id: `live-${item.id}`,
|
|
140
|
-
at:
|
|
148
|
+
at: liveItemTimestamp(runStartedAt, itemIndex),
|
|
141
149
|
kind: "item",
|
|
142
150
|
runId,
|
|
143
151
|
item: materializeItem(item),
|
|
144
152
|
});
|
|
153
|
+
itemIndex += 1;
|
|
145
154
|
}
|
|
146
155
|
}
|
|
147
156
|
return entries;
|
|
148
157
|
}
|
|
158
|
+
const LIVE_ITEM_FALLBACK_START_MS = Date.UTC(9999, 0, 1, 0, 0, 0, 0);
|
|
159
|
+
function liveItemTimestamp(runStartedAt, itemIndex) {
|
|
160
|
+
const baseMs = runStartedAt ? Date.parse(runStartedAt) : LIVE_ITEM_FALLBACK_START_MS;
|
|
161
|
+
const stableBaseMs = Number.isFinite(baseMs) ? baseMs : LIVE_ITEM_FALLBACK_START_MS;
|
|
162
|
+
return new Date(stableBaseMs + itemIndex).toISOString();
|
|
163
|
+
}
|
|
149
164
|
function materializeItem(item) {
|
|
150
165
|
const r = item;
|
|
151
166
|
const id = String(r.id ?? "unknown");
|
|
152
167
|
const type = String(r.type ?? "unknown");
|
|
153
168
|
const base = { id, type, status: "completed" };
|
|
154
169
|
switch (type) {
|
|
170
|
+
case "userMessage":
|
|
171
|
+
return { ...base, text: extractUserMessageText(r.content) };
|
|
155
172
|
case "agentMessage":
|
|
156
173
|
return { ...base, text: String(r.text ?? "") };
|
|
157
174
|
case "commandExecution":
|
|
@@ -340,16 +357,150 @@ function mergeDefinedItemFields(base, patch) {
|
|
|
340
357
|
...base,
|
|
341
358
|
id: patch.id,
|
|
342
359
|
type: patch.type,
|
|
343
|
-
status: patch.status,
|
|
344
|
-
...(patch.text !== undefined ? { text: patch.text } : {}),
|
|
360
|
+
status: preferredItemStatus(base.status, patch.status),
|
|
361
|
+
...(mergePreferredString(base.text, patch.text) !== undefined ? { text: mergePreferredString(base.text, patch.text) } : {}),
|
|
345
362
|
...(patch.command !== undefined ? { command: patch.command } : {}),
|
|
346
|
-
...(patch.output !== undefined ? { output: patch.output } : {}),
|
|
363
|
+
...(mergePreferredString(base.output, patch.output) !== undefined ? { output: mergePreferredString(base.output, patch.output) } : {}),
|
|
347
364
|
...(patch.exitCode !== undefined ? { exitCode: patch.exitCode } : {}),
|
|
348
|
-
...(patch.durationMs !== undefined
|
|
349
|
-
|
|
365
|
+
...(patch.durationMs !== undefined || base.durationMs !== undefined
|
|
366
|
+
? { durationMs: preferredNumber(base.durationMs, patch.durationMs) }
|
|
367
|
+
: {}),
|
|
368
|
+
...(patch.changes !== undefined || base.changes !== undefined
|
|
369
|
+
? { changes: preferredChanges(base.changes, patch.changes) }
|
|
370
|
+
: {}),
|
|
350
371
|
...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),
|
|
351
372
|
};
|
|
352
373
|
}
|
|
374
|
+
function mergeTimelineEntry(existing, incoming) {
|
|
375
|
+
if (!existing || existing.kind !== incoming.kind) {
|
|
376
|
+
return incoming;
|
|
377
|
+
}
|
|
378
|
+
switch (incoming.kind) {
|
|
379
|
+
case "item":
|
|
380
|
+
return {
|
|
381
|
+
...incoming,
|
|
382
|
+
at: existing.at,
|
|
383
|
+
...(existing.runId !== undefined && incoming.runId === undefined ? { runId: existing.runId } : {}),
|
|
384
|
+
item: incoming.item && existing.item ? mergeDefinedItemFields(existing.item, incoming.item) : incoming.item,
|
|
385
|
+
};
|
|
386
|
+
case "run-start":
|
|
387
|
+
case "run-end":
|
|
388
|
+
return {
|
|
389
|
+
...incoming,
|
|
390
|
+
at: existing.at,
|
|
391
|
+
run: existing.run && incoming.run ? { ...existing.run, ...incoming.run } : incoming.run,
|
|
392
|
+
};
|
|
393
|
+
case "feed":
|
|
394
|
+
return {
|
|
395
|
+
...incoming,
|
|
396
|
+
at: existing.at,
|
|
397
|
+
feed: existing.feed && incoming.feed ? { ...existing.feed, ...incoming.feed } : incoming.feed,
|
|
398
|
+
};
|
|
399
|
+
case "ci-checks":
|
|
400
|
+
return {
|
|
401
|
+
...incoming,
|
|
402
|
+
at: existing.at,
|
|
403
|
+
ciChecks: incoming.ciChecks ?? existing.ciChecks,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function shouldCarryForwardEntry(entry, activeRunId, liveUserMessages) {
|
|
408
|
+
if (entry.kind !== "item" || entry.runId !== activeRunId) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
if (entry.item?.id.startsWith("prompt-") === true) {
|
|
412
|
+
const text = normalizePromptText(entry.item.text);
|
|
413
|
+
return !text || !consumeUserMessageMatch(liveUserMessages, text);
|
|
414
|
+
}
|
|
415
|
+
return entry.item?.status === "inProgress";
|
|
416
|
+
}
|
|
417
|
+
function sortTimelineEntries(entries) {
|
|
418
|
+
return [...entries].sort((a, b) => {
|
|
419
|
+
const cmp = a.at.localeCompare(b.at);
|
|
420
|
+
if (cmp !== 0)
|
|
421
|
+
return cmp;
|
|
422
|
+
// Within same timestamp: run-start before items, items before run-end
|
|
423
|
+
const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
|
|
424
|
+
if (kindCmp !== 0)
|
|
425
|
+
return kindCmp;
|
|
426
|
+
return a.id.localeCompare(b.id);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function preferredItemStatus(existing, incoming) {
|
|
430
|
+
return itemStatusRank(incoming) >= itemStatusRank(existing) ? incoming : existing;
|
|
431
|
+
}
|
|
432
|
+
function itemStatusRank(status) {
|
|
433
|
+
switch (status) {
|
|
434
|
+
case "failed":
|
|
435
|
+
case "completed":
|
|
436
|
+
case "declined":
|
|
437
|
+
return 2;
|
|
438
|
+
case "inProgress":
|
|
439
|
+
return 1;
|
|
440
|
+
default:
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function mergePreferredString(existing, incoming) {
|
|
445
|
+
if (incoming === undefined)
|
|
446
|
+
return existing;
|
|
447
|
+
if (existing === undefined)
|
|
448
|
+
return incoming;
|
|
449
|
+
return incoming.length >= existing.length ? incoming : existing;
|
|
450
|
+
}
|
|
451
|
+
function preferredNumber(existing, incoming) {
|
|
452
|
+
return incoming ?? existing;
|
|
453
|
+
}
|
|
454
|
+
function preferredChanges(existing, incoming) {
|
|
455
|
+
if (incoming === undefined)
|
|
456
|
+
return existing;
|
|
457
|
+
if (existing === undefined)
|
|
458
|
+
return incoming;
|
|
459
|
+
return incoming.length >= existing.length ? incoming : existing;
|
|
460
|
+
}
|
|
461
|
+
function collectUserMessageCounts(entries, activeRunId) {
|
|
462
|
+
const texts = new Map();
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
if (entry.kind !== "item" || entry.runId !== activeRunId || entry.item?.type !== "userMessage") {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const text = normalizePromptText(entry.item.text);
|
|
468
|
+
if (text) {
|
|
469
|
+
texts.set(text, (texts.get(text) ?? 0) + 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return texts;
|
|
473
|
+
}
|
|
474
|
+
function consumeUserMessageMatch(messages, text) {
|
|
475
|
+
const count = messages.get(text) ?? 0;
|
|
476
|
+
if (count <= 0) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (count === 1) {
|
|
480
|
+
messages.delete(text);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
messages.set(text, count - 1);
|
|
484
|
+
}
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
function normalizePromptText(text) {
|
|
488
|
+
const normalized = text?.trim();
|
|
489
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
490
|
+
}
|
|
491
|
+
function extractUserMessageText(content) {
|
|
492
|
+
if (!Array.isArray(content))
|
|
493
|
+
return "";
|
|
494
|
+
return content
|
|
495
|
+
.map((entry) => {
|
|
496
|
+
if (!entry || typeof entry !== "object")
|
|
497
|
+
return undefined;
|
|
498
|
+
const value = entry.text;
|
|
499
|
+
return typeof value === "string" ? value : undefined;
|
|
500
|
+
})
|
|
501
|
+
.filter((value) => Boolean(value))
|
|
502
|
+
.join("\n\n");
|
|
503
|
+
}
|
|
353
504
|
// ─── Feed Events to Timeline Entries ──────────────────────────────
|
|
354
505
|
function feedEventsToEntries(feedEvents) {
|
|
355
506
|
const entries = [];
|
|
@@ -76,6 +76,7 @@ function buildCompactTimelineRows(entries) {
|
|
|
76
76
|
}
|
|
77
77
|
for (const run of runs.values()) {
|
|
78
78
|
const status = resolveCompactRunStatus(run.run, run.items);
|
|
79
|
+
const verboseItems = status === "running" ? selectVerboseItems(run.items) : run.items;
|
|
79
80
|
rows.push({
|
|
80
81
|
id: run.id,
|
|
81
82
|
kind: "run",
|
|
@@ -83,7 +84,7 @@ function buildCompactTimelineRows(entries) {
|
|
|
83
84
|
finalized: status !== "running",
|
|
84
85
|
run: { ...run.run, status, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
|
|
85
86
|
details: summarizeRunDetails(run.items),
|
|
86
|
-
items:
|
|
87
|
+
items: verboseItems.map((item) => ({ at: run.at, item })),
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
rows.sort((left, right) => {
|
|
@@ -167,6 +168,25 @@ function summarizeRunDetails(items) {
|
|
|
167
168
|
}
|
|
168
169
|
return dedupeDetails(details).slice(0, 3);
|
|
169
170
|
}
|
|
171
|
+
function selectVerboseItems(items) {
|
|
172
|
+
const latestAssistantMessage = findLatest(items, (item) => item.type === "agentMessage" && Boolean(item.text?.trim()));
|
|
173
|
+
const latestUserMessage = !latestAssistantMessage
|
|
174
|
+
? findLatest(items, (item) => item.type === "userMessage" && Boolean(item.text?.trim()))
|
|
175
|
+
: undefined;
|
|
176
|
+
const activeCommand = findLatest(items, (item) => item.type === "commandExecution" && item.status === "inProgress");
|
|
177
|
+
const latestCommandWithOutput = findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.output?.trim()));
|
|
178
|
+
const latestCommand = activeCommand
|
|
179
|
+
?? latestCommandWithOutput
|
|
180
|
+
?? findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.command?.trim()));
|
|
181
|
+
const latestFileChange = findLatest(items, (item) => item.type === "fileChange" && Array.isArray(item.changes) && item.changes.length > 0);
|
|
182
|
+
const latestToolCall = !latestFileChange
|
|
183
|
+
? findLatest(items, (item) => item.type === "mcpToolCall" || item.type === "dynamicToolCall")
|
|
184
|
+
: undefined;
|
|
185
|
+
const selectedIds = new Set([latestUserMessage, latestAssistantMessage, latestCommand, latestFileChange, latestToolCall]
|
|
186
|
+
.filter((item) => Boolean(item))
|
|
187
|
+
.map((item) => item.id));
|
|
188
|
+
return items.filter((item) => selectedIds.has(item.id));
|
|
189
|
+
}
|
|
170
190
|
function summarizeNarrative(input) {
|
|
171
191
|
const normalized = input
|
|
172
192
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const defaultTimerApi = {
|
|
2
|
+
setTimeout(callback, delayMs) {
|
|
3
|
+
return setTimeout(callback, delayMs);
|
|
4
|
+
},
|
|
5
|
+
clearTimeout(timer) {
|
|
6
|
+
clearTimeout(timer);
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
export function showTransientStatus(controller, message, setStatus, timers, delayMs = 3_000) {
|
|
10
|
+
setStatus(message);
|
|
11
|
+
if (controller.timer !== null) {
|
|
12
|
+
timers.clearTimeout(controller.timer);
|
|
13
|
+
}
|
|
14
|
+
controller.timer = timers.setTimeout(() => {
|
|
15
|
+
controller.timer = null;
|
|
16
|
+
setStatus(null);
|
|
17
|
+
}, delayMs);
|
|
18
|
+
}
|
|
19
|
+
export function setPersistentStatus(controller, message, setStatus, timers) {
|
|
20
|
+
clearTransientStatus(controller, timers);
|
|
21
|
+
setStatus(message);
|
|
22
|
+
}
|
|
23
|
+
export function clearTransientStatus(controller, timers) {
|
|
24
|
+
if (controller.timer !== null) {
|
|
25
|
+
timers.clearTimeout(controller.timer);
|
|
26
|
+
controller.timer = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { buildDetailLines } from "./detail-rows.js";
|
|
6
|
+
import { lineToPlainText } from "./render-rich-text.js";
|
|
7
|
+
export function findLastAssistantMessage(timeline) {
|
|
8
|
+
return findLastItemField(timeline, (entry) => entry.item?.type === "agentMessage", "text");
|
|
9
|
+
}
|
|
10
|
+
export function findLastCommand(timeline) {
|
|
11
|
+
return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution", "command");
|
|
12
|
+
}
|
|
13
|
+
export function findLastCommandOutput(timeline) {
|
|
14
|
+
return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution" && Boolean(entry.item?.output?.trim()), "output");
|
|
15
|
+
}
|
|
16
|
+
export function buildWatchDetailExportText(input) {
|
|
17
|
+
const lines = buildDetailLines({
|
|
18
|
+
...input,
|
|
19
|
+
width: input.width ?? 100,
|
|
20
|
+
});
|
|
21
|
+
return `${lines.map(lineToPlainText).join("\n").trimEnd()}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function writeTextToClipboard(text, stream = process.stderr) {
|
|
24
|
+
if (!stream.isTTY || text.length === 0) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const encoded = Buffer.from(text, "utf8").toString("base64");
|
|
28
|
+
stream.write(`\u001b]52;c;${encoded}\u0007`);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
export function exportWatchTextToTempFile(text, issueKey) {
|
|
32
|
+
const directory = mkdtempSync(join(tmpdir(), "patchrelay-watch-"));
|
|
33
|
+
const safeIssueKey = issueKey.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
34
|
+
const filePath = join(directory, `${safeIssueKey || "issue"}-transcript.txt`);
|
|
35
|
+
writeFileSync(filePath, text, "utf8");
|
|
36
|
+
return filePath;
|
|
37
|
+
}
|
|
38
|
+
export function openTextInPager(text, stream = process.stderr) {
|
|
39
|
+
if (!stream.isTTY) {
|
|
40
|
+
return { ok: false, reason: "interactive TTY required" };
|
|
41
|
+
}
|
|
42
|
+
const streamWithFd = stream;
|
|
43
|
+
if (typeof streamWithFd.fd !== "number") {
|
|
44
|
+
return { ok: false, reason: "TTY stream fd unavailable" };
|
|
45
|
+
}
|
|
46
|
+
const pagerCommand = process.env.PAGER?.trim() || "less -R";
|
|
47
|
+
stream.write("\u001b[?1049l");
|
|
48
|
+
try {
|
|
49
|
+
const result = spawnSync("/bin/sh", ["-lc", pagerCommand], {
|
|
50
|
+
input: text,
|
|
51
|
+
stdio: ["pipe", streamWithFd.fd, streamWithFd.fd],
|
|
52
|
+
});
|
|
53
|
+
if (result.error) {
|
|
54
|
+
return { ok: false, reason: result.error.message };
|
|
55
|
+
}
|
|
56
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
57
|
+
return { ok: false, reason: `${pagerCommand} exited with status ${result.status}` };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
stream.write("\u001b[?1049h\u001b[2J\u001b[H");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function findLastItemField(timeline, predicate, field) {
|
|
66
|
+
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
|
67
|
+
const entry = timeline[index];
|
|
68
|
+
if (!predicate(entry))
|
|
69
|
+
continue;
|
|
70
|
+
const value = entry.item?.[field];
|
|
71
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { reconcileTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
|
|
2
2
|
// ─── Array size caps (prevent OOM) ───────────────────────────────
|
|
3
3
|
const MAX_TIMELINE_ENTRIES = 2000;
|
|
4
4
|
const MAX_RAW_FEED_EVENTS = 2000;
|
|
5
|
-
const MAX_FEED_EVENTS = 1000;
|
|
6
5
|
function capArray(arr, max) {
|
|
7
6
|
return arr.length > max ? arr.slice(arr.length - max) : arr;
|
|
8
7
|
}
|
|
@@ -32,7 +31,6 @@ export const initialWatchState = {
|
|
|
32
31
|
filter: "non-done",
|
|
33
32
|
follow: true,
|
|
34
33
|
...DETAIL_INITIAL,
|
|
35
|
-
feedEvents: [],
|
|
36
34
|
};
|
|
37
35
|
const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
|
|
38
36
|
function effectiveSessionState(issue) {
|
|
@@ -216,7 +214,7 @@ export function watchReducer(state, action) {
|
|
|
216
214
|
};
|
|
217
215
|
}
|
|
218
216
|
case "timeline-rehydrate": {
|
|
219
|
-
const timeline =
|
|
217
|
+
const timeline = reconcileTimelineFromRehydration(state.timeline, action.runs, action.feedEvents, action.liveThread, action.activeRunId);
|
|
220
218
|
const activeRun = action.runs.find((r) => r.id === action.activeRunId);
|
|
221
219
|
return {
|
|
222
220
|
...state,
|
|
@@ -243,14 +241,6 @@ export function watchReducer(state, action) {
|
|
|
243
241
|
...state,
|
|
244
242
|
...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
|
|
245
243
|
};
|
|
246
|
-
case "enter-feed":
|
|
247
|
-
return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
|
|
248
|
-
case "exit-feed":
|
|
249
|
-
return { ...state, view: "list" };
|
|
250
|
-
case "feed-snapshot":
|
|
251
|
-
return { ...state, feedEvents: action.events };
|
|
252
|
-
case "feed-new-event":
|
|
253
|
-
return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
|
|
254
244
|
case "switch-detail-tab":
|
|
255
245
|
return { ...state, follow: true, ...DETAIL_INITIAL, detailTab: action.tab };
|
|
256
246
|
default:
|
package/dist/factory-state.js
CHANGED
|
@@ -30,7 +30,7 @@ const TRANSITION_RULES = [
|
|
|
30
30
|
// pr_closed during an active run is suppressed — Codex may reopen.
|
|
31
31
|
// Without a guard match, the event produces no transition (undefined).
|
|
32
32
|
{ event: "pr_closed",
|
|
33
|
-
guard: (
|
|
33
|
+
guard: (s, ctx) => ctx.activeRunId === undefined && !TERMINAL_STATES.has(s),
|
|
34
34
|
to: "failed" },
|
|
35
35
|
// ── PR lifecycle ───────────────────────────────────────────────
|
|
36
36
|
{ event: "pr_opened",
|