@krapht/ork 1.0.0-alpha.2 → 1.0.0-alpha.21
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/README.md +12 -7
- package/dist/__build_info__-0wqQ-rtf.mjs +180 -0
- package/dist/apply-Mb0fNpCX.mjs +17 -0
- package/dist/argument-error-CihH46Le.mjs +27 -0
- package/dist/attach-C0-YyNoY.mjs +92 -0
- package/dist/attach-CAKNqnUK.mjs +1259 -0
- package/dist/bin.mjs +1885 -1609
- package/dist/brief-C2hew8cS.mjs +116 -0
- package/dist/{decide-CGyx9rv7.mjs → builtin-files-YYAnbZGD.mjs} +3 -51
- package/dist/chips-CP_pgKlj.mjs +48 -0
- package/dist/command-tree-CEkzWNy6.mjs +63 -0
- package/dist/{completion-CSU5vkEt.mjs → completion-K4rAVsrf.mjs} +200 -30
- package/dist/context-B-fjPJky.mjs +106 -0
- package/dist/context-DMK0U1w0.mjs +160 -0
- package/dist/detached-q0AYG0Bs.mjs +39 -0
- package/dist/extra-arguments-BxQMXARO.mjs +13 -0
- package/dist/{find-D6Gj1sxg.mjs → find-Ct5CNl1J.mjs} +2 -2
- package/dist/flags-C90N4J1i.mjs +145 -0
- package/dist/follow-ups-D3HPyNYM.mjs +568 -0
- package/dist/format-CiPhQqbX.mjs +260 -0
- package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BQvcKQ9H.mjs} +49 -78
- package/dist/in-a-run-D_wg4iJi.mjs +27 -0
- package/dist/install-Hji6LIei.mjs +259 -0
- package/dist/{limits-B1-DYc3d.mjs → limits-BD6RZRCS.mjs} +2 -2
- package/dist/{mcp-DE_EmyVm.mjs → mcp-CpbDmBLG.mjs} +6 -6
- package/dist/once-J8Qn5fTG.mjs +112 -0
- package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
- package/dist/output-DXZbnFt_.mjs +458 -0
- package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
- package/dist/{pause-CL6N_Xrj.mjs → pause-Cl4ZYA35.mjs} +7 -4
- package/dist/print-DzugGZQB.mjs +19 -0
- package/dist/project-records-CtkmlsHN.mjs +233 -0
- package/dist/refusals-DQQX3AIb.mjs +116 -0
- package/dist/{relay-0SD0erwf.mjs → relay-CiP77vga.mjs} +2 -2
- package/dist/run-CtuNY3mE.mjs +234 -0
- package/dist/send-DRYGks8K.mjs +104 -0
- package/dist/short-flags-DkJLWVqh.mjs +1 -0
- package/dist/short-flags-Qf21as0E.mjs +83 -0
- package/dist/src-BMFQKHz4.mjs +3122 -0
- package/dist/{src-BbyEoMn4.mjs → src-BrFfDKux.mjs} +800 -235
- package/dist/{src-Dujmvv4s.mjs → src-BuF9Lbow.mjs} +12580 -5505
- package/dist/{src-DOcK8Jfp.mjs → src-k0A_QpST.mjs} +1024 -590
- package/dist/store-rTgRu8xy.mjs +25 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
- package/dist/ui/assets/app-BYxpKJRh.css +1 -0
- package/dist/ui/assets/app-SEFEJLkF.js +112 -0
- package/dist/ui/assets/{index-9dFha9Qn.js → index-Cb6Voq_W.js} +3 -3
- package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{ui-CTmO2Xzu.mjs → ui-C2f37TSb.mjs} +57 -25
- package/dist/usage-BuvGcQSk.mjs +256 -0
- package/dist/validate-ObP5lt29.mjs +1 -0
- package/dist/validate-_HjACtk9.mjs +632 -0
- package/dist/verdict-B592j1W3.mjs +84 -0
- package/dist/{watch-CtbgDAcz.mjs → watch-DV3ixCYj.mjs} +45 -17
- package/dist/{workflows-D1_WVukk.mjs → workflows-QLjBhGNS.mjs} +30 -5
- package/dist/worktrees-D3ubG_Qw.mjs +302 -0
- package/dist/yolo-BSH1E_kE.mjs +33 -0
- package/package.json +10 -8
- package/dist/__build_info__-fy6L1Mrd.mjs +0 -615
- package/dist/clean-DaHjs__2.mjs +0 -88
- package/dist/context-DT8MfEIR.mjs +0 -274
- package/dist/format-Dnfv8zMT.mjs +0 -427
- package/dist/in-a-run-pLAZeNys.mjs +0 -22
- package/dist/list-BrH8KRSJ.mjs +0 -110
- package/dist/output-BLQWD6MJ.mjs +0 -950
- package/dist/remove-BeGdqroy.mjs +0 -194
- package/dist/report-DK4LGqwF.mjs +0 -129
- package/dist/send-C8tunWhv.mjs +0 -163
- package/dist/src-CCUKRX51.mjs +0 -1204
- package/dist/ui/assets/app-BNT0SuIz.css +0 -1
- package/dist/ui/assets/app-T7wVAhgx.js +0 -67
- package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
- package/dist/usage-DYUmP0i4.mjs +0 -419
- package/dist/worktrees-DdMYoNOC.mjs +0 -212
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
import { Ar as maskReplacer, L as isNodeEvent, Nt as lockWaitLine, Or as TOOL_INPUT_KEYS, Pn as liveStatus, at as followRunTree, ft as DETACHED_ENV, ir as outcomeEnd, jr as maskSecrets, mt as followEvents, or as END_KINDS, ot as answerDecision, rr as endOf, st as pendingDecisions, zt as slotWaitLine } from "./src-BuF9Lbow.mjs";
|
|
2
|
+
import { t as applyFilters } from "./apply-Mb0fNpCX.mjs";
|
|
3
|
+
import { t as outputsText } from "./outputs-text-CNTJ7Qq_.mjs";
|
|
4
|
+
import { C as readKeys, D as apply, O as bold, S as wrap, a as plain, d as createRegion, f as interrupt, g as plainEnv, h as canKey, k as paint, m as withdrawn, n as cost, p as readLine, r as costOf, s as style, u as confirmOrStop, v as termOf, w as barred, x as widthOf, y as cut } from "./output-DXZbnFt_.mjs";
|
|
5
|
+
import { i as nodeChip, n as askChip, r as chip, t as CHIP_LABELS } from "./chips-CP_pgKlj.mjs";
|
|
6
|
+
import { a as createView, i as formatBusEvent, n as nodeVerdict, o as styler, r as firstLine, t as hasConflict } from "./verdict-B592j1W3.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
// src/human-card.ts
|
|
10
|
+
/** Lines of context a card shows before `d` (or `--verbose`) shows the rest. */
|
|
11
|
+
const CONTEXT_LINES = 6;
|
|
12
|
+
/** Text from outside with its escapes gone and its line breaks kept. */
|
|
13
|
+
const clean = (text) => text.split("\n").map((line) => plain(line)).join("\n");
|
|
14
|
+
/** The card above the options, in the rich look or the plain lines. */
|
|
15
|
+
const cardLines = (t, q, place, verbose, document) => {
|
|
16
|
+
const context = q.context === void 0 ? void 0 : clean(q.context);
|
|
17
|
+
if (!t.rich) return {
|
|
18
|
+
lines: [
|
|
19
|
+
...place === void 0 ? [] : [`${[
|
|
20
|
+
`run ${place.run}`,
|
|
21
|
+
place.workflow,
|
|
22
|
+
place.path
|
|
23
|
+
].filter((part) => part !== void 0).join(" · ")} · decision ${place.decision}`],
|
|
24
|
+
...document === void 0 ? [] : [document],
|
|
25
|
+
`? ${plain(q.nodeId)} asks: ${clean(q.question)}`,
|
|
26
|
+
...context === void 0 ? [] : [` ${context.replaceAll("\n", "\n ")}`]
|
|
27
|
+
],
|
|
28
|
+
moreContext: false
|
|
29
|
+
};
|
|
30
|
+
const shown = context === void 0 ? [] : wrap(context, t.width - 3);
|
|
31
|
+
const cutShort = !verbose && shown.length > CONTEXT_LINES;
|
|
32
|
+
const body = cutShort ? shown.slice(0, CONTEXT_LINES) : shown;
|
|
33
|
+
const where = place === void 0 ? "" : ` ${paint(t, "muted", [
|
|
34
|
+
`run ${place.run}`,
|
|
35
|
+
place.workflow,
|
|
36
|
+
place.path,
|
|
37
|
+
place.index === void 0 || place.total === void 0 ? void 0 : `${String(place.index)} of ${String(place.total)}`
|
|
38
|
+
].filter((part) => part !== void 0).join(" · "))}`;
|
|
39
|
+
return {
|
|
40
|
+
lines: [
|
|
41
|
+
"",
|
|
42
|
+
` ${askChip(t)} ${nodeChip(t, plain(q.nodeId))}${where}`,
|
|
43
|
+
...wrap(clean(q.question), t.width - 1).map((l) => ` ${bold(t, l)}`),
|
|
44
|
+
...body.map((l) => paint(t, "muted", ` ${l}`)),
|
|
45
|
+
...cutShort ? [paint(t, "muted", ` … ${String(shown.length - CONTEXT_LINES)} more lines (d)`)] : []
|
|
46
|
+
],
|
|
47
|
+
moreContext: cutShort
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/** The line above a permission prompt: who wants to run what. */
|
|
51
|
+
const permitLine = (t, nodeId, preview) => {
|
|
52
|
+
const node = plain(nodeId);
|
|
53
|
+
const what = plain(preview);
|
|
54
|
+
if (!t.rich) return `? ${node} wants to run ${what}`;
|
|
55
|
+
const room = Math.max(20, t.width - node.length - 24);
|
|
56
|
+
return `${apply(t, "greenBright", "▌")}${askChip(t)} ${nodeChip(t, node)} wants to run ${cut(what, room)}`;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The line a card collapses to once answered: `✓ 509da722-1 · add-invalid-node`
|
|
60
|
+
* (the decision, or the node when there is none, and the choice), or a note
|
|
61
|
+
* that it was left for later.
|
|
62
|
+
*/
|
|
63
|
+
const doneLine = (t, place, nodeId, answer) => {
|
|
64
|
+
const who = place?.decision ?? plain(nodeId);
|
|
65
|
+
if (answer.kind === "later") return ` ${paint(t, "muted", "·")} ${cut(`${who} left for later`, t.width - 3)}`;
|
|
66
|
+
const said = answer.note === void 0 ? "" : ` · said: ${plain(answer.note)}`;
|
|
67
|
+
return ` ${paint(t, "ok", "✓")} ${cut(`${who} · ${answer.id}${said}`, t.width - 3)}`;
|
|
68
|
+
};
|
|
69
|
+
// src/term/option-name.ts
|
|
70
|
+
/**
|
|
71
|
+
* @file The select prompt's options: how one is named (its label, then its id
|
|
72
|
+
* dimmed in parentheses, `Allow (allow)`, or its id alone), the numbered
|
|
73
|
+
* lines of the plain prompt (`1) label (id) - description`), and which option
|
|
74
|
+
* a typed answer names.
|
|
75
|
+
*/
|
|
76
|
+
const asIs = (text) => text;
|
|
77
|
+
/**
|
|
78
|
+
* An option's name in at most `width` columns: its label, then its id (what
|
|
79
|
+
* `ork decide --choose` and a plain prompt take) dimmed in parentheses; the id
|
|
80
|
+
* alone when it has no label.
|
|
81
|
+
*/
|
|
82
|
+
const nameOf = (t, option, width, look = asIs) => {
|
|
83
|
+
if (width < 1) return "";
|
|
84
|
+
if (option.label === void 0) return look(cut(option.id, width));
|
|
85
|
+
const head = cut(option.label, width);
|
|
86
|
+
const room = width - widthOf(head);
|
|
87
|
+
return room <= 1 ? look(head) : `${look(head)}${paint(t, "muted", cut(` (${option.id})`, room))}`;
|
|
88
|
+
};
|
|
89
|
+
/** The numbered option lines of a plain prompt: ` 1) label (id) - description`. */
|
|
90
|
+
const plainOptions = (options, recommended) => options.map((option, i) => {
|
|
91
|
+
const description = option.description === void 0 ? "" : ` - ${option.description}`;
|
|
92
|
+
const mark = option.id === recommended ? " (recommended)" : "";
|
|
93
|
+
const name = option.label === void 0 ? option.id : `${option.label} (${option.id})`;
|
|
94
|
+
return ` ${String(i + 1)}) ${name}${description}${mark}`;
|
|
95
|
+
});
|
|
96
|
+
/** The option an answer names: its number, or its id; empty is `recommended`. */
|
|
97
|
+
const pickOption = (answer, options, recommended) => {
|
|
98
|
+
const value = answer.trim();
|
|
99
|
+
if (value === "") return recommended;
|
|
100
|
+
const index = Number(value);
|
|
101
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1]?.id;
|
|
102
|
+
return options.find((o) => o.id === value)?.id;
|
|
103
|
+
};
|
|
104
|
+
// src/term/text.ts
|
|
105
|
+
/**
|
|
106
|
+
* @file The text prompt: one line of text, edited in place on a terminal
|
|
107
|
+
* (arrows, Home/End, Backspace, Delete, Ctrl-U to clear), collapsing to one
|
|
108
|
+
* line when answered; Enter sends, Esc (or Ctrl-D on an empty line) leaves
|
|
109
|
+
* it. Plain mode asks `message: ` and reads a line, as the prompts always
|
|
110
|
+
* did. `editLine` is the editing itself, which the select prompt reuses for
|
|
111
|
+
* its note.
|
|
112
|
+
*/
|
|
113
|
+
const at = (next, to) => ({
|
|
114
|
+
value: next.join(""),
|
|
115
|
+
pos: to
|
|
116
|
+
});
|
|
117
|
+
/** `state` after `key`; keys that do not edit leave it as it is. */
|
|
118
|
+
const editLine = (state, key) => {
|
|
119
|
+
const { value, pos } = state;
|
|
120
|
+
const chars = Array.from(value);
|
|
121
|
+
if (key.name === "char") return at(chars.toSpliced(pos, 0, key.text), pos + 1);
|
|
122
|
+
if (key.name === "backspace") return pos === 0 ? state : at(chars.toSpliced(pos - 1, 1), pos - 1);
|
|
123
|
+
if (key.name === "delete") return at(chars.toSpliced(pos, 1), pos);
|
|
124
|
+
if (key.name === "left") return {
|
|
125
|
+
value,
|
|
126
|
+
pos: Math.max(0, pos - 1)
|
|
127
|
+
};
|
|
128
|
+
if (key.name === "right") return {
|
|
129
|
+
value,
|
|
130
|
+
pos: Math.min(chars.length, pos + 1)
|
|
131
|
+
};
|
|
132
|
+
if (key.name === "home" || key.name === "ctrl-a") return {
|
|
133
|
+
value,
|
|
134
|
+
pos: 0
|
|
135
|
+
};
|
|
136
|
+
if (key.name === "end" || key.name === "ctrl-e") return {
|
|
137
|
+
value,
|
|
138
|
+
pos: chars.length
|
|
139
|
+
};
|
|
140
|
+
if (key.name === "ctrl-u") return at(chars.slice(pos), 0);
|
|
141
|
+
return state;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* The line to draw for `state` after `lead`, within `width`, and the column of
|
|
145
|
+
* the cursor in it. A value too long for the room scrolls to keep the cursor in
|
|
146
|
+
* view.
|
|
147
|
+
*/
|
|
148
|
+
const showLine = (lead, state, width, leadWidth) => {
|
|
149
|
+
const chars = Array.from(state.value);
|
|
150
|
+
const room = Math.max(1, width - leadWidth - 1);
|
|
151
|
+
const start = Math.max(0, state.pos - room + 1);
|
|
152
|
+
return {
|
|
153
|
+
line: `${lead}${chars.slice(start, start + room).join("")}`,
|
|
154
|
+
column: leadWidth + state.pos - start
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
const defaultDone$1 = (io, message) => (value) => {
|
|
158
|
+
const term = termOf(io.output, io.env);
|
|
159
|
+
const mark = paint(term, "ok", "✓");
|
|
160
|
+
return cut(` ${mark} ${message} · ${value === void 0 || value === "" ? "skipped" : value}`, term.width);
|
|
161
|
+
};
|
|
162
|
+
/** Asks for one line of text. */
|
|
163
|
+
const readText = async (io, options) => {
|
|
164
|
+
const { message, signal } = options;
|
|
165
|
+
if (signal?.aborted === true) throw withdrawn(signal);
|
|
166
|
+
if (!canKey(io.input, io.output, io.env)) {
|
|
167
|
+
const answer = await readLine(io, ` ${message}: `, signal);
|
|
168
|
+
return answer === void 0 ? { kind: "cancelled" } : {
|
|
169
|
+
kind: "text",
|
|
170
|
+
value: answer.trim()
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const done = options.done ?? defaultDone$1(io, message);
|
|
174
|
+
const term = () => termOf(io.output, io.env);
|
|
175
|
+
const region = createRegion(io.output, term);
|
|
176
|
+
const initial = options.initial ?? "";
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
let state = {
|
|
179
|
+
value: initial,
|
|
180
|
+
pos: Array.from(initial).length
|
|
181
|
+
};
|
|
182
|
+
const lead = ` ${paint(term(), "accent", "?")} ${bold(term(), message)} › `;
|
|
183
|
+
const leadWidth = Array.from(` ? ${message} › `).length;
|
|
184
|
+
const render = () => {
|
|
185
|
+
const { line, column } = showLine(lead, state, term().width, leadWidth);
|
|
186
|
+
region.draw([line], column);
|
|
187
|
+
};
|
|
188
|
+
const finish = (result, line) => {
|
|
189
|
+
stop();
|
|
190
|
+
io.output.off("resize", render);
|
|
191
|
+
signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
region.finish(line);
|
|
193
|
+
resolve(result);
|
|
194
|
+
};
|
|
195
|
+
const onAbort = () => {
|
|
196
|
+
stop();
|
|
197
|
+
io.output.off("resize", render);
|
|
198
|
+
region.finish();
|
|
199
|
+
reject(withdrawn(signal));
|
|
200
|
+
};
|
|
201
|
+
const stop = readKeys(io.input, (key) => {
|
|
202
|
+
if (key.name === "enter") {
|
|
203
|
+
const value = state.value.trim();
|
|
204
|
+
finish({
|
|
205
|
+
kind: "text",
|
|
206
|
+
value
|
|
207
|
+
}, done(value));
|
|
208
|
+
} else if (key.name === "escape" || key.name === "ctrl-d" && state.value === "") finish({ kind: "cancelled" }, done());
|
|
209
|
+
else if (key.name === "ctrl-c") {
|
|
210
|
+
finish({ kind: "interrupted" });
|
|
211
|
+
interrupt(io);
|
|
212
|
+
} else {
|
|
213
|
+
state = editLine(state, key);
|
|
214
|
+
render();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
io.output.on("resize", render);
|
|
218
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
render();
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
// src/term/select.ts
|
|
223
|
+
/**
|
|
224
|
+
* @file The select prompt: a list of options moved with ↑/↓ (or `j`/`k`),
|
|
225
|
+
* Home/End and `1`–`9`, chosen with Enter. `n` adds a note (one line, edited
|
|
226
|
+
* in place), `d` shows or hides the details (a document), `q` or Esc leaves
|
|
227
|
+
* the question for later, Ctrl-C stops. It redraws only its own lines,
|
|
228
|
+
* follows a resize, and collapses to one line once answered. Plain mode
|
|
229
|
+
* prints the options as `1) label (id) - description` and asks `Choose [n]:
|
|
230
|
+
* `, as `ork decide` always did; there a note is asked for after the choice
|
|
231
|
+
* when the prompt takes one, and `q` or an ended input leaves the question
|
|
232
|
+
* for later where it may wait.
|
|
233
|
+
*/
|
|
234
|
+
const NOTE_PROMPT = "Note for the run (optional, Enter to skip)";
|
|
235
|
+
const defaultDone = (options) => (result, term) => {
|
|
236
|
+
if (result.kind === "later") return ` ${paint(term, "muted", "·")} left for later`;
|
|
237
|
+
const option = options.find((o) => o.id === result.id);
|
|
238
|
+
const name = option === void 0 ? cut(result.id, term.width - 3) : nameOf(term, option, term.width - 3);
|
|
239
|
+
return ` ${paint(term, "ok", "✓")} ${name}`;
|
|
240
|
+
};
|
|
241
|
+
/** The plain prompt: numbered options, then `Choose [n]: ` until one fits. */
|
|
242
|
+
const selectPlain = (io, o) => {
|
|
243
|
+
const term = termOf(io.output, io.env);
|
|
244
|
+
const say = (text) => {
|
|
245
|
+
io.output.write(`${text}\n`);
|
|
246
|
+
};
|
|
247
|
+
for (const line of o.header?.(term) ?? []) say(line);
|
|
248
|
+
for (const line of plainOptions(o.options, o.recommended)) say(line);
|
|
249
|
+
for (const line of o.footer ?? []) say(line);
|
|
250
|
+
const at = o.options.findIndex((opt) => opt.id === o.recommended);
|
|
251
|
+
const hint = at === -1 ? "" : ` [${String(at + 1)}]`;
|
|
252
|
+
const ask = async () => {
|
|
253
|
+
const answer = await readLine(io, ` Choose${hint}: `, o.signal);
|
|
254
|
+
if (answer === void 0) {
|
|
255
|
+
if (o.later === true) return { kind: "later" };
|
|
256
|
+
throw new Error("input ended");
|
|
257
|
+
}
|
|
258
|
+
const id = pickOption(answer, o.options, o.recommended);
|
|
259
|
+
if (id === void 0 && o.later === true && answer.trim() === "q") return { kind: "later" };
|
|
260
|
+
if (id === void 0) {
|
|
261
|
+
if (o.details !== void 0 && answer.trim() === "d") say(o.details);
|
|
262
|
+
else say(" Type an option's number or id.");
|
|
263
|
+
return ask();
|
|
264
|
+
}
|
|
265
|
+
if (o.note !== true) return {
|
|
266
|
+
kind: "chosen",
|
|
267
|
+
id
|
|
268
|
+
};
|
|
269
|
+
const noted = await readText(io, {
|
|
270
|
+
message: o.notePrompt ?? NOTE_PROMPT,
|
|
271
|
+
...o.signal === void 0 ? {} : { signal: o.signal }
|
|
272
|
+
});
|
|
273
|
+
return noted.kind === "text" && noted.value !== "" ? {
|
|
274
|
+
kind: "chosen",
|
|
275
|
+
id,
|
|
276
|
+
note: noted.value
|
|
277
|
+
} : {
|
|
278
|
+
kind: "chosen",
|
|
279
|
+
id
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
return ask();
|
|
283
|
+
};
|
|
284
|
+
/** Asks for one of `options`. */
|
|
285
|
+
const select = (io, o) => {
|
|
286
|
+
if (o.signal?.aborted === true) return Promise.reject(withdrawn(o.signal));
|
|
287
|
+
if (!canKey(io.input, io.output, io.env)) return selectPlain(io, o);
|
|
288
|
+
const term = () => termOf(io.output, io.env);
|
|
289
|
+
const region = createRegion(io.output, term);
|
|
290
|
+
const done = o.done ?? defaultDone(o.options);
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const start = o.options.findIndex((opt) => opt.id === o.recommended);
|
|
293
|
+
let cursor = Math.max(0, start);
|
|
294
|
+
let showDetails = false;
|
|
295
|
+
let note;
|
|
296
|
+
let editing;
|
|
297
|
+
const optionLines = (t) => o.options.flatMap((option, i) => {
|
|
298
|
+
const selected = i === cursor;
|
|
299
|
+
const number = i < 9 ? `${String(i + 1)}.` : " ";
|
|
300
|
+
const mark = option.id === o.recommended ? " (recommended)" : "";
|
|
301
|
+
const look = selected ? (text) => apply(t, ["bold", "cyan"], text) : asIs;
|
|
302
|
+
const room = t.width - 4 - mark.length;
|
|
303
|
+
const prefix = cut(`${number} `, room);
|
|
304
|
+
const text = `${look(prefix)}${nameOf(t, option, room - widthOf(prefix), look)}`;
|
|
305
|
+
const lines = [` ${selected ? paint(t, "accent", "❯") : " "} ${text}${mark === "" ? "" : paint(t, "ok", mark)}`];
|
|
306
|
+
for (const line of wrap(option.description ?? "", t.width - 7)) if (line !== "") lines.push(paint(t, "muted", ` ${line}`));
|
|
307
|
+
return lines;
|
|
308
|
+
});
|
|
309
|
+
const keyLine = (t) => {
|
|
310
|
+
const keys = editing === void 0 ? [
|
|
311
|
+
"↑/↓ move",
|
|
312
|
+
"Enter choose",
|
|
313
|
+
...o.note === true ? ["n note"] : [],
|
|
314
|
+
...o.details === void 0 ? [] : [`d ${showDetails ? "hide" : "show"} details`],
|
|
315
|
+
...o.later === true ? ["q later"] : []
|
|
316
|
+
] : ["Enter save", "Esc cancel"];
|
|
317
|
+
return paint(t, "muted", cut(` ${keys.join(" · ")}`, t.width));
|
|
318
|
+
};
|
|
319
|
+
const render = () => {
|
|
320
|
+
const t = term();
|
|
321
|
+
const head = o.header?.(t) ?? [];
|
|
322
|
+
const body = optionLines(t);
|
|
323
|
+
const noteLine = note === void 0 ? [] : [paint(t, "muted", cut(` note: ${note}`, t.width))];
|
|
324
|
+
const above = [
|
|
325
|
+
...head,
|
|
326
|
+
...body,
|
|
327
|
+
...noteLine
|
|
328
|
+
];
|
|
329
|
+
const room = Math.max(3, t.rows - above.length - 3);
|
|
330
|
+
const all = showDetails && o.details !== void 0 ? wrap(o.details.trimEnd(), t.width - 3) : [];
|
|
331
|
+
const detail = (all.length > room ? [...all.slice(0, room - 1), `… ${String(all.length - room + 1)} more lines`] : all).map((line) => paint(t, "muted", ` ${line}`));
|
|
332
|
+
const lines = [
|
|
333
|
+
...above,
|
|
334
|
+
...detail,
|
|
335
|
+
keyLine(t)
|
|
336
|
+
];
|
|
337
|
+
if (editing === void 0) {
|
|
338
|
+
region.draw(barred(t, lines));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const lead = ` ${paint(t, "accent", "✎")} Note › `;
|
|
342
|
+
const { line, column } = showLine(lead, editing, t.width, 10);
|
|
343
|
+
region.draw(barred(t, [...lines, line]), column);
|
|
344
|
+
};
|
|
345
|
+
const finish = (result) => {
|
|
346
|
+
stop();
|
|
347
|
+
cleanup();
|
|
348
|
+
region.finish(done(result, term()));
|
|
349
|
+
resolve(result);
|
|
350
|
+
};
|
|
351
|
+
const cleanup = () => {
|
|
352
|
+
io.output.off("resize", render);
|
|
353
|
+
o.signal?.removeEventListener("abort", onAbort);
|
|
354
|
+
};
|
|
355
|
+
const onAbort = () => {
|
|
356
|
+
stop();
|
|
357
|
+
cleanup();
|
|
358
|
+
region.finish();
|
|
359
|
+
reject(withdrawn(o.signal));
|
|
360
|
+
};
|
|
361
|
+
const edit = (key) => {
|
|
362
|
+
if (editing === void 0) return;
|
|
363
|
+
if (key.name === "enter") {
|
|
364
|
+
const value = editing.value.trim();
|
|
365
|
+
note = value === "" ? void 0 : value;
|
|
366
|
+
editing = void 0;
|
|
367
|
+
} else if (key.name === "escape") editing = void 0;
|
|
368
|
+
else editing = editLine(editing, key);
|
|
369
|
+
render();
|
|
370
|
+
};
|
|
371
|
+
const move = (to) => {
|
|
372
|
+
cursor = Math.min(o.options.length - 1, Math.max(0, to));
|
|
373
|
+
render();
|
|
374
|
+
};
|
|
375
|
+
const stop = readKeys(io.input, (key) => {
|
|
376
|
+
if (key.name === "ctrl-c") {
|
|
377
|
+
stop();
|
|
378
|
+
cleanup();
|
|
379
|
+
region.finish();
|
|
380
|
+
resolve({ kind: "interrupted" });
|
|
381
|
+
interrupt(io);
|
|
382
|
+
} else if (editing !== void 0) edit(key);
|
|
383
|
+
else if (key.name === "enter") {
|
|
384
|
+
const id = o.options[cursor]?.id;
|
|
385
|
+
if (id === void 0) return;
|
|
386
|
+
finish(note === void 0 ? {
|
|
387
|
+
kind: "chosen",
|
|
388
|
+
id
|
|
389
|
+
} : {
|
|
390
|
+
kind: "chosen",
|
|
391
|
+
id,
|
|
392
|
+
note
|
|
393
|
+
});
|
|
394
|
+
} else if (key.name === "up") move(cursor - 1);
|
|
395
|
+
else if (key.name === "down") move(cursor + 1);
|
|
396
|
+
else if (key.name === "home") move(0);
|
|
397
|
+
else if (key.name === "end") move(o.options.length - 1);
|
|
398
|
+
else if (key.name === "escape" && o.later === true) finish({ kind: "later" });
|
|
399
|
+
else if (key.name === "char") {
|
|
400
|
+
const c = key.text;
|
|
401
|
+
if (c === "k") move(cursor - 1);
|
|
402
|
+
else if (c === "j") move(cursor + 1);
|
|
403
|
+
else if (c >= "1" && c <= "9") {
|
|
404
|
+
if (Number(c) <= o.options.length) move(Number(c) - 1);
|
|
405
|
+
} else if (c === "n" && o.note === true) {
|
|
406
|
+
const value = note ?? "";
|
|
407
|
+
editing = {
|
|
408
|
+
value,
|
|
409
|
+
pos: Array.from(value).length
|
|
410
|
+
};
|
|
411
|
+
render();
|
|
412
|
+
} else if (c === "d" && o.details !== void 0) {
|
|
413
|
+
showDetails = !showDetails;
|
|
414
|
+
render();
|
|
415
|
+
} else if (c === "q" && o.later === true) finish({ kind: "later" });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
io.output.on("resize", render);
|
|
419
|
+
o.signal?.addEventListener("abort", onAbort, { once: true });
|
|
420
|
+
render();
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
// src/human.ts
|
|
424
|
+
/**
|
|
425
|
+
* @file The terminal `HumanChannel`: answers `ask_user` questions, gates and
|
|
426
|
+
* permission prompts on the terminal, one at a time, and denies or defers
|
|
427
|
+
* when no one is there. It draws through the terminal kit (`term/`): a card
|
|
428
|
+
* with an ASK chip and a list moved with the arrow keys, a yes-or-no for a
|
|
429
|
+
* permission, and one optional line for a note after a choice. Plain (not a
|
|
430
|
+
* terminal, `NO_COLOR`, `--plain`) it keeps the numbered prompts. In a run it
|
|
431
|
+
* is the decision queue's local answerer, racing `ork decide`.
|
|
432
|
+
*/
|
|
433
|
+
const PREVIEW = 160;
|
|
434
|
+
/** A document longer than this is offered (`d`) rather than printed. */
|
|
435
|
+
const SHORT_DOCUMENT_LINES = 40;
|
|
436
|
+
const NO_ONE_TO_PERMIT = "No one is at the terminal to approve this tool, so it is not allowed in this run.";
|
|
437
|
+
const NOTE_MESSAGE = "Note for the run (optional, Enter to skip)";
|
|
438
|
+
const NO_ONE_TO_ASK = "no one (nobody is at the terminal; use your best judgment and say so in your report)";
|
|
439
|
+
/**
|
|
440
|
+
* What a foreground caller shows of the runs it calls: their lines always, and
|
|
441
|
+
* their questions only when stdin is a terminal, so no one is answered for.
|
|
442
|
+
*/
|
|
443
|
+
const callsShown = (human, stdinIsTty) => stdinIsTty === true ? { ask: human } : {};
|
|
444
|
+
/** One line that shows what a tool is about to do. */
|
|
445
|
+
const previewInput = (toolName, input) => {
|
|
446
|
+
const key = TOOL_INPUT_KEYS.find((k) => typeof input[k] === "string");
|
|
447
|
+
const line = (key === void 0 ? JSON.stringify(input) : `${key}: ${String(input[key])}`).replaceAll(/\s+/gu, " ").trim();
|
|
448
|
+
return line.length > PREVIEW ? `${toolName} ${line.slice(0, 159)}…` : `${toolName} ${line}`;
|
|
449
|
+
};
|
|
450
|
+
/** Never answers until `signal` withdraws it, at once if it already has. */
|
|
451
|
+
const parked = (signal) => new Promise((_, reject) => {
|
|
452
|
+
if (signal?.aborted === true) {
|
|
453
|
+
reject(withdrawn(signal));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
signal?.addEventListener("abort", () => {
|
|
457
|
+
reject(withdrawn(signal));
|
|
458
|
+
}, { once: true });
|
|
459
|
+
});
|
|
460
|
+
/**
|
|
461
|
+
* Answers permission prompts and `ask_user` questions on the terminal, one at a
|
|
462
|
+
* time. Without a terminal it denies tools and leaves questions to the node,
|
|
463
|
+
* but a gate's question (`waits`) waits for `ork decide` or the UI. A withdrawn
|
|
464
|
+
* prompt (answered elsewhere) rejects, which frees the turn.
|
|
465
|
+
*/
|
|
466
|
+
const createTerminalHuman = (io) => {
|
|
467
|
+
let rl;
|
|
468
|
+
let closed;
|
|
469
|
+
let turn = Promise.resolve();
|
|
470
|
+
const say = (text) => {
|
|
471
|
+
io.output.write(`${text}\n`);
|
|
472
|
+
};
|
|
473
|
+
/** The one `readline` the plain prompts share; a rich prompt never opens it. */
|
|
474
|
+
const readLine = (text, signal) => {
|
|
475
|
+
if (rl === void 0) {
|
|
476
|
+
const opened = createInterface({
|
|
477
|
+
input: io.input,
|
|
478
|
+
output: io.output,
|
|
479
|
+
...plainEnv(io.env) ? { terminal: false } : {}
|
|
480
|
+
});
|
|
481
|
+
if (io.interrupt !== void 0) opened.on("SIGINT", io.interrupt);
|
|
482
|
+
closed = new Promise((resolve) => {
|
|
483
|
+
opened.once("close", () => {
|
|
484
|
+
resolve(void 0);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
rl = opened;
|
|
488
|
+
}
|
|
489
|
+
const answer = signal === void 0 ? rl.question(text) : rl.question(text, { signal });
|
|
490
|
+
return closed === void 0 ? answer : Promise.race([answer, closed]);
|
|
491
|
+
};
|
|
492
|
+
const kit = {
|
|
493
|
+
input: io.input,
|
|
494
|
+
output: io.output,
|
|
495
|
+
readLine,
|
|
496
|
+
...io.env === void 0 ? {} : { env: io.env },
|
|
497
|
+
...io.interrupt === void 0 ? {} : { interrupt: io.interrupt }
|
|
498
|
+
};
|
|
499
|
+
const term = () => termOf(io.output, io.env);
|
|
500
|
+
/** The optional line after a choice, when the asker takes a note. */
|
|
501
|
+
const takeNote = async (onNote, signal) => {
|
|
502
|
+
if (onNote === void 0) return;
|
|
503
|
+
const noted = await readText(kit, {
|
|
504
|
+
message: NOTE_MESSAGE,
|
|
505
|
+
...signal === void 0 ? {} : { signal }
|
|
506
|
+
});
|
|
507
|
+
if (noted.kind === "interrupted") await parked(signal);
|
|
508
|
+
if (noted.kind === "text" && noted.value !== "") onNote(noted.value);
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* Says where a waiting question is answered, and waits until withdrawn. A
|
|
512
|
+
* foreground run has no one at `ork decide` yet, so it says to start over
|
|
513
|
+
* detached.
|
|
514
|
+
*/
|
|
515
|
+
const waitElsewhere = (q) => {
|
|
516
|
+
const detached = (io.env ?? process.env)[DETACHED_ENV] === "1";
|
|
517
|
+
say(paint(term(), "muted", ` no terminal; ${plain(q.nodeId)} waits for ork decide or ork ui` + (detached ? "" : ": use --detach and answer with ork decide")));
|
|
518
|
+
return parked(q.signal);
|
|
519
|
+
};
|
|
520
|
+
const oneAtATime = (work) => {
|
|
521
|
+
const result = turn.then(work);
|
|
522
|
+
turn = result.catch(() => null);
|
|
523
|
+
return result;
|
|
524
|
+
};
|
|
525
|
+
const asking = (q, place) => !io.interactive && q.waits === true ? waitElsewhere(q) : oneAtATime(async () => {
|
|
526
|
+
const { signal } = q;
|
|
527
|
+
if (signal?.aborted === true) throw new Error("the question was withdrawn");
|
|
528
|
+
const t = term();
|
|
529
|
+
const documentLines = q.document?.trimEnd().split("\n").length ?? 0;
|
|
530
|
+
const document = q.document === void 0 ? void 0 : clean(q.document);
|
|
531
|
+
const printed = document !== void 0 && documentLines <= SHORT_DOCUMENT_LINES;
|
|
532
|
+
const shownDocument = t.rich || !printed ? void 0 : document;
|
|
533
|
+
const header = (tt) => cardLines(tt, q, place, io.verbose === true, shownDocument).lines;
|
|
534
|
+
const options = q.options.map((o) => ({
|
|
535
|
+
id: o.id,
|
|
536
|
+
...o.label === void 0 ? {} : { label: plain(o.label) },
|
|
537
|
+
...o.description === void 0 ? {} : { description: plain(o.description) }
|
|
538
|
+
}));
|
|
539
|
+
const { moreContext } = cardLines(t, q, place, io.verbose === true);
|
|
540
|
+
const details = t.rich ? [moreContext ? clean(q.context ?? "") : void 0, document].filter((part) => part !== void 0).join("\n\n") || void 0 : printed ? void 0 : document;
|
|
541
|
+
if (!io.interactive) {
|
|
542
|
+
for (const line of header(t)) say(line);
|
|
543
|
+
for (const line of plainOptions(options, q.recommended)) say(line);
|
|
544
|
+
const answer = q.recommended ?? NO_ONE_TO_ASK;
|
|
545
|
+
say(paint(t, "muted", ` no terminal; answered ${answer}`));
|
|
546
|
+
return answer;
|
|
547
|
+
}
|
|
548
|
+
const result = await select(kit, {
|
|
549
|
+
header,
|
|
550
|
+
options,
|
|
551
|
+
...q.recommended === void 0 ? {} : { recommended: q.recommended },
|
|
552
|
+
...details === void 0 ? {} : { details },
|
|
553
|
+
...!t.rich && details !== void 0 ? { footer: [` Document: ${String(documentLines)} lines. Type d to read it.`] } : {},
|
|
554
|
+
note: q.onNote !== void 0,
|
|
555
|
+
notePrompt: NOTE_MESSAGE,
|
|
556
|
+
later: place !== void 0,
|
|
557
|
+
done: (answer, tt) => doneLine(tt, place, q.nodeId, answer),
|
|
558
|
+
...signal === void 0 ? {} : { signal }
|
|
559
|
+
});
|
|
560
|
+
if (result.kind === "interrupted") return parked(signal);
|
|
561
|
+
if (result.kind === "later") return null;
|
|
562
|
+
if (result.note !== void 0) q.onNote?.(result.note);
|
|
563
|
+
return result.id;
|
|
564
|
+
});
|
|
565
|
+
return {
|
|
566
|
+
permit: ({ nodeId, toolName, input, signal, onNote }) => oneAtATime(async () => {
|
|
567
|
+
if (signal?.aborted === true) throw new Error("the prompt was withdrawn");
|
|
568
|
+
const t = term();
|
|
569
|
+
if (t.rich) say("");
|
|
570
|
+
say(permitLine(t, nodeId, previewInput(toolName, input)));
|
|
571
|
+
if (!io.interactive) {
|
|
572
|
+
say(paint(t, "muted", ` denied: ${NO_ONE_TO_PERMIT}`));
|
|
573
|
+
return {
|
|
574
|
+
allow: false,
|
|
575
|
+
message: NO_ONE_TO_PERMIT
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const allowed = await confirmOrStop(kit, {
|
|
579
|
+
question: "Allow?",
|
|
580
|
+
...signal === void 0 ? {} : { signal }
|
|
581
|
+
});
|
|
582
|
+
if (allowed === null) return parked(signal);
|
|
583
|
+
if (allowed) return { allow: true };
|
|
584
|
+
await takeNote(onNote, signal);
|
|
585
|
+
return {
|
|
586
|
+
allow: false,
|
|
587
|
+
message: "The user did not allow this tool."
|
|
588
|
+
};
|
|
589
|
+
}),
|
|
590
|
+
ask: async (q) => await asking(q) ?? NO_ONE_TO_ASK,
|
|
591
|
+
askAt: (q, place) => asking(q, place),
|
|
592
|
+
close: () => {
|
|
593
|
+
rl?.close();
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
};
|
|
597
|
+
// src/run/format-end.ts
|
|
598
|
+
/**
|
|
599
|
+
* @file How a node's end reads on the terminal (specs/runtime/session-ends.md):
|
|
600
|
+
* its kind's label and Ork's own message, a note for a kind that may pass,
|
|
601
|
+
* the setup hint for an account or provider end, the last line of a failed
|
|
602
|
+
* tool's own stderr, and the scrubbed raw detail only when asked for. Never
|
|
603
|
+
* the provider's own words first.
|
|
604
|
+
*/
|
|
605
|
+
/** What a transient kind says in place of its hint on the log line. */
|
|
606
|
+
const MAY_PASS = "(may pass; ork resume retries)";
|
|
607
|
+
/** `Turn limit: reached its turn limit (8)`, with a note when it may pass. */
|
|
608
|
+
const endHeadline = (end) => {
|
|
609
|
+
const { label, transient } = END_KINDS[end.kind];
|
|
610
|
+
return `${label}: ${end.message}${transient ? ` ${MAY_PASS}` : ""}`;
|
|
611
|
+
};
|
|
612
|
+
/** The longest a script's last stderr line is shown on the log line. */
|
|
613
|
+
const LAST_LINE_LIMIT = 200;
|
|
614
|
+
/** The last non-empty line of a scrubbed detail, cut; "" when there is none. */
|
|
615
|
+
const lastLine = (detail) => (detail ?? "").split("\n").map((line) => line.trim()).findLast((line) => line !== "")?.slice(0, LAST_LINE_LIMIT) ?? "";
|
|
616
|
+
/**
|
|
617
|
+
* The lines under a headline: the hint for an account or provider end (what to
|
|
618
|
+
* set up) or whenever `full` (`--verbose`, one node's log), and the detail only
|
|
619
|
+
* when `full`. A transient kind's hint is its retry wording, which the headline
|
|
620
|
+
* already says, so it is not printed twice. A failed tool (`command_failed`)
|
|
621
|
+
* also shows the last line of its detail without `full`: a script's stderr is
|
|
622
|
+
* the workflow's own message, not the provider's.
|
|
623
|
+
*/
|
|
624
|
+
const endLines = (end, full, term = createView().term) => {
|
|
625
|
+
const style = styler(term);
|
|
626
|
+
const { actor, hint, transient } = END_KINDS[end.kind];
|
|
627
|
+
const setup = actor === "account" || actor === "provider";
|
|
628
|
+
const lines = [];
|
|
629
|
+
if (hint !== "" && !transient && (full || setup)) lines.push(` ${style("dim", hint)}`);
|
|
630
|
+
if (full && end.detail !== void 0) lines.push(style("dim", " details:"), ...end.detail.split("\n").map((line) => style("dim", ` ${line}`)));
|
|
631
|
+
else if (end.kind === "command_failed") {
|
|
632
|
+
const last = lastLine(end.detail);
|
|
633
|
+
if (last !== "") lines.push(` ${style("dim", last)}`);
|
|
634
|
+
}
|
|
635
|
+
return lines;
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* A finished node's line under `tag`: its verdict, or for a failure its kind
|
|
639
|
+
* and message (and the lines under it). The agent's own summary stays for a
|
|
640
|
+
* `fail` it reported, and an old run's text no rule reads stays as stored.
|
|
641
|
+
*/
|
|
642
|
+
const formatFinished = (event, tag, verbose, term = createView().term, rest = tag) => {
|
|
643
|
+
const style = styler(term);
|
|
644
|
+
const conflict = hasConflict(event.output);
|
|
645
|
+
const mark = event.ok ? style(conflict ? "yellow" : "green", conflict ? "!" : "✓") : style("red", "✗");
|
|
646
|
+
const spent = event.usage === void 0 ? "" : style("dim", ` · ${cost(event.usage.costUsd)}`);
|
|
647
|
+
const end = endOf(event);
|
|
648
|
+
if (!(!event.ok && end.kind !== "reported_failure" && (event.end !== void 0 || end.kind !== "unknown"))) return [`${tag} ${mark} ${nodeVerdict(event.output, event.ok, event.error)}${spent}`];
|
|
649
|
+
return [`${tag} ${mark} ${endHeadline(end)}${spent}`, ...endLines(end, verbose, term).map((line) => `${rest} ${line}`)];
|
|
650
|
+
};
|
|
651
|
+
// src/run/format-machine.ts
|
|
652
|
+
/**
|
|
653
|
+
* @file How a node's waits for the machine read in `ork logs`: a slot for a
|
|
654
|
+
* heavy command (ADR-080) and a lock (RFC-016), each naming who holds it.
|
|
655
|
+
*/
|
|
656
|
+
/** Whether `event` is a slot's or a lock's. */
|
|
657
|
+
const isMachineEvent = (event) => event.type.startsWith("slot.") || event.type.startsWith("lock.");
|
|
658
|
+
/** A slot or lock event's line, after its node's tag. */
|
|
659
|
+
const machineLine = (event, style) => {
|
|
660
|
+
if (event.type === "slot.waiting") return style("yellow", `‖ ${slotWaitLine(event.holders)}`);
|
|
661
|
+
if (event.type === "slot.taken") return style("dim", "▸ got a check slot");
|
|
662
|
+
if (event.type === "lock.waiting") return style("yellow", `‖ ${lockWaitLine(event.lock, event.holders)}`);
|
|
663
|
+
if (event.type === "lock.taken") return style("dim", `▸ holds ${event.lock}`);
|
|
664
|
+
return style("dim", `▸ let ${event.lock} go`);
|
|
665
|
+
};
|
|
666
|
+
// src/run/format-outcome.ts
|
|
667
|
+
/**
|
|
668
|
+
* @file How a finished session reads on the terminal: its report or its end,
|
|
669
|
+
* the branch, artifacts and fields, and the usage line.
|
|
670
|
+
*/
|
|
671
|
+
const STATUS_MARK = {
|
|
672
|
+
pass: {
|
|
673
|
+
mark: "✓",
|
|
674
|
+
color: "green"
|
|
675
|
+
},
|
|
676
|
+
done: {
|
|
677
|
+
mark: "✓",
|
|
678
|
+
color: "green"
|
|
679
|
+
},
|
|
680
|
+
changes_requested: {
|
|
681
|
+
mark: "!",
|
|
682
|
+
color: "yellow"
|
|
683
|
+
},
|
|
684
|
+
fail: {
|
|
685
|
+
mark: "✗",
|
|
686
|
+
color: "red"
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
const seconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
690
|
+
/** The usage line: cost, tokens, turns and time. */
|
|
691
|
+
const formatUsage = ({ usage }, term = createView().term) => styler(term)("dim", [
|
|
692
|
+
cost(usage.costUsd),
|
|
693
|
+
`${String(usage.inputTokens)} in / ${String(usage.outputTokens)} out (${String(usage.cacheReadTokens)} cached)`,
|
|
694
|
+
`${String(usage.turns)} turns`,
|
|
695
|
+
seconds(usage.durationMs)
|
|
696
|
+
].join(" · "));
|
|
697
|
+
/** How a finished session reads on the terminal. */
|
|
698
|
+
const formatOutcome = (nodeId, outcome, verbose = false, term = createView().term) => {
|
|
699
|
+
const style = styler(term);
|
|
700
|
+
const { report } = outcome;
|
|
701
|
+
if (report === null) {
|
|
702
|
+
const end = outcomeEnd(outcome);
|
|
703
|
+
return [
|
|
704
|
+
`${style("red", "✗")} ${style("bold", nodeId)} ${endHeadline(end)}`,
|
|
705
|
+
...endLines(end, verbose, term),
|
|
706
|
+
` ${formatUsage(outcome, term)}`
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
const shown = STATUS_MARK[report.status];
|
|
710
|
+
const lines = [`${shown === void 0 ? "-" : style(shown.color, shown.mark)} ${style("bold", nodeId)} ${report.status}: ${report.summary}`];
|
|
711
|
+
if (report.branch !== void 0) lines.push(` branch: ${report.branch}`);
|
|
712
|
+
for (const [name, file] of Object.entries(report.artifacts)) lines.push(` ${name}: ${file}`);
|
|
713
|
+
for (const [name, value] of Object.entries(report.fields)) lines.push(` ${name}: ${JSON.stringify(value)}`);
|
|
714
|
+
lines.push(` ${formatUsage(outcome, term)}`);
|
|
715
|
+
return lines;
|
|
716
|
+
};
|
|
717
|
+
// src/run/format-result.ts
|
|
718
|
+
/**
|
|
719
|
+
* @file The run summary on the terminal: status, cost, what ran, what a resume
|
|
720
|
+
* kept, what was skipped and which failures an edge handled (G8, L42),
|
|
721
|
+
* branches to look at, why it did not succeed, and mail to nodes that never
|
|
722
|
+
* ran (L91).
|
|
723
|
+
*/
|
|
724
|
+
/** How a run ended: its mark, and its chip's word and role when rich. */
|
|
725
|
+
const RUN_END = {
|
|
726
|
+
succeeded: {
|
|
727
|
+
mark: "✓",
|
|
728
|
+
ok: "ok",
|
|
729
|
+
label: CHIP_LABELS.ok
|
|
730
|
+
},
|
|
731
|
+
failed: {
|
|
732
|
+
mark: "✗",
|
|
733
|
+
ok: "danger",
|
|
734
|
+
label: CHIP_LABELS.fail
|
|
735
|
+
},
|
|
736
|
+
budget_exhausted: {
|
|
737
|
+
mark: "!",
|
|
738
|
+
ok: "warn",
|
|
739
|
+
label: CHIP_LABELS.wait
|
|
740
|
+
},
|
|
741
|
+
quota_exhausted: {
|
|
742
|
+
mark: "!",
|
|
743
|
+
ok: "warn",
|
|
744
|
+
label: CHIP_LABELS.wait
|
|
745
|
+
},
|
|
746
|
+
timed_out: {
|
|
747
|
+
mark: "!",
|
|
748
|
+
ok: "warn",
|
|
749
|
+
label: CHIP_LABELS.wait
|
|
750
|
+
},
|
|
751
|
+
interrupted: {
|
|
752
|
+
mark: "!",
|
|
753
|
+
ok: "warn",
|
|
754
|
+
label: CHIP_LABELS.wait
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
const MARK_COLOR = {
|
|
758
|
+
ok: "green",
|
|
759
|
+
danger: "red",
|
|
760
|
+
warn: "yellow"
|
|
761
|
+
};
|
|
762
|
+
/** `1 failure handled`, `2 failures handled`. */
|
|
763
|
+
const failuresHandled = (count) => `${String(count)} ${count === 1 ? "failure" : "failures"} handled`;
|
|
764
|
+
/**
|
|
765
|
+
* The workflow's declared outputs, one per key: a one-line value on the key's
|
|
766
|
+
* line, a longer one under it; a cut value points at the stored full one.
|
|
767
|
+
*/
|
|
768
|
+
const outputLines = (result, style) => outputsText(result.outputs).flatMap(({ name, text, cut }) => {
|
|
769
|
+
const key = style("bold", `${name}:`);
|
|
770
|
+
const tail = cut ? [style("dim", ` … cut; full value: ork logs ${result.runId} --json`)] : [];
|
|
771
|
+
if (text === "") return [` ${key} ${style("dim", "(empty)")}`];
|
|
772
|
+
if (!text.includes("\n")) return [` ${key} ${text}`].concat(tail);
|
|
773
|
+
const body = text.split("\n").map((line) => ` ${line}`);
|
|
774
|
+
return [` ${key}`].concat(body, tail);
|
|
775
|
+
});
|
|
776
|
+
/**
|
|
777
|
+
* The run summary: status, cost, what ran, what was skipped and which failures
|
|
778
|
+
* an edge handled, branches to look at, why, and mail to nodes that never ran.
|
|
779
|
+
*/
|
|
780
|
+
const formatRunResult = (result, term = createView().term) => {
|
|
781
|
+
const style = styler(term);
|
|
782
|
+
const end = RUN_END[result.status];
|
|
783
|
+
const mark = term.rich ? chip(term, end.label, end.ok) : style(MARK_COLOR[end.ok], end.mark);
|
|
784
|
+
const keptCount = result.kept ?? 0;
|
|
785
|
+
const ran = result.nodes.filter((n) => n.runs > 0).length - keptCount;
|
|
786
|
+
const skipped = result.skipped ?? result.nodes.length - ran - keptCount;
|
|
787
|
+
const handled = result.failuresHandled ?? 0;
|
|
788
|
+
const lines = [
|
|
789
|
+
"",
|
|
790
|
+
`${mark} run ${result.runId} ${result.status.replace("_", " ")} ${style("dim", `· ${costOf(result.costUsd, result.partial, result.tokens)} · ${String(ran)} ran, ${keptCount === 0 ? "" : `${String(keptCount)} kept, `}${String(skipped)} skipped${handled === 0 ? "" : `, ${failuresHandled(handled)}`}`)}`,
|
|
791
|
+
...outputLines(result, style)
|
|
792
|
+
];
|
|
793
|
+
for (const kept of result.worktrees) lines.push(` ${kept.id}: ${kept.branch ?? "detached"} ${style("dim", `at ${kept.path} (${kept.reason})`)}`);
|
|
794
|
+
for (const reason of result.reasons) lines.push(` ${style("red", reason)}`);
|
|
795
|
+
for (const { node, messages } of result.unread ?? []) lines.push(` ${style(["yellow", "bold"], "warning")} ${node} never ran, so ${messages === 1 ? "1 message" : `${String(messages)} messages`} to it went unread`);
|
|
796
|
+
return lines;
|
|
797
|
+
};
|
|
798
|
+
// src/run/format-tool.ts
|
|
799
|
+
/**
|
|
800
|
+
* @file How a session's own words and tool calls read in the transcript: the
|
|
801
|
+
* agent's text (indented plain, wrapped rich), and a tool call as `Tool first
|
|
802
|
+
* line` cut to the screen with `+N lines`, a path inside the node's worktree
|
|
803
|
+
* relative to it, or whole with `--verbose`. Untrusted text goes through
|
|
804
|
+
* `plain()` first.
|
|
805
|
+
*/
|
|
806
|
+
const indent = (text) => text.trimEnd().split("\n").map((line) => ` ${plain(line)}`);
|
|
807
|
+
/** Agent text under a 2-column indent, its long lines wrapped to `room`. */
|
|
808
|
+
const wrapped = (text, room) => {
|
|
809
|
+
const width = Math.max(1, room - 2);
|
|
810
|
+
return text.trimEnd().split("\n").flatMap((line) => {
|
|
811
|
+
const safe = plain(line).trimEnd();
|
|
812
|
+
return widthOf(safe) <= width ? [safe] : wrap(safe, width);
|
|
813
|
+
}).map((line) => ` ${line}`);
|
|
814
|
+
};
|
|
815
|
+
/** Ork's `delegate` tool, as each driver names it (`mcp__ork__…`, `ork_…`). */
|
|
816
|
+
const DELEGATE = /(?:^|_)delegate$/u;
|
|
817
|
+
/** The input with every string in it masked. */
|
|
818
|
+
const maskedInput = (input) => z.record(z.string(), z.unknown()).parse(JSON.parse(JSON.stringify(input, maskReplacer)));
|
|
819
|
+
/** A tool's input as the call says it: the key that names its target, whole. */
|
|
820
|
+
const toolText = (input, root) => {
|
|
821
|
+
const key = TOOL_INPUT_KEYS.find((k) => typeof input[k] === "string");
|
|
822
|
+
const text = key === void 0 ? JSON.stringify(input) : String(input[key]);
|
|
823
|
+
if (root === void 0) return text;
|
|
824
|
+
const base = root.replace(/\/+$/u, "");
|
|
825
|
+
return text === base ? "." : text.replaceAll(`${base}/`, "");
|
|
826
|
+
};
|
|
827
|
+
/**
|
|
828
|
+
* A tool call: rich, `Tool first line` cut to `room` with `+N lines` when it
|
|
829
|
+
* had more; plain, today's one dim line. `verbose` shows the call whole.
|
|
830
|
+
*/
|
|
831
|
+
const toolLines = (name, raw, view, room, verbose, node) => {
|
|
832
|
+
const input = DELEGATE.test(name) ? maskedInput(raw) : raw;
|
|
833
|
+
const { term } = view;
|
|
834
|
+
const style = styler(term);
|
|
835
|
+
if (!term.rich && !verbose) return [style("dim", ` · ${plain(previewInput(name, input))}`)];
|
|
836
|
+
const label = plain(name);
|
|
837
|
+
const [first = "", ...rest] = toolText(input, view.roots.get(node)).trimEnd().split("\n").map((line) => plain(line));
|
|
838
|
+
const lead = term.rich ? " " : " · ";
|
|
839
|
+
const sep = term.rich ? " " : " ";
|
|
840
|
+
const headWidth = widthOf(lead) + widthOf(label) + sep.length;
|
|
841
|
+
const styledHead = `${style("dim", lead)}${style("bold", label)}${style("dim", sep)}`;
|
|
842
|
+
if (verbose) {
|
|
843
|
+
const pad = " ".repeat(headWidth);
|
|
844
|
+
return [`${styledHead}${style("dim", first)}`, ...rest.map((line) => style("dim", `${pad}${line}`))];
|
|
845
|
+
}
|
|
846
|
+
const more = rest.length === 0 ? "" : ` +${String(rest.length)} ${rest.length === 1 ? "line" : "lines"}`;
|
|
847
|
+
return [`${styledHead}${style("dim", cut(first.trim(), Math.max(1, room - headWidth - more.length)))}${style("dim", more)}`];
|
|
848
|
+
};
|
|
849
|
+
// src/run/format.ts
|
|
850
|
+
/**
|
|
851
|
+
* @file How runs read on the terminal: session events and run events with node
|
|
852
|
+
* chips or prefixes; the node result is `format-outcome.ts`'s, a tool call
|
|
853
|
+
* `format-tool.ts`'s and the run summary `format-result.ts`'s.
|
|
854
|
+
*/
|
|
855
|
+
/** A line's 2-column indent, after any style codes that open it. */
|
|
856
|
+
const INDENT = /^((?:\u001B\[[0-9;]*m)*) {2}/u;
|
|
857
|
+
/** Who answered a decision, as the log says it. */
|
|
858
|
+
const ANSWERED_BY = {
|
|
859
|
+
terminal: "terminal",
|
|
860
|
+
decide: "ork decide",
|
|
861
|
+
ui: "ork ui",
|
|
862
|
+
owner: "its owner",
|
|
863
|
+
yolo: "yolo: not asked"
|
|
864
|
+
};
|
|
865
|
+
/** The transcript lines for one event; tool names come from `tools`. */
|
|
866
|
+
const formatEvent = (nodeId, event, tools, verbose = false, view = createView(), room = view.term.width - 1) => {
|
|
867
|
+
const { term } = view;
|
|
868
|
+
const style = styler(term);
|
|
869
|
+
if (event.type === "session.started") {
|
|
870
|
+
const fork = event.fork === true ? ", a fork to answer a question" : "";
|
|
871
|
+
return [style("dim", `▸ ${nodeId} (${event.model}, session ${event.sessionId}${fork})`)];
|
|
872
|
+
}
|
|
873
|
+
if (event.type === "assistant.text") return term.rich ? wrapped(event.text, room) : indent(event.text);
|
|
874
|
+
if (event.type === "session.waiting") return indent(event.reason);
|
|
875
|
+
if (event.type === "tool.called") {
|
|
876
|
+
tools.set(event.id, event.name);
|
|
877
|
+
return toolLines(event.name, event.input, view, room, verbose, nodeId);
|
|
878
|
+
}
|
|
879
|
+
if (event.type === "tool.result") return event.isError ? [style("red", ` ✗ ${tools.get(event.id) ?? "tool"} failed`)] : [];
|
|
880
|
+
if (event.type === "plan.limit") return event.status === "allowed" ? [] : [style("yellow", ` ! plan: ${event.window.replaceAll("_", " ")} limit ${event.status === "rejected" ? "reached" : "is close"}`)];
|
|
881
|
+
return formatOutcome(nodeId, event.outcome, verbose, term);
|
|
882
|
+
};
|
|
883
|
+
const items = (count) => `${String(count)} ${count === 1 ? "item" : "items"}`;
|
|
884
|
+
/** A `once: true` check that did not run, and the pass it stands on. */
|
|
885
|
+
const skippedLine = ({ check, at, who }) => `${check}: already passed on these files · ${at} · ${who}`;
|
|
886
|
+
/** Transcript lines for one run event, prefixed with the node they are from. */
|
|
887
|
+
const formatRunEvent = (event, width, tools, terminal = false, verbose = false, view = createView()) => {
|
|
888
|
+
const { term } = view;
|
|
889
|
+
const style = styler(term);
|
|
890
|
+
const tag = (id) => term.rich ? `${nodeChip(term, id)}${" ".repeat(Math.max(0, width - id.length))}` : style("cyan", `${id.padEnd(width)} │`);
|
|
891
|
+
const blank = (id) => term.rich ? " ".repeat(widthOf(tag(id))) : tag(id);
|
|
892
|
+
const under = (id, lines) => lines.map((line, i) => `${i === 0 ? tag(id) : blank(id)} ${line}`);
|
|
893
|
+
if (event.type === "run.started") {
|
|
894
|
+
const starts = `starts at ${event.entries.join(", ")}`;
|
|
895
|
+
const yolo = event.settings?.yolo === true ? " · yolo" : "";
|
|
896
|
+
return [
|
|
897
|
+
term.rich ? `${chip(term, CHIP_LABELS.run, "accent")} run ${style("bold", event.runId)} of ${event.workflow} ${style("dim", starts)}${style("yellow", yolo)}` : `${style("dim", `▸ run ${event.runId} of ${event.workflow} (${starts})`)}${yolo}`,
|
|
898
|
+
...event.settings?.trigger === void 0 ? [] : [style("dim", ` started by trigger ${event.settings.trigger}; untrusted inputs: ${(event.settings.untrusted ?? []).join(", ") || "none"}`)],
|
|
899
|
+
...term.rich ? [""] : []
|
|
900
|
+
];
|
|
901
|
+
}
|
|
902
|
+
if (event.type === "node.started") {
|
|
903
|
+
const round = event.round > 1 ? ` (round ${String(event.round)})` : "";
|
|
904
|
+
const owner = event.owner === void 0 ? "" : `, for ${event.owner}`;
|
|
905
|
+
const named = event.description === void 0 ? "" : ` · ${maskSecrets(event.description)}`;
|
|
906
|
+
return [`${tag(event.id)} ${style("dim", `${event.nodeType}${round}${owner}${named}`)}`];
|
|
907
|
+
}
|
|
908
|
+
if (event.type === "node.event") {
|
|
909
|
+
const inner = event.event;
|
|
910
|
+
if (inner.type === "session.finished") return [];
|
|
911
|
+
const fork = inner.fork === true ? style("dim", "fork · ") : "";
|
|
912
|
+
if (inner.type === "session.started") {
|
|
913
|
+
const why = inner.fork === true ? ", to answer a question" : "";
|
|
914
|
+
return [`${tag(event.id)} ${fork}${style("dim", `${inner.model}, session ${inner.sessionId}${why}`)}`];
|
|
915
|
+
}
|
|
916
|
+
const room = term.width - widthOf(tag(event.id)) - 1 + 2 - (inner.fork === true ? 7 : 0);
|
|
917
|
+
return under(event.id, formatEvent(event.id, event.event, tools, verbose, view, room).map((line) => `${fork}${line.replace(INDENT, "$1")}`));
|
|
918
|
+
}
|
|
919
|
+
if (event.type === "node.finished") return formatFinished(event, tag(event.id), verbose, term, blank(event.id));
|
|
920
|
+
if (event.type === "node.skipped") return [`${tag(event.id)} ${style("dim", "- skipped")}`];
|
|
921
|
+
if (event.type === "loop.exhausted") return [`${style(["yellow", "bold"], "!")} the loop ${event.from} -> ${event.to} used all ${String(event.maxRounds)} rounds`];
|
|
922
|
+
if (event.type === "watch.started") return [`${tag(event.id)} ${style("dim", `watching until ${event.until}, every ${event.every}`)}`];
|
|
923
|
+
if (event.type === "watch.changed") return [`${tag(event.id)} ${style("dim", `look ${String(event.looks)}:`)} ${event.said}`];
|
|
924
|
+
if (event.type === "watch.ended") return [`${tag(event.id)} ${style("dim", `${event.outcome} after ${String(event.looks)} looks`)}`];
|
|
925
|
+
if (event.type === "edge.taken") return [];
|
|
926
|
+
if (event.type === "worktree") {
|
|
927
|
+
if (event.action !== "removed") view.roots.set(event.id, event.path);
|
|
928
|
+
const detail = event.detail === void 0 ? "" : ` (${event.detail})`;
|
|
929
|
+
const what = event.action === "removed" && event.branch !== void 0 ? `worktree of ${event.branch} (branch kept)` : `${event.branch ?? event.path}${detail}`;
|
|
930
|
+
const line = `${event.action} ${what}`;
|
|
931
|
+
return [`${tag(event.id)} ${event.action === "conflict" ? style("yellow", `! ${line}`) : style("dim", `⎇ ${line}`)}`];
|
|
932
|
+
}
|
|
933
|
+
if (event.type === "decision.opened") {
|
|
934
|
+
const what = event.kind === "permission" ? "asks to" : "asks:";
|
|
935
|
+
const question = event.kind === "permission" ? event.question.replace(/^Allow /u, "run ").replace(/\?$/u, "") : event.question;
|
|
936
|
+
const where = event.owner === void 0 ? `decision ${event.decision}; ${terminal ? "answer here or with ork decide" : "ork decide"}` : `decision ${event.decision}, via ${event.owner}`;
|
|
937
|
+
return [`${tag(event.node)} ${term.rich ? chip(term, CHIP_LABELS.ask, "accent") : style(["yellow", "bold"], "?")} ${what} ${question} ${style("dim", `(${where})`)}`];
|
|
938
|
+
}
|
|
939
|
+
if (event.type === "decision.answered") {
|
|
940
|
+
const by = event.by === "owner" && event.owner !== void 0 ? event.owner : ANSWERED_BY[event.by];
|
|
941
|
+
const said = event.note === void 0 ? "" : ` · said: ${event.note.replaceAll(/\s+/gu, " ")}`;
|
|
942
|
+
return [`${tag(event.node)} ${style("dim", `→ ${event.answer} (${by})${said}`)}`];
|
|
943
|
+
}
|
|
944
|
+
if (event.type === "decision.withdrawn") return [`${tag(event.node)} ${style("dim", "→ question withdrawn")}`];
|
|
945
|
+
if (event.type === "run.command") {
|
|
946
|
+
const node = event.node === void 0 ? "" : ` --node ${event.node}`;
|
|
947
|
+
return [style(["yellow", "bold"], `! ork ${event.command}${node} received`)];
|
|
948
|
+
}
|
|
949
|
+
if (event.type === "node.held") {
|
|
950
|
+
const at = event.at === "tool" ? "its tool call waits for ork continue" : "it starts after ork continue";
|
|
951
|
+
return [`${tag(event.id)} ${style("yellow", `‖ paused: ${at}`)}`];
|
|
952
|
+
}
|
|
953
|
+
if (event.type === "node.released") return [`${tag(event.id)} ${style("dim", "▸ continues")}`];
|
|
954
|
+
if (isMachineEvent(event)) return [`${tag(event.id)} ${machineLine(event, style)}`];
|
|
955
|
+
if (event.type === "verify.skipped") return [`${tag(event.id)} ${style("dim", `✓ ${skippedLine(event)}`)}`];
|
|
956
|
+
if (event.type === "each.listed") {
|
|
957
|
+
const round = event.round > 1 ? ` (round ${String(event.round)})` : "";
|
|
958
|
+
return [`${tag(event.node)} ${style("dim", `${items(event.count)}${round}`)}`];
|
|
959
|
+
}
|
|
960
|
+
if (event.type === "each.exceeded") {
|
|
961
|
+
const over = `${items(event.count)}, over maxItems ${String(event.maxItems)}`;
|
|
962
|
+
return [`${tag(event.node)} ${style("yellow", `! ${over}`)}`];
|
|
963
|
+
}
|
|
964
|
+
if (event.type === "run.finished") return formatRunResult(event.result, term);
|
|
965
|
+
if (event.type === "run.resumed") {
|
|
966
|
+
const [one] = event.kept;
|
|
967
|
+
return ["", style("dim", `▸ resumed: ${one === void 0 ? "every node runs again" : event.kept.length === 1 ? `${one} keeps its result` : `${event.kept.join(", ")} keep their results`}`)];
|
|
968
|
+
}
|
|
969
|
+
if (event.type === "feedback.recorded") {
|
|
970
|
+
const { target, kind, title } = event.feedback;
|
|
971
|
+
const line = style("dim", `✎ feedback (${target}, ${kind}): ${title}`);
|
|
972
|
+
return [event.node === void 0 ? line : `${tag(event.node)} ${line}`];
|
|
973
|
+
}
|
|
974
|
+
if (event.type === "user.message") {
|
|
975
|
+
const note = event.delivered === "queued" ? " (for its next session)" : "";
|
|
976
|
+
return [`${tag(event.node)} ${style(["magenta", "bold"], "you:")} ${firstLine(event.body)}${style("dim", note)}`];
|
|
977
|
+
}
|
|
978
|
+
return under(event.node, formatBusEvent(event, term));
|
|
979
|
+
};
|
|
980
|
+
// src/runs/logs.ts
|
|
981
|
+
/**
|
|
982
|
+
* @file `ork logs`: replays a stored run's events as the transcript `ork run`
|
|
983
|
+
* printed, for the whole run or one node, and tails a live run until it
|
|
984
|
+
* ends.
|
|
985
|
+
*/
|
|
986
|
+
/** Turns stored events into lines, keeping the state a transcript needs. */
|
|
987
|
+
const createLogPrinter = (options) => {
|
|
988
|
+
const tools = /* @__PURE__ */ new Map();
|
|
989
|
+
const view = options.view ?? createView();
|
|
990
|
+
let width = 8;
|
|
991
|
+
return (stored) => {
|
|
992
|
+
const { event } = stored;
|
|
993
|
+
if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
|
|
994
|
+
if (options.node !== void 0 && !isNodeEvent(event, options.node)) return [];
|
|
995
|
+
if (options.json) return [JSON.stringify(stored)];
|
|
996
|
+
return formatRunEvent(event, width, tools, false, options.verbose === true || options.node !== void 0, view);
|
|
997
|
+
};
|
|
998
|
+
};
|
|
999
|
+
/**
|
|
1000
|
+
* Why `--node` names nothing in this run, or `null`: no event names it and the
|
|
1001
|
+
* run didn't declare it. A live run may still start it, so that is not
|
|
1002
|
+
* refused.
|
|
1003
|
+
*/
|
|
1004
|
+
const unknownNode = (stored, node, live) => {
|
|
1005
|
+
const named = stored.some(({ event }) => isNodeEvent(event, node));
|
|
1006
|
+
const declared = stored.some(({ event }) => event.type === "run.started" && event.nodes.some((n) => n.id === node));
|
|
1007
|
+
return named || declared || live ? null : `Run has no node ${node}.`;
|
|
1008
|
+
};
|
|
1009
|
+
/** `ork logs`'s filter flags (RFC-017): an event's time; no ids. */
|
|
1010
|
+
const logListTable = { fields: [{
|
|
1011
|
+
name: "since",
|
|
1012
|
+
description: "Only events since an age (24h, 30d, 12w), a day (2026-11-01), or all",
|
|
1013
|
+
type: "since",
|
|
1014
|
+
all: true,
|
|
1015
|
+
get: (stored) => stored.at
|
|
1016
|
+
}, {
|
|
1017
|
+
name: "until",
|
|
1018
|
+
description: "Only events on or before this day (2026-11-01)",
|
|
1019
|
+
type: "until",
|
|
1020
|
+
get: (stored) => stored.at
|
|
1021
|
+
}] };
|
|
1022
|
+
/** Whether an event is one `--since`, `--until` and `--node` let through. */
|
|
1023
|
+
const matcher = (options) => (stored) => applyFilters(logListTable, [stored], options.filter ?? {}).length > 0 && (options.node === void 0 || isNodeEvent(stored.event, options.node));
|
|
1024
|
+
/**
|
|
1025
|
+
* Prints a run's events; with `follow`, then each new one as it is stored:
|
|
1026
|
+
* pushed over the run's socket, or polled from the store when there is none.
|
|
1027
|
+
* Returns when `run.finished` is stored, the run's process is gone, or `signal`
|
|
1028
|
+
* aborts.
|
|
1029
|
+
*/
|
|
1030
|
+
const followRun = async (store, run, out, options) => {
|
|
1031
|
+
const print = createLogPrinter(options);
|
|
1032
|
+
const matches = matcher(options);
|
|
1033
|
+
const replayed = options.limit === void 0 ? [] : store.events(run.id, options.after ?? 0).filter((stored) => matches(stored)).slice(0, -options.limit);
|
|
1034
|
+
const dropped = new Set(replayed.map((stored) => stored.seq));
|
|
1035
|
+
if (await followEvents(store, run.id, (stored) => {
|
|
1036
|
+
const lines = print(stored);
|
|
1037
|
+
if (!matches(stored) || dropped.has(stored.seq)) return;
|
|
1038
|
+
for (const line of lines) out(line);
|
|
1039
|
+
}, {
|
|
1040
|
+
follow: options.follow,
|
|
1041
|
+
...options.after === void 0 ? {} : { after: options.after },
|
|
1042
|
+
...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs },
|
|
1043
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
1044
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
1045
|
+
}) === "crashed" || !options.follow && liveStatus(run) === "crashed") out(style("red", `run ${run.id} crashed: its process is gone`));
|
|
1046
|
+
};
|
|
1047
|
+
// src/runs/gutter.ts
|
|
1048
|
+
/**
|
|
1049
|
+
* @file The gutter before a called run's log lines (its call path, then `›`)
|
|
1050
|
+
* and a log printer laid out in the columns the gutter leaves. Shared by `ork
|
|
1051
|
+
* attach` and the commands that print several runs.
|
|
1052
|
+
*/
|
|
1053
|
+
/** A call path for reading: `plan › build`. */
|
|
1054
|
+
const callPathOf = (path) => path.join(" › ");
|
|
1055
|
+
/** The gutter before a called run's lines: its call path, then ` › `. */
|
|
1056
|
+
const gutterOf = (path) => path.length === 0 ? "" : `${callPathOf(path)} › `;
|
|
1057
|
+
/**
|
|
1058
|
+
* A run's printer, laid out in the columns its gutter leaves, so a line fits
|
|
1059
|
+
* the terminal with the gutter before it and a wrapped line goes on under its
|
|
1060
|
+
* own text, not at the terminal's first column.
|
|
1061
|
+
*/
|
|
1062
|
+
const printerFor = (term, path, options = {}) => createLogPrinter({
|
|
1063
|
+
...options,
|
|
1064
|
+
json: false,
|
|
1065
|
+
view: {
|
|
1066
|
+
term: {
|
|
1067
|
+
...term,
|
|
1068
|
+
width: Math.max(20, term.width - widthOf(gutterOf(path)))
|
|
1069
|
+
},
|
|
1070
|
+
roots: /* @__PURE__ */ new Map()
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
/** Prints `lines` through `out`, each (not blank) behind `path`'s muted gutter. */
|
|
1074
|
+
const printUnder = (out, term, path, lines) => {
|
|
1075
|
+
const gutter = gutterOf(path);
|
|
1076
|
+
const prefix = gutter === "" ? "" : paint(term, "muted", gutter);
|
|
1077
|
+
for (const line of lines) out(line === "" ? line : `${prefix}${line}`);
|
|
1078
|
+
};
|
|
1079
|
+
// src/runs/ask-stored.ts
|
|
1080
|
+
/**
|
|
1081
|
+
* @file Asks one stored decision on a terminal and stores the answer: the
|
|
1082
|
+
* shared step of `ork decide`'s prompts and of `ork attach` (specs/cli.md).
|
|
1083
|
+
* The question goes through the `TerminalHuman`, so prompts keep their order;
|
|
1084
|
+
* a `signal` withdraws the prompt when the decision closes elsewhere; the
|
|
1085
|
+
* answer goes through `answerDecision`, whose first answer wins.
|
|
1086
|
+
*/
|
|
1087
|
+
/** Asks `decision` on `human`, then stores the answer unless it was withdrawn. */
|
|
1088
|
+
const askStored = async (store, human, decision, { place, by, signal, home }) => {
|
|
1089
|
+
let note;
|
|
1090
|
+
const question = {
|
|
1091
|
+
nodeId: decision.node,
|
|
1092
|
+
question: decision.question,
|
|
1093
|
+
options: decision.options,
|
|
1094
|
+
onNote: (given) => {
|
|
1095
|
+
note = given;
|
|
1096
|
+
},
|
|
1097
|
+
...decision.recommended === void 0 ? {} : { recommended: decision.recommended },
|
|
1098
|
+
...decision.context === void 0 ? {} : { context: decision.context },
|
|
1099
|
+
...decision.document === void 0 ? {} : { document: decision.document },
|
|
1100
|
+
...signal === void 0 ? {} : { signal }
|
|
1101
|
+
};
|
|
1102
|
+
let choice;
|
|
1103
|
+
try {
|
|
1104
|
+
choice = await human.askAt(question, place);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
if (signal?.aborted === true) return { kind: "withdrawn" };
|
|
1107
|
+
throw error;
|
|
1108
|
+
}
|
|
1109
|
+
if (choice === null) return { kind: "later" };
|
|
1110
|
+
const answered = await answerDecision(store, decision.id, choice, home, by, note);
|
|
1111
|
+
return answered.ok ? {
|
|
1112
|
+
kind: "answered",
|
|
1113
|
+
choice
|
|
1114
|
+
} : {
|
|
1115
|
+
kind: "refused",
|
|
1116
|
+
message: answered.error
|
|
1117
|
+
};
|
|
1118
|
+
};
|
|
1119
|
+
// src/runs/prompts.ts
|
|
1120
|
+
const closedLine = (id, closed) => {
|
|
1121
|
+
if (closed.type === "decision.withdrawn") return `${id} withdrawn`;
|
|
1122
|
+
const by = closed.by === "owner" ? closed.owner ?? ANSWERED_BY.owner : ANSWERED_BY[closed.by];
|
|
1123
|
+
return `${id} answered elsewhere: ${closed.answer} (${by})`;
|
|
1124
|
+
};
|
|
1125
|
+
/** Creates the prompts a command owns over `store`, asked on `human`. */
|
|
1126
|
+
const createPrompts = (store, human, options) => {
|
|
1127
|
+
const { term, out } = options;
|
|
1128
|
+
const say = styler(term);
|
|
1129
|
+
const prompts = /* @__PURE__ */ new Map();
|
|
1130
|
+
/** Decisions answered here: their `decision.answered` line is the card's. */
|
|
1131
|
+
const mine = /* @__PURE__ */ new Set();
|
|
1132
|
+
const asks = [];
|
|
1133
|
+
const state = {};
|
|
1134
|
+
const show = (path, lines) => {
|
|
1135
|
+
printUnder(out, term, path, lines);
|
|
1136
|
+
};
|
|
1137
|
+
const ask = ({ run, path = [], workflow }, id) => {
|
|
1138
|
+
const decision = store.decision(id);
|
|
1139
|
+
if (decision === void 0) return;
|
|
1140
|
+
const prompt = { controller: new AbortController() };
|
|
1141
|
+
prompts.set(id, prompt);
|
|
1142
|
+
const settle = async () => {
|
|
1143
|
+
try {
|
|
1144
|
+
const asked = await askStored(store, human, decision, {
|
|
1145
|
+
by: options.by ?? "terminal",
|
|
1146
|
+
signal: prompt.controller.signal,
|
|
1147
|
+
place: {
|
|
1148
|
+
run,
|
|
1149
|
+
decision: id,
|
|
1150
|
+
...workflow === void 0 ? {} : { workflow },
|
|
1151
|
+
...path.length === 0 ? {} : { path: callPathOf(path) }
|
|
1152
|
+
},
|
|
1153
|
+
...options.home === void 0 ? {} : { home: options.home }
|
|
1154
|
+
});
|
|
1155
|
+
prompts.delete(id);
|
|
1156
|
+
const { closed } = prompt;
|
|
1157
|
+
if (asked.kind === "answered") {
|
|
1158
|
+
mine.add(id);
|
|
1159
|
+
if (!term.rich) show(path, [`${say("green", "✓")} ${id}: ${asked.choice}`]);
|
|
1160
|
+
} else if (asked.kind === "refused") show(path, [paint(term, "warn", `! ${asked.message}`)]);
|
|
1161
|
+
else if (asked.kind === "withdrawn" && closed !== void 0) show(path, [say("dim", closedLine(id, closed))]);
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
prompts.delete(id);
|
|
1164
|
+
state.failure ??= { error };
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
asks.push(settle());
|
|
1168
|
+
};
|
|
1169
|
+
return {
|
|
1170
|
+
ask,
|
|
1171
|
+
has: (id) => prompts.has(id) || mine.has(id),
|
|
1172
|
+
seen: (event) => {
|
|
1173
|
+
if (event.type !== "decision.answered" && event.type !== "decision.withdrawn") return false;
|
|
1174
|
+
const prompt = prompts.get(event.decision);
|
|
1175
|
+
if (prompt !== void 0) {
|
|
1176
|
+
prompt.closed = event;
|
|
1177
|
+
prompt.controller.abort();
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
return event.type === "decision.answered" && mine.has(event.decision);
|
|
1181
|
+
},
|
|
1182
|
+
open: () => prompts.size,
|
|
1183
|
+
abort: () => {
|
|
1184
|
+
for (const prompt of prompts.values()) prompt.controller.abort();
|
|
1185
|
+
},
|
|
1186
|
+
settled: async () => {
|
|
1187
|
+
await Promise.all(asks);
|
|
1188
|
+
if (state.failure !== void 0) throw state.failure.error;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
};
|
|
1192
|
+
// src/runs/attach.ts
|
|
1193
|
+
/**
|
|
1194
|
+
* @file Following a run and every run it called on a terminal, and answering
|
|
1195
|
+
* their open decisions there (`ork attach`, and a foreground `ork run` that
|
|
1196
|
+
* calls workflows; specs/cli.md, specs/scheduler/calls.md). Each run has its
|
|
1197
|
+
* own log printer behind its call path; a decision opened in any of them is
|
|
1198
|
+
* asked through one `TerminalHuman`, withdrawn when it closes elsewhere.
|
|
1199
|
+
*/
|
|
1200
|
+
/**
|
|
1201
|
+
* Prints the events of `root` and of the runs it called until the root ends or
|
|
1202
|
+
* `signal` aborts, and asks each open decision with `options.human`.
|
|
1203
|
+
*/
|
|
1204
|
+
const attachRun = async (store, root, out, options = {}) => {
|
|
1205
|
+
const { human, basePath = [] } = options;
|
|
1206
|
+
const { term } = options.view ?? createView();
|
|
1207
|
+
const say = styler(term);
|
|
1208
|
+
const printers = /* @__PURE__ */ new Map();
|
|
1209
|
+
const seen = {
|
|
1210
|
+
hinted: false,
|
|
1211
|
+
succeeded: false
|
|
1212
|
+
};
|
|
1213
|
+
const show = (path, lines) => {
|
|
1214
|
+
printUnder(out, term, path, lines);
|
|
1215
|
+
};
|
|
1216
|
+
const prompts = human === void 0 ? void 0 : createPrompts(store, human, {
|
|
1217
|
+
out,
|
|
1218
|
+
term,
|
|
1219
|
+
...options.home === void 0 ? {} : { home: options.home }
|
|
1220
|
+
});
|
|
1221
|
+
const { end, stillRunning } = await followRunTree(store, root, ({ run, path: below, stored }) => {
|
|
1222
|
+
const path = [...basePath, ...below];
|
|
1223
|
+
const { event } = stored;
|
|
1224
|
+
if (event.type === "run.finished" && below.length === 0) seen.succeeded = event.result.status === "succeeded";
|
|
1225
|
+
if (prompts?.seen(event) === true) return;
|
|
1226
|
+
if (event.type === "decision.opened" && event.owner === void 0 && pendingDecisions(store, { runId: run }).some((d) => d.id === event.decision)) {
|
|
1227
|
+
if (prompts !== void 0) {
|
|
1228
|
+
prompts.ask({
|
|
1229
|
+
run,
|
|
1230
|
+
path
|
|
1231
|
+
}, event.decision);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (options.hintNoTerminal === true && !seen.hinted) {
|
|
1235
|
+
seen.hinted = true;
|
|
1236
|
+
show(path, [say("dim", "no terminal: answer with ork decide")]);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
let print = printers.get(run);
|
|
1240
|
+
if (print === void 0) {
|
|
1241
|
+
print = printerFor(term, path);
|
|
1242
|
+
printers.set(run, print);
|
|
1243
|
+
}
|
|
1244
|
+
show(path, print(stored));
|
|
1245
|
+
}, {
|
|
1246
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
1247
|
+
...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs },
|
|
1248
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
1249
|
+
});
|
|
1250
|
+
prompts?.abort();
|
|
1251
|
+
await prompts?.settled();
|
|
1252
|
+
if (end === "crashed") show(basePath, [say("red", `run ${root} crashed: its process is gone`)]);
|
|
1253
|
+
for (const id of stillRunning) out(say("dim", `↳ run ${id} still running · ork attach ${id}`));
|
|
1254
|
+
return {
|
|
1255
|
+
stillRunning,
|
|
1256
|
+
exitCode: end === "aborted" || seen.succeeded ? 0 : 1
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
export { printerFor as a, logListTable as c, formatEvent as d, formatRunEvent as f, readText as h, printUnder as i, matcher as l, createTerminalHuman as m, createPrompts as n, createLogPrinter as o, callsShown as p, askStored as r, followRun as s, attachRun as t, unknownNode as u };
|