@mingchuno/agent-workflows 0.4.0 → 0.5.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.
@@ -0,0 +1,76 @@
1
+ import type { Key } from "ink";
2
+ import type { RunRecord } from "../domain.js";
3
+ import type { EventRecord, InvocationRecord, ProjectState } from "../store.js";
4
+ import type { MonitorAction } from "./data.js";
5
+ import type { LogSource } from "./log.js";
6
+ type Focus = "runs" | "summary" | "sessions";
7
+ type RunAction = "stop" | "retry" | "retry-refresh" | "recover";
8
+ type Selection = {
9
+ projectId?: string;
10
+ runId?: string;
11
+ };
12
+ type Modal = {
13
+ type: "help";
14
+ } | {
15
+ type: "confirmation";
16
+ kind: RunAction;
17
+ runId: string;
18
+ title: string;
19
+ } | {
20
+ type: "logs";
21
+ sources: LogSource[];
22
+ initial: number;
23
+ };
24
+ export interface MonitorNavigation {
25
+ selection: Selection;
26
+ /** Actual displayed run, which may fall back from a requested selection. */
27
+ displayedRunId?: string;
28
+ screen: "dashboard" | "details";
29
+ focus: Focus;
30
+ sessionId?: string;
31
+ stepSequence?: number;
32
+ offset: number;
33
+ modal?: Modal;
34
+ }
35
+ export declare const initialNavigation: MonitorNavigation;
36
+ interface NavigationContext {
37
+ terminalIsLargeEnough: boolean;
38
+ projects: ProjectState[];
39
+ project?: ProjectState;
40
+ runs: RunRecord[];
41
+ run?: RunRecord;
42
+ sessions: InvocationRecord[];
43
+ session?: InvocationRecord;
44
+ events: EventRecord[];
45
+ event?: EventRecord;
46
+ logSessions: InvocationRecord[];
47
+ logSession?: InvocationRecord;
48
+ available: Record<"stop" | "retry" | "recover", boolean>;
49
+ pending: boolean;
50
+ scrollMaximum: number;
51
+ pageSize: number;
52
+ }
53
+ export type NavigationEvent = {
54
+ type: "selection";
55
+ selection: Selection;
56
+ } | {
57
+ type: "dismiss";
58
+ } | {
59
+ type: "key";
60
+ input: string;
61
+ key: Key;
62
+ context: NavigationContext;
63
+ };
64
+ interface Transition {
65
+ state: MonitorNavigation;
66
+ effect?: {
67
+ type: "exit";
68
+ } | {
69
+ type: "command";
70
+ kind: MonitorAction;
71
+ target: string;
72
+ };
73
+ }
74
+ /** Pure navigation transitions; the monitor owns rendering and command effects. */
75
+ export declare function transitionNavigation(state: MonitorNavigation, event: NavigationEvent): Transition;
76
+ export {};
@@ -0,0 +1,187 @@
1
+ export const initialNavigation = {
2
+ selection: {},
3
+ screen: "dashboard",
4
+ focus: "runs",
5
+ offset: 0,
6
+ };
7
+ const focuses = ["runs", "summary", "sessions"];
8
+ /** Pure navigation transitions; the monitor owns rendering and command effects. */
9
+ export function transitionNavigation(state, event) {
10
+ if (event.type === "dismiss")
11
+ return { state: { ...state, modal: undefined } };
12
+ if (event.type === "selection")
13
+ return { state: synchronizeSelection(state, event.selection) };
14
+ const { input, key, context } = event;
15
+ if (key.ctrl || key.meta || key.eventType === "release")
16
+ return { state };
17
+ // Dialogs/logs own keyboard input, except when hidden by the resize screen.
18
+ if (state.modal && context.terminalIsLargeEnough)
19
+ return { state };
20
+ if (input === "q")
21
+ return { state, effect: { type: "exit" } };
22
+ if (!context.terminalIsLargeEnough)
23
+ return { state };
24
+ if (key.escape)
25
+ return {
26
+ state: { ...state, screen: "dashboard", focus: "runs", offset: 0 },
27
+ };
28
+ if (input === "?")
29
+ return { state: { ...state, modal: { type: "help" } } };
30
+ if (key.upArrow || key.downArrow || key.pageUp || key.pageDown)
31
+ return { state: moveVertically(state, key, context) };
32
+ if (input === "l")
33
+ return { state: openAgentLog(state, context) };
34
+ if (input === "v" && context.run?.validation?.length)
35
+ return {
36
+ state: {
37
+ ...state,
38
+ modal: {
39
+ type: "logs",
40
+ sources: context.run.validation.map((check) => ({
41
+ path: check.log,
42
+ label: `${check.command} · exit ${check.exitCode}`,
43
+ })),
44
+ initial: 0,
45
+ },
46
+ },
47
+ };
48
+ const kind = input === "s"
49
+ ? "stop"
50
+ : input === "r"
51
+ ? "retry"
52
+ : input === "R"
53
+ ? "retry-refresh"
54
+ : input === "c"
55
+ ? "recover"
56
+ : undefined;
57
+ if (kind &&
58
+ context.run &&
59
+ context.available[kind === "retry-refresh" ? "retry" : kind])
60
+ return {
61
+ state: {
62
+ ...state,
63
+ modal: {
64
+ type: "confirmation",
65
+ kind,
66
+ runId: context.run.id,
67
+ title: `#${context.run.issue.number} ${context.run.issue.title}`,
68
+ },
69
+ },
70
+ };
71
+ if (state.screen === "details")
72
+ return { state };
73
+ return dashboardInput(state, input, key, context);
74
+ }
75
+ function synchronizeSelection(state, selection) {
76
+ const sameRun = state.displayedRunId === selection.runId;
77
+ if (sameRun &&
78
+ state.selection.projectId === selection.projectId &&
79
+ state.selection.runId === selection.runId)
80
+ return state;
81
+ if (sameRun)
82
+ return { ...state, selection };
83
+ return {
84
+ ...state,
85
+ selection,
86
+ displayedRunId: selection.runId,
87
+ sessionId: undefined,
88
+ stepSequence: undefined,
89
+ offset: 0,
90
+ modal: state.modal?.type === "confirmation" ? undefined : state.modal,
91
+ };
92
+ }
93
+ function dashboardInput(state, input, key, context) {
94
+ if (key.tab)
95
+ return {
96
+ state: {
97
+ ...state,
98
+ focus: focuses[(focuses.indexOf(state.focus) + (key.shift ? 2 : 1)) % 3],
99
+ offset: 0,
100
+ },
101
+ };
102
+ if (input === "a")
103
+ return { state: { ...state, focus: "sessions" } };
104
+ if (key.leftArrow || key.rightArrow) {
105
+ const next = adjacent(context.projects, context.projects.findIndex((item) => item.id === context.project?.id), key.leftArrow ? -1 : 1);
106
+ return {
107
+ state: {
108
+ ...state,
109
+ selection: { projectId: next?.id },
110
+ focus: "runs",
111
+ offset: 0,
112
+ },
113
+ };
114
+ }
115
+ if (key.return && context.run)
116
+ return { state: { ...state, screen: "details", offset: 0 } };
117
+ if (input === "[" || input === "]") {
118
+ const next = adjacent(context.events, context.events.findIndex((item) => item.sequence === context.event?.sequence), input === "]" ? 1 : -1);
119
+ return { state: { ...state, stepSequence: next?.sequence } };
120
+ }
121
+ if (key.end)
122
+ return { state: { ...state, stepSequence: undefined } };
123
+ if (input === "p" && context.project && !context.pending)
124
+ return {
125
+ state,
126
+ effect: {
127
+ type: "command",
128
+ kind: context.project.paused ? "resume" : "pause",
129
+ target: context.project.id,
130
+ },
131
+ };
132
+ return { state };
133
+ }
134
+ function moveVertically(state, key, context) {
135
+ const delta = key.upArrow || key.pageUp ? -1 : 1;
136
+ if (state.screen === "details" || state.focus === "summary") {
137
+ const current = state.screen === "details"
138
+ ? Math.min(state.offset, context.scrollMaximum)
139
+ : state.offset;
140
+ const distance = key.pageUp || key.pageDown ? context.pageSize : 1;
141
+ return {
142
+ ...state,
143
+ offset: Math.max(0, Math.min(context.scrollMaximum, current + delta * distance)),
144
+ };
145
+ }
146
+ if (state.focus === "sessions") {
147
+ const next = adjacent(context.sessions, context.sessions.findIndex((item) => item.id === context.session?.id), delta);
148
+ return { ...state, sessionId: next?.id };
149
+ }
150
+ const next = adjacent(context.runs, context.runs.findIndex((item) => item.id === context.run?.id), delta);
151
+ return {
152
+ ...state,
153
+ selection: { projectId: context.project?.id, runId: next?.id },
154
+ };
155
+ }
156
+ function openAgentLog(state, context) {
157
+ const currentExecutionId = context.run?.executions?.at(-1)?.id ?? context.run?.id;
158
+ const stageSources = (context.run?.stageLogs ?? [])
159
+ .filter((item) => item.executionId === currentExecutionId)
160
+ .map((item) => ({
161
+ path: item.path,
162
+ label: `${item.step} · stage diagnostic`,
163
+ }));
164
+ const sources = [
165
+ ...stageSources,
166
+ ...context.logSessions.map((session) => ({
167
+ path: session.log,
168
+ label: `${session.step} · invocation ${session.attempt}`,
169
+ })),
170
+ ];
171
+ if (!sources.length)
172
+ return state;
173
+ return {
174
+ ...state,
175
+ modal: {
176
+ type: "logs",
177
+ sources,
178
+ initial: context.logSession
179
+ ? Math.max(0, stageSources.length +
180
+ context.logSessions.findIndex((session) => session.id === context.logSession?.id))
181
+ : 0,
182
+ },
183
+ };
184
+ }
185
+ function adjacent(items, index, delta) {
186
+ return items[Math.max(0, Math.min(items.length - 1, index + delta))];
187
+ }
@@ -1,233 +1,114 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useApp, useInput, useWindowSize } from "ink";
3
3
  import { useEffect, useState } from "react";
