@workflow-manager/runner 0.6.0 → 0.7.0

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,305 @@
1
+ // RunObserver implementation for `wfm run --ui`: owns the mutable TUI state
2
+ // (latest snapshot, per-step logs, selection/follow, transient status
3
+ // messages), wires TerminalScreen input/output, and composes+paints frames
4
+ // via the pure renderFrame(). This is the only piece that talks timers,
5
+ // process streams, and the session approve/resume/cancel controls — layout
6
+ // math and terminal I/O stay in layout.ts / screen.ts.
7
+ import { isTerminalStatus } from "../runFormat.js";
8
+ import { renderFrame } from "./layout.js";
9
+ import { TuiLogStore } from "./logStore.js";
10
+ import { TerminalScreen } from "./screen.js";
11
+ import { createTheme } from "./theme.js";
12
+ import picocolors from "picocolors";
13
+ const DEFAULT_REDRAW_MS = 80;
14
+ const DEFAULT_TICK_MS = 1000;
15
+ const STATUS_MESSAGE_TTL_MS = 4000;
16
+ const STEP_TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
17
+ function readString(payload, key) {
18
+ const value = payload[key];
19
+ return typeof value === "string" ? value : undefined;
20
+ }
21
+ function buildEventMetaText(event) {
22
+ const payload = event.payload;
23
+ if (event.type === "agent.started") {
24
+ const command = readString(payload, "command") ?? "agent";
25
+ const args = Array.isArray(payload.args) ? payload.args.map((value) => String(value)).join(" ") : "";
26
+ const model = readString(payload, "model");
27
+ return `agent started: ${command}${args ? ` ${args}` : ""}${model ? ` model=${model}` : ""}`;
28
+ }
29
+ if (event.type === "agent.finished") {
30
+ const status = readString(payload, "executionStatus") ?? "unknown";
31
+ const exitStatus = typeof payload.exitStatus === "number" || payload.exitStatus === null ? ` exit=${String(payload.exitStatus)}` : "";
32
+ const timedOut = payload.timedOut === true ? " timedOut=true" : "";
33
+ return `agent finished: ${status}${exitStatus}${timedOut}`;
34
+ }
35
+ if (event.type === "step.execution_finished") {
36
+ const status = readString(payload, "status") ?? "unknown";
37
+ const action = readString(payload, "action");
38
+ const feedbackReason = readString(payload, "feedbackReason");
39
+ return `execution finished: ${status}${action ? ` action=${action}` : ""}${feedbackReason ? ` reason=${feedbackReason}` : ""}`;
40
+ }
41
+ if (event.type === "step.retried") {
42
+ const attempt = typeof payload.attempt === "number" ? payload.attempt : undefined;
43
+ return attempt !== undefined ? `retry scheduled (attempt ${attempt})` : "retry scheduled";
44
+ }
45
+ if (event.type === "approval.resolved") {
46
+ const decision = readString(payload, "decision") ?? "unknown";
47
+ const actor = readString(payload, "actor");
48
+ return `approval resolved: ${decision}${actor ? ` by ${actor}` : ""}`;
49
+ }
50
+ return null;
51
+ }
52
+ export class TuiRunRenderer {
53
+ workflow;
54
+ session;
55
+ attachUrl;
56
+ attachToken;
57
+ stdout;
58
+ stdin;
59
+ redrawMs;
60
+ tickMs;
61
+ nowFn;
62
+ theme;
63
+ screen;
64
+ snapshot = null;
65
+ stepDetails = new Map();
66
+ logs = new TuiLogStore();
67
+ selectedStepKey = null;
68
+ follow = true;
69
+ statusMessage = null;
70
+ statusMessageExpiresAt = null;
71
+ dirty = true;
72
+ redrawTimer = null;
73
+ tickTimer = null;
74
+ started = false;
75
+ stopped = false;
76
+ constructor(options) {
77
+ this.workflow = options.workflow;
78
+ this.session = options.session;
79
+ this.attachUrl = options.attachUrl;
80
+ this.attachToken = options.attachToken;
81
+ this.stdout = options.stdout ?? process.stdout;
82
+ this.stdin = options.stdin ?? process.stdin;
83
+ this.redrawMs = options.redrawMs ?? DEFAULT_REDRAW_MS;
84
+ this.tickMs = options.tickMs ?? DEFAULT_TICK_MS;
85
+ this.nowFn = options.now ?? Date.now;
86
+ this.theme = createTheme(options.colorEnabled ?? picocolors.isColorSupported);
87
+ this.screen = new TerminalScreen({
88
+ stdout: this.stdout,
89
+ stdin: this.stdin,
90
+ onKey: this.handleKey,
91
+ onResize: this.handleResize,
92
+ });
93
+ }
94
+ start() {
95
+ if (this.started) {
96
+ return;
97
+ }
98
+ this.started = true;
99
+ this.stopped = false;
100
+ this.screen.start();
101
+ if (this.redrawMs > 0) {
102
+ this.redrawTimer = setInterval(() => {
103
+ if (this.dirty) {
104
+ this.renderNow();
105
+ }
106
+ }, this.redrawMs);
107
+ }
108
+ if (this.tickMs > 0) {
109
+ this.tickTimer = setInterval(() => {
110
+ const status = this.snapshot?.status;
111
+ if (status === "running" || status === "waiting_for_approval") {
112
+ this.markDirty();
113
+ }
114
+ }, this.tickMs);
115
+ }
116
+ // Seed an initial (pending / snapshot-null) frame so the alt screen never
117
+ // shows a blank body while waiting on the first onSnapshot.
118
+ this.renderNow();
119
+ }
120
+ stop() {
121
+ if (this.stopped) {
122
+ return;
123
+ }
124
+ this.stopped = true;
125
+ this.started = false;
126
+ if (this.redrawTimer) {
127
+ clearInterval(this.redrawTimer);
128
+ this.redrawTimer = null;
129
+ }
130
+ if (this.tickTimer) {
131
+ clearInterval(this.tickTimer);
132
+ this.tickTimer = null;
133
+ }
134
+ this.screen.stop();
135
+ }
136
+ onSnapshot(snapshot, stepDetails) {
137
+ this.snapshot = snapshot;
138
+ this.stepDetails = new Map(stepDetails.map((detail) => [detail.stepKey, detail]));
139
+ if (this.follow) {
140
+ const firstNonTerminal = snapshot.steps.find((step) => !STEP_TERMINAL_STATUSES.has(step.status))?.stepKey;
141
+ this.selectedStepKey = snapshot.currentStepKey ?? firstNonTerminal ?? this.selectedStepKey;
142
+ }
143
+ if (isTerminalStatus(snapshot.status)) {
144
+ this.logs.flushPartialLines();
145
+ }
146
+ this.markDirty();
147
+ }
148
+ onEvent(event) {
149
+ const text = buildEventMetaText(event);
150
+ if (text === null) {
151
+ return;
152
+ }
153
+ this.logs.appendMeta(event.stepRunId, text);
154
+ this.markDirty();
155
+ }
156
+ onLog(log) {
157
+ this.logs.appendChunk(log);
158
+ this.markDirty();
159
+ }
160
+ handleKey = (key) => {
161
+ const name = key.name;
162
+ if (name === "up" || name === "k") {
163
+ this.follow = false;
164
+ this.moveSelection(-1);
165
+ this.markDirty();
166
+ return;
167
+ }
168
+ if (name === "down" || name === "j") {
169
+ this.follow = false;
170
+ this.moveSelection(1);
171
+ this.markDirty();
172
+ return;
173
+ }
174
+ if (name === "f") {
175
+ this.follow = true;
176
+ this.selectedStepKey = this.snapshot?.currentStepKey ?? this.selectedStepKey;
177
+ this.markDirty();
178
+ return;
179
+ }
180
+ if (name === "q" || (key.ctrl && name === "c")) {
181
+ this.handleQuit();
182
+ return;
183
+ }
184
+ if (name === "a") {
185
+ this.handleApprove();
186
+ return;
187
+ }
188
+ if (name === "r") {
189
+ this.handleResume();
190
+ return;
191
+ }
192
+ if (name === "c") {
193
+ this.handleCancel();
194
+ return;
195
+ }
196
+ this.markDirty();
197
+ };
198
+ renderNow() {
199
+ const rows = renderFrame(this.composeFrame());
200
+ this.screen.paint(rows);
201
+ this.dirty = false;
202
+ return rows;
203
+ }
204
+ handleResize = () => {
205
+ this.markDirty();
206
+ };
207
+ handleApprove() {
208
+ const waiting = this.snapshot?.waitingForApproval;
209
+ if (!waiting) {
210
+ this.markDirty();
211
+ return;
212
+ }
213
+ if (waiting.validation === "external") {
214
+ this.setStatusMessage("external step — press r to resume");
215
+ this.markDirty();
216
+ return;
217
+ }
218
+ const result = this.session.approve(waiting.stepKey, { actor: "cli", source: "tui" });
219
+ if (!result.ok) {
220
+ this.setStatusMessage(`approve failed: ${result.reason ?? "unknown error"}`);
221
+ }
222
+ this.markDirty();
223
+ }
224
+ handleResume() {
225
+ const waiting = this.snapshot?.waitingForApproval;
226
+ if (!waiting) {
227
+ this.markDirty();
228
+ return;
229
+ }
230
+ const result = this.session.resume(waiting.stepKey, { actor: "cli", source: "tui" });
231
+ if (!result.ok) {
232
+ this.setStatusMessage(`resume failed: ${result.reason ?? "unknown error"}`);
233
+ }
234
+ this.markDirty();
235
+ }
236
+ handleCancel() {
237
+ const waiting = this.snapshot?.waitingForApproval;
238
+ if (!waiting) {
239
+ this.setStatusMessage("press q to quit; c cancels only a pending approval");
240
+ this.markDirty();
241
+ return;
242
+ }
243
+ const result = this.session.cancel(waiting.stepKey, { actor: "cli", source: "tui" });
244
+ if (!result.ok) {
245
+ this.setStatusMessage(`cancel failed: ${result.reason ?? "unknown error"}`);
246
+ }
247
+ this.markDirty();
248
+ }
249
+ handleQuit() {
250
+ const status = this.snapshot?.status;
251
+ if (status && isTerminalStatus(status)) {
252
+ // Run already finished; cmdRun owns process exit from here.
253
+ this.markDirty();
254
+ return;
255
+ }
256
+ this.session.cancel(undefined, { actor: "cli", source: "tui", note: "cancelled from TUI" });
257
+ this.setStatusMessage("cancelling…");
258
+ this.markDirty();
259
+ }
260
+ moveSelection(delta) {
261
+ const steps = this.snapshot?.steps ?? [];
262
+ if (steps.length === 0) {
263
+ return;
264
+ }
265
+ const currentIndex = this.selectedStepKey ? steps.findIndex((step) => step.stepKey === this.selectedStepKey) : -1;
266
+ const baseIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
267
+ const nextIndex = Math.min(Math.max(baseIndex + delta, 0), steps.length - 1);
268
+ this.selectedStepKey = steps[nextIndex].stepKey;
269
+ }
270
+ setStatusMessage(message) {
271
+ this.statusMessage = message;
272
+ this.statusMessageExpiresAt = this.nowFn() + STATUS_MESSAGE_TTL_MS;
273
+ }
274
+ currentStatusMessage(now) {
275
+ if (this.statusMessage && this.statusMessageExpiresAt !== null && now >= this.statusMessageExpiresAt) {
276
+ this.statusMessage = null;
277
+ this.statusMessageExpiresAt = null;
278
+ }
279
+ return this.statusMessage;
280
+ }
281
+ markDirty() {
282
+ this.dirty = true;
283
+ if (this.redrawMs === 0) {
284
+ this.renderNow();
285
+ }
286
+ }
287
+ composeFrame() {
288
+ const { columns, rows } = this.screen.size();
289
+ const now = this.nowFn();
290
+ return {
291
+ snapshot: this.snapshot,
292
+ stepDetails: this.stepDetails,
293
+ logs: this.logs,
294
+ selectedStepKey: this.selectedStepKey,
295
+ follow: this.follow,
296
+ attachUrl: this.attachUrl,
297
+ attachToken: this.attachToken,
298
+ statusMessage: this.currentStatusMessage(now),
299
+ width: columns,
300
+ height: rows,
301
+ now,
302
+ theme: this.theme,
303
+ };
304
+ }
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workflow-manager/runner",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes",
5
5
  "repository": {
6
6
  "type": "git",
@@ -73,7 +73,8 @@
73
73
  "license": "MIT",
74
74
  "dependencies": {
75
75
  "@zed-industries/agent-client-protocol": "^0.4.5",
76
- "gray-matter": "^4.0.3"
76
+ "gray-matter": "^4.0.3",
77
+ "picocolors": "^1.1.1"
77
78
  },
78
79
  "devDependencies": {
79
80
  "@biomejs/biome": "^2.4.14",