4
- import { actionAvailability } from "./actions.js";
5
4
  import { minimumTerminalSize } from "./constants.js";
6
5
  import { useMonitorData } from "./data.js";
7
6
  import { ConfirmDialog, HelpDialog } from "./dialogs.js";
8
7
  import { cells, colorFor, wrapLines } from "./format.js";
9
8
  import { monitorLayout } from "./layout.js";
10
9
  import { LogViewer } from "./log.js";
10
+ import { initialNavigation, transitionNavigation, } from "./monitor-navigation.js";
11
+ import { projectRunProjection } from "./projection.js";
11
12
  import { detailLines, Lines, RunList, summaryLines, wrapDetailLines, } from "./views.js";
12
13
  const notificationNoticeDurationMs = 2000;
13
- const focuses = ["runs", "summary", "sessions"];
14
14
  const statusRefreshIntervalMs = 1_000;
15
15
  const actionDescriptions = {
16
16
  stop: "Cancel this run and wait for its active local work to stop.",
17
17
  retry: "Create a new run and branch; execute the workflow again.",
18
+ "retry-refresh": "Create a new run using the current hosted issue and validation selection.",
18
19
  recover: "Continue the failed publication step using completed work.",
19
20
  };
20
- function currentExecutionSessions(run, sessions, events) {
21
- const current = run?.executions?.at(-1);
22
- if (!current || (run?.executions?.length ?? 0) <= 1)
23
- return sessions;
24
- const currentStepIds = new Set(events.flatMap((event) => {
25
- const payload = event.payload;
26
- return payload.executionId === current.id &&
27
- typeof payload.stepId === "number"
28
- ? [payload.stepId]
29
- : [];
30
- }));
31
- if (currentStepIds.size)
32
- return sessions.filter((item) => currentStepIds.has(item.stepId));
33
- const executionCreatedAt = Date.parse(current.createdAt);
34
- if (!Number.isFinite(executionCreatedAt))
35
- return [];
36
- return sessions.filter((item) => Date.parse(item.startedAt) >= executionCreatedAt);
37
- }
38
- function latestSession(sessions) {
39
- const byRecency = [...sessions].sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
40
- return (byRecency.filter((item) => item.outcome === "running").at(-1) ??
41
- byRecency.at(-1));
42
- }
43
21
  export function Monitor({ source, size, notificationWriter, }) {
44
22
  const window = useWindowSize();
45
23
  const { columns, rows } = size ?? window;
46
24
  const { exit } = useApp();
47
- const [selection, setSelection] = useState({});
25
+ const [navigation, setNavigation] = useState(initialNavigation);
26
+ const { selection, focus, screen, sessionId, stepSequence, offset, modal } = navigation;
27
+ const confirmation = modal?.type === "confirmation" ? modal : undefined;
28
+ const logs = modal?.type === "logs" ? modal : undefined;
48
29
  const data = useMonitorData(source, selection, notificationWriter);
49
30
  const { projects, project, projectRuns, run, sessions, events } = data;
50
- const [focus, setFocus] = useState("runs");
51
- const [screen, setScreen] = useState("dashboard");
52
- const [sessionId, setSessionId] = useState();
53
- const [stepSequence, setStepSequence] = useState();
54
- const [offset, setOffset] = useState(0);
55
- const [helpOpen, setHelpOpen] = useState(false);
56
- const [confirmation, setConfirmation] = useState();
57
- const [logs, setLogs] = useState();
31
+ const dismissModal = () => setNavigation((current) => transitionNavigation(current, { type: "dismiss" }).state);
58
32
  const [now, setNow] = useState(Date.now());
59
33
  const [notificationNoticeUntil] = useState(() => Date.now() + notificationNoticeDurationMs);
60
34
  const showNotificationNotice = Boolean(notificationWriter) && now < notificationNoticeUntil;
61
35
  const layout = monitorLayout(columns, rows - (showNotificationNotice ? 1 : 0), screen === "details");
62
36
  const { wide, height, paneWidth, summaryWidth } = layout;
63
37
  const session = sessions.find((item) => item.id === sessionId) ?? sessions[0];
64
- const executionSessions = currentExecutionSessions(run, sessions, events);
65
- const detailLogSession = latestSession(executionSessions);
38
+ const { executionSessions, detailLogSession, recoveryReason, available } = projectRunProjection({
39
+ run,
40
+ project,
41
+ projectRuns,
42
+ sessions,
43
+ events,
44
+ pending: Boolean(data.pending),
45
+ });
66
46
  const event = events.find((item) => item.sequence === stepSequence) ?? events.at(-1);
67
47
  useEffect(() => {
68
48
  const timer = setInterval(() => setNow(Date.now()), statusRefreshIntervalMs);
69
49
  return () => clearInterval(timer);
70
50
  }, []);
71
51
  useEffect(() => {
72
- // Pin default selections by identity before incoming rows can reorder them.
73
- setSelection((current) => current.projectId === project?.id && current.runId === run?.id
74
- ? current
75
- : { projectId: project?.id, runId: run?.id });
52
+ // Pin default selections and reset run-specific navigation in one transition.
53
+ setNavigation((current) => transitionNavigation(current, {
54
+ type: "selection",
55
+ selection: { projectId: project?.id, runId: run?.id },
56
+ }).state);
76
57
  }, [project?.id, run?.id]);
77
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset navigation when the selected run identity changes.
78
- useEffect(() => {
79
- setSessionId(undefined);
80
- setStepSequence(undefined);
81
- setOffset(0);
82
- setConfirmation(undefined);
83
- }, [run?.id]);
84
- const selectProject = (delta) => {
85
- const index = projects.findIndex((item) => item.id === project?.id);
86
- const next = projects[Math.max(0, Math.min(projects.length - 1, index + delta))];
87
- setSelection({ projectId: next?.id });
88
- setFocus("runs");
89
- setOffset(0);
90
- };
91
- const openAgentLog = (candidates = sessions, initialSession = session) => {
92
- if (!candidates.length)
93
- return;
94
- setLogs({
95
- sources: candidates.map((item) => ({
96
- path: item.log,
97
- label: `${item.step} · invocation ${item.attempt}`,
98
- })),
99
- initial: Math.max(0, candidates.findIndex((item) => item.id === initialSession?.id)),
100
- });
101
- };
102
- const { recoveryReason, available } = actionAvailability({
103
- run,
104
- project,
105
- projectRuns,
106
- pending: Boolean(data.pending),
107
- });
108
58
  const detailDocument = run
109
59
  ? detailLines(run, sessions, now, recoveryReason, layout.details.width, available)
110
60
  : ["Run no longer available"];
111
61
  const wrappedDetailLines = wrapDetailLines(detailDocument, layout.details.width);
112
62
  const detailOffset = Math.min(offset, Math.max(0, wrappedDetailLines.length - layout.details.height));
63
+ const terminalIsLargeEnough = columns >= minimumTerminalSize.columns && rows >= minimumTerminalSize.rows;
113
64
  useInput((input, key) => {
114
- if (key.ctrl || key.meta || key.eventType === "release")
115
- return;
116
- const terminalIsLargeEnough = columns >= minimumTerminalSize.columns &&
117
- rows >= minimumTerminalSize.rows;
118
- if (logs && terminalIsLargeEnough)
119
- return;
120
- if (terminalIsLargeEnough && (confirmation || helpOpen))
121
- return;
122
- if (input === "q") {
65
+ const viewport = screen === "details" ? layout.details : layout.summary;
66
+ const scrollLines = run
67
+ ? screen === "details"
68
+ ? wrappedDetailLines
69
+ : wrapLines(summaryLines(run, now, event), viewport.width)
70
+ : [];
71
+ const navigationEvent = {
72
+ type: "key",
73
+ input,
74
+ key,
75
+ context: {
76
+ terminalIsLargeEnough,
77
+ projects,
78
+ project,
79
+ runs: projectRuns,
80
+ run,
81
+ sessions,
82
+ session,
83
+ events,
84
+ event,
85
+ logSessions: screen === "details" ? executionSessions : sessions,
86
+ logSession: screen === "details" ? detailLogSession : session,
87
+ available,
88
+ pending: Boolean(data.pending),
89
+ scrollMaximum: Math.max(0, scrollLines.length - viewport.height),
90
+ pageSize: layout.details.height,
91
+ },
92
+ };
93
+ const { effect } = transitionNavigation(navigation, navigationEvent);
94
+ setNavigation((current) => transitionNavigation(current, navigationEvent).state);
95
+ if (effect?.type === "exit")
123
96
  exit();
124
- return;
125
- }
126
- if (!terminalIsLargeEnough)
127
- return;
128
- if (key.escape) {
129
- setScreen("dashboard");
130
- setFocus("runs");
131
- setOffset(0);
132
- return;
133
- }
134
- if (input === "?") {
135
- setHelpOpen(true);
136
- return;
137
- }
138
- if (key.tab && screen === "dashboard") {
139
- setFocus(focuses[(focuses.indexOf(focus) + (key.shift ? 2 : 1)) % 3]);
140
- setOffset(0);
141
- }
142
- if (input === "a" && screen === "dashboard") {
143
- setScreen("dashboard");
144
- setFocus("sessions");
145
- }
146
- if (key.leftArrow && screen === "dashboard")
147
- selectProject(-1);
148
- if (key.rightArrow && screen === "dashboard")
149
- selectProject(1);
150
- if (key.return && run && screen === "dashboard") {
151
- setScreen("details");
152
- setOffset(0);
153
- }
154
- if (input === "l")
155
- screen === "details"
156
- ? openAgentLog(executionSessions, detailLogSession)
157
- : openAgentLog();
158
- if (input === "v" && run?.validation?.length)
159
- setLogs({
160
- sources: run.validation.map((item) => ({
161
- path: item.log,
162
- label: `${item.command} · exit ${item.exitCode}`,
163
- })),
164
- initial: 0,
165
- });
166
- if (screen === "dashboard" && (input === "[" || input === "]")) {
167
- const index = events.findIndex((item) => item.sequence === event?.sequence);
168
- setStepSequence(events[Math.max(0, Math.min(events.length - 1, index + (input === "]" ? 1 : -1)))]?.sequence);
169
- }
170
- if (key.end && screen === "dashboard")
171
- setStepSequence(undefined);
172
- if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
173
- const delta = key.upArrow || key.pageUp ? -1 : 1;
174
- if (screen === "details" || focus === "summary") {
175
- const lines = run
176
- ? screen === "details"
177
- ? detailDocument
178
- : summaryLines(run, now, event)
179
- : [];
180
- const viewport = screen === "details" ? layout.details : layout.summary;
181
- setOffset((value) => {
182
- const maximum = Math.max(0, (screen === "details"
183
- ? wrapDetailLines(lines, viewport.width)
184
- : wrapLines(lines, viewport.width)).length - viewport.height);
185
- const current = screen === "details" ? Math.min(value, maximum) : value;
186
- return Math.max(0, Math.min(maximum, current +
187
- delta *
188
- (key.pageUp || key.pageDown ? layout.details.height : 1)));
189
- });
190
- }
191
- else if (focus === "sessions") {
192
- const index = sessions.findIndex((item) => item.id === session?.id);
193
- setSessionId(sessions[Math.max(0, Math.min(sessions.length - 1, index + delta))]
194
- ?.id);
195
- }
196
- else {
197
- const index = projectRuns.findIndex((item) => item.id === run?.id);
198
- setSelection({
199
- projectId: project?.id,
200
- runId: projectRuns[Math.max(0, Math.min(projectRuns.length - 1, index + delta))]?.id,
201
- });
202
- }
203
- }
204
- if (input === "p" && screen === "dashboard" && project && !data.pending)
205
- void data.action(project.paused ? "resume" : "pause", project.id);
206
- const kind = input === "s"
207
- ? "stop"
208
- : input === "r"
209
- ? "retry"
210
- : input === "c"
211
- ? "recover"
212
- : undefined;
213
- if (kind && run && available[kind])
214
- setConfirmation({
215
- kind,
216
- runId: run.id,
217
- title: `#${run.issue.number} ${run.issue.title}`,
218
- });
97
+ if (effect?.type === "command")
98
+ void data.action(effect.kind, effect.target);
219
99
  });
220
- if (columns < minimumTerminalSize.columns || rows < minimumTerminalSize.rows)
100
+ if (!terminalIsLargeEnough)
221
101
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Text, { children: cells(`Resize terminal to at least ${minimumTerminalSize.columns}×${minimumTerminalSize.rows}. q closes monitor.`, columns) }) }));
222
- if (helpOpen)
223
- return (_jsx(HelpDialog, { columns: columns, rows: rows, onClose: () => setHelpOpen(false) }));
102
+ if (modal?.type === "help")
103
+ return _jsx(HelpDialog, { columns: columns, rows: rows, onClose: dismissModal });
224
104
  if (confirmation)
225
- return (_jsx(ConfirmDialog, { columns: columns, rows: rows, title: `Confirm ${confirmation.kind}`, subject: confirmation.title, description: actionDescriptions[confirmation.kind], available: run?.id === confirmation.runId && available[confirmation.kind], onCancel: () => setConfirmation(undefined), onConfirm: () => {
105
+ return (_jsx(ConfirmDialog, { columns: columns, rows: rows, title: `Confirm ${confirmation.kind}`, subject: confirmation.title, description: actionDescriptions[confirmation.kind], available: run?.id === confirmation.runId &&
106
+ available[confirmation.kind === "retry-refresh" ? "retry" : confirmation.kind], onCancel: dismissModal, onConfirm: () => {
226
107
  void data.action(confirmation.kind, confirmation.runId);
227
- setConfirmation(undefined);
108
+ dismissModal();
228
109
  } }));
229
110
  if (logs)
230
- return (_jsx(LogViewer, { ...logs, columns: columns, rows: rows, onBack: () => setLogs(undefined) }));
111
+ return (_jsx(LogViewer, { ...logs, columns: columns, rows: rows, onBack: dismissModal }));
231
112
  const freshness = data.lastUpdated
232
113
  ? `refreshed ${Math.max(0, Math.floor((now - data.lastUpdated) / statusRefreshIntervalMs))}s ago`
233
114
  : "freshness unavailable";
@@ -242,9 +123,15 @@ export function Monitor({ source, size, notificationWriter, }) {
242
123
  ? columns < 120
243
124
  ? "l current log"
244
125
  : `l log: ${detailLogSession.step} invocation ${detailLogSession.attempt} (${detailLogSession.outcome})`
245
- : "";
126
+ : run?.stageLogs?.some((item) => item.executionId === (run.executions?.at(-1)?.id ?? run.id))
127
+ ? "l stage diagnostic"
128
+ : "";
246
129
  const controls = [
247
- screen === "details" ? detailsLogControl : sessions.length ? "l log" : "",
130
+ screen === "details"
131
+ ? detailsLogControl
132
+ : sessions.length || run?.stageLogs?.length
133
+ ? "l log"
134
+ : "",
248
135
  run?.validation?.length
249
136
  ? columns < 120
250
137
  ? "v checks"
@@ -0,0 +1,22 @@
1
+ import type { RunRecord } from "../domain.js";
2
+ import type { EventRecord, InvocationRecord, ProjectState } from "../store.js";
3
+ interface RunProjectionInput {
4
+ run?: RunRecord;
5
+ project?: ProjectState;
6
+ projectRuns: RunRecord[];
7
+ sessions?: InvocationRecord[];
8
+ events?: EventRecord[];
9
+ pending: boolean;
10
+ }
11
+ /** Operator facts derived from one selected Run; admission remains authoritative. */
12
+ export declare function projectRunProjection({ run, project, projectRuns, sessions, events, pending, }: RunProjectionInput): {
13
+ recoveryReason: string | undefined;
14
+ available: {
15
+ stop: boolean;
16
+ retry: boolean;
17
+ recover: boolean;
18
+ };
19
+ executionSessions: InvocationRecord[];
20
+ detailLogSession: InvocationRecord | undefined;
21
+ };
22
+ export {};
@@ -0,0 +1,49 @@
1
+ import { recoveryUnavailable } from "../recovery.js";
2
+ /** Operator facts derived from one selected Run; admission remains authoritative. */
3
+ export function projectRunProjection({ run, project, projectRuns, sessions = [], events = [], pending, }) {
4
+ const recoveryReason = run
5
+ ? (recoveryUnavailable(run) ??
6
+ project?.blocked ??
7
+ (projectRuns.some((item) => item.taskKey === run.taskKey && item.attempt > run.attempt)
8
+ ? "A newer attempt has superseded this run"
9
+ : undefined))
10
+ : undefined;
11
+ const executionSessions = currentExecutionSessions(run, sessions, events);
12
+ return {
13
+ recoveryReason,
14
+ available: {
15
+ stop: Boolean(run && !pending && ["queued", "running"].includes(run.outcome)),
16
+ retry: Boolean(run &&
17
+ !pending &&
18
+ ["failed", "blocked", "cancelled"].includes(run.outcome) &&
19
+ !projectRuns.some((item) => item.taskKey === run.taskKey &&
20
+ ["queued", "running"].includes(item.outcome))),
21
+ recover: Boolean(run && !pending && !recoveryReason),
22
+ },
23
+ executionSessions,
24
+ detailLogSession: latestSession(executionSessions),
25
+ };
26
+ }
27
+ function currentExecutionSessions(run, sessions, events) {
28
+ const current = run?.executions?.at(-1);
29
+ if (!current || (run?.executions?.length ?? 0) <= 1)
30
+ return sessions;
31
+ const currentStepIds = new Set(events.flatMap((event) => {
32
+ const payload = event.payload;
33
+ return payload.executionId === current.id &&
34
+ typeof payload.stepId === "number"
35
+ ? [payload.stepId]
36
+ : [];
37
+ }));
38
+ if (currentStepIds.size)
39
+ return sessions.filter((item) => currentStepIds.has(item.stepId));
40
+ const executionCreatedAt = Date.parse(current.createdAt);
41
+ if (!Number.isFinite(executionCreatedAt))
42
+ return [];
43
+ return sessions.filter((item) => Date.parse(item.startedAt) >= executionCreatedAt);
44
+ }
45
+ function latestSession(sessions) {
46
+ const byRecency = [...sessions].sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt));
47
+ return (byRecency.filter((item) => item.outcome === "running").at(-1) ??
48
+ byRecency.at(-1));
49
+ }
@@ -0,0 +1,6 @@
1
+ import type { Project } from "./config.js";
2
+ /** Resolve the one ticket-selected set against project-owned commands. */
3
+ export declare function selectValidation(body: string, project: Project): {
4
+ profile?: string;
5
+ commands: Project["validation"];
6
+ };