omkit 0.1.4 → 0.1.6

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.
@@ -1,5 +1,10 @@
1
1
  /** Tunes an action built with {@link command}. */
2
2
  export interface CommandOptions {
3
+ /**
4
+ * Launch `cmdOrFile` directly with these arguments instead of executing it
5
+ * through a shell. Omit to run the command as a shell command.
6
+ */
7
+ args?: readonly string[];
3
8
  /** Directory to run in; relative paths resolve against `process.cwd()`. Default ".". */
4
9
  cwd?: string;
5
10
  /** Full environment for the child (handed as-is, not merged). Omit to inherit. */
@@ -8,9 +13,11 @@ export interface CommandOptions {
8
13
  inheritDebugger?: boolean;
9
14
  }
10
15
  /**
11
- * Runs an executable directly (without a shell) as an action instance.
16
+ * Runs a command as an action instance.
12
17
  *
13
- * Output streams into the run's log under `name`; a non-zero exit fails the
14
- * action; teardown force-kills the process tree.
18
+ * By default the command is executed through the shell.
19
+ *
20
+ * If `options.args` is provided, `cmdOrFile` is treated as an executable and
21
+ * launched directly without a shell.
15
22
  */
16
- export declare function command(name: string, file: string, args?: readonly string[], options?: CommandOptions): import("../index.ts").ActionInstance<void, import("../core/types.ts").NoEvents, void>;
23
+ export declare function command(name: string, cmdOrFile: string, options?: CommandOptions): import("../index.ts").ActionInstance<import("zx").ProcessOutput, import("../core/types.ts").NoEvents, void> | import("../index.ts").ActionInstance<void, import("../core/types.ts").NoEvents, void>;
@@ -1,43 +1,72 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { action } from "../core/action.js";
4
- import { killTree, streamLines } from "../system/proc.js";
4
+ import { debuggerFreeEnv, killTree, streamLines } from "../system/proc.js";
5
5
  /**
6
- * Runs an executable directly (without a shell) as an action instance.
6
+ * Build a zx tagged template that runs `cmd` verbatim.
7
+ */
8
+ const verbatim = (cmd) => Object.assign([cmd], { raw: [cmd] });
9
+ /**
10
+ * Runs a command as an action instance.
7
11
  *
8
- * Output streams into the run's log under `name`; a non-zero exit fails the
9
- * action; teardown force-kills the process tree.
12
+ * By default the command is executed through the shell.
13
+ *
14
+ * If `options.args` is provided, `cmdOrFile` is treated as an executable and
15
+ * launched directly without a shell.
10
16
  */
11
- export function command(name, file, args = [], options = {}) {
17
+ export function command(name, cmdOrFile, options = {}) {
18
+ const cwd = path.resolve(options.cwd ?? ".");
19
+ // Shell command.
20
+ if (options.args === undefined) {
21
+ return action(name).run((ctx) => ctx.proc(name, {
22
+ cwd,
23
+ ...(options.env && { env: options.env }),
24
+ ...(options.inheritDebugger && {
25
+ inheritDebugger: true,
26
+ }),
27
+ })(verbatim(cmdOrFile)))();
28
+ }
29
+ // Direct executable.
12
30
  return action(name).run(async (ctx) => {
13
- const cwd = path.resolve(options.cwd ?? ".");
14
- const child = spawn(file, args, {
31
+ const inherit = options.inheritDebugger ?? Boolean(process.env.OMKIT_INHERIT_DEBUGGER);
32
+ const env = inherit
33
+ ? (options.env ?? process.env)
34
+ : debuggerFreeEnv(options.env ?? process.env);
35
+ const args = options.args ?? [];
36
+ const child = spawn(cmdOrFile, args, {
15
37
  cwd,
16
- env: options.env,
38
+ env,
17
39
  detached: process.platform !== "win32",
18
40
  windowsHide: true,
19
41
  stdio: ["ignore", "pipe", "pipe"],
20
42
  });
21
- console.log(`start · '${file}' ${args.join(" ")} · in ${cwd}`);
22
- void streamLines(child.stdout, (line) => console.info(line));
23
- void streamLines(child.stderr, (line) => console.error(line));
43
+ console.log(`${name} · start · '${cmdOrFile}' ${args.join(" ")} · in ${cwd}`);
44
+ void streamLines(child.stdout, console.info);
45
+ void streamLines(child.stderr, console.error);
24
46
  let finished = false;
25
47
  const onAbort = () => {
26
48
  if (!finished && child.pid !== undefined) {
27
49
  killTree(child.pid);
28
50
  }
29
51
  };
30
- ctx.signal.addEventListener("abort", onAbort, { once: true });
52
+ if (ctx.signal.aborted) {
53
+ onAbort();
54
+ }
55
+ else {
56
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
57
+ }
31
58
  try {
32
59
  await new Promise((resolve, reject) => {
33
- child.once("error", reject);
60
+ child.once("error", (err) => {
61
+ reject(new Error(`${name}: ${err.message}`, { cause: err }));
62
+ });
34
63
  child.once("exit", (code, signal) => {
35
64
  finished = true;
36
- if (code === 0) {
65
+ if (ctx.signal.aborted || code === 0) {
37
66
  resolve();
38
67
  }
39
68
  else {
40
- reject(new Error(signal ? `${name} terminated by ${signal}` : `${name} exited with code ${code}`));
69
+ reject(Object.assign(new Error(signal ? `${name} terminated by ${signal}` : `${name} exited with code ${code}`), { exitCode: code ?? undefined }));
41
70
  }
42
71
  });
43
72
  });
@@ -7,6 +7,8 @@ import type { Exec, InstanceEvents, NodeStatus, Outcome, RunHandle } from "./typ
7
7
  export interface NodeInit {
8
8
  readonly store: LogStore;
9
9
  readonly name: string;
10
+ /** `file:line` where the action was defined (the `.run(...)` site) — for the log header. */
11
+ readonly definedAt: string | undefined;
10
12
  readonly uuid: string;
11
13
  /** `${name}_${shortId}`, or `main` for the root. */
12
14
  readonly id: string;
@@ -36,6 +38,7 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
36
38
  readonly id: string;
37
39
  readonly uuid: string;
38
40
  readonly name: string;
41
+ readonly definedAt: string | undefined;
39
42
  readonly path: string;
40
43
  readonly parentId: string | null;
41
44
  readonly tags: string[];
@@ -62,6 +65,7 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
62
65
  private attached;
63
66
  private observed;
64
67
  private refObserved;
68
+ private cancelLogged;
65
69
  private cascadeCancelled;
66
70
  constructor(init: NodeInit);
67
71
  /** A stable reference for headers/attribution (tags are the accumulated set now). */
@@ -76,6 +80,13 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
76
80
  tag(name: string): this;
77
81
  handleFailure(handler: (error: unknown) => void): this;
78
82
  cancel(): void;
83
+ /**
84
+ * Log a cancellation milestone on this node (once) and bubble it to the parent, so a
85
+ * cancellation is visible per-action and up the tree into `main`. Only while the node is
86
+ * still running — a node that already settled isn't "cancelled" by a later teardown — and
87
+ * never for the root, whose cancellation is the run-level `tearing down · …` narration.
88
+ */
89
+ private noteCancelled;
79
90
  /** Run the body inside the error boundary, settling the node. Never rejects. */
80
91
  run(exec: Exec<Events, Handle, Result>, args: readonly unknown[]): Promise<void>;
81
92
  private context;
@@ -13,6 +13,7 @@ export class ActionRun {
13
13
  id;
14
14
  uuid;
15
15
  name;
16
+ definedAt;
16
17
  path;
17
18
  parentId;
18
19
  tags = [];
@@ -39,6 +40,7 @@ export class ActionRun {
39
40
  attached = false;
40
41
  observed = false;
41
42
  refObserved = false;
43
+ cancelLogged = false;
42
44
  // Set only by the parent-signal abort listener (an ANCESTOR cancelling this node).
43
45
  // Distinguishes a run/ancestor teardown from this node's own subtree-abort on failure,
44
46
  // so the unobserved-failure check below isn't fooled by our own controller.abort().
@@ -47,6 +49,7 @@ export class ActionRun {
47
49
  this.id = init.id;
48
50
  this.uuid = init.uuid;
49
51
  this.name = init.name;
52
+ this.definedAt = init.definedAt;
50
53
  this.path = init.path;
51
54
  this.parentId = init.parentId;
52
55
  this.store = init.store;
@@ -60,6 +63,7 @@ export class ActionRun {
60
63
  const parent = init.parentSignal;
61
64
  const onAncestorAbort = () => {
62
65
  this.cascadeCancelled = true;
66
+ this.noteCancelled(); // this node is being cancelled by an ancestor's teardown
63
67
  this.controller.abort();
64
68
  };
65
69
  if (parent) {
@@ -135,8 +139,22 @@ export class ActionRun {
135
139
  return this;
136
140
  }
137
141
  cancel() {
142
+ this.noteCancelled(); // a direct cancel of this activity
138
143
  this.controller.abort();
139
144
  }
145
+ /**
146
+ * Log a cancellation milestone on this node (once) and bubble it to the parent, so a
147
+ * cancellation is visible per-action and up the tree into `main`. Only while the node is
148
+ * still running — a node that already settled isn't "cancelled" by a later teardown — and
149
+ * never for the root, whose cancellation is the run-level `tearing down · …` narration.
150
+ */
151
+ noteCancelled() {
152
+ if (this.cancelLogged || this.parentId === null || this.status !== "running")
153
+ return;
154
+ this.cancelLogged = true;
155
+ this.log("event", "cancel", "cancelled");
156
+ this.bubble("⊘ cancelled");
157
+ }
140
158
  // ── Execution ──────────────────────────────────────────────────────────────
141
159
  /** Run the body inside the error boundary, settling the node. Never rejects. */
142
160
  async run(exec, args) {
@@ -39,7 +39,7 @@ export declare class ExecutionTree {
39
39
  private readonly onSigint;
40
40
  private readonly onUncaught;
41
41
  private readonly onUnhandled;
42
- constructor();
42
+ constructor(definedAt?: string);
43
43
  /** Launch an instance under the ambient current node (or the root). */
44
44
  launch(spec: LaunchSpec): ActionRun;
45
45
  private append;
@@ -62,11 +62,12 @@ export class ExecutionTree {
62
62
  // Process hooks — installed at run start, removed at finalize (they must not outlive the run).
63
63
  onUncaught = (e) => this.onFatal("uncaughtException", e);
64
64
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
65
- constructor() {
65
+ constructor(definedAt) {
66
66
  this.folder.ensure();
67
67
  this.root = new ActionRun({
68
68
  ...this.nodeDeps(),
69
69
  name: "main",
70
+ definedAt, // where om() was called — the run's script
70
71
  uuid: newUuid(),
71
72
  id: "main",
72
73
  path: "main",
@@ -91,6 +92,7 @@ export class ExecutionTree {
91
92
  const node = new ActionRun({
92
93
  ...this.nodeDeps(),
93
94
  name: spec.name,
95
+ definedAt: spec.definedAt,
94
96
  uuid,
95
97
  id,
96
98
  path: nodePath,
@@ -102,8 +104,10 @@ export class ExecutionTree {
102
104
  });
103
105
  parent.children.push(node);
104
106
  this.registry.push(node);
105
- // Launch reference — the parent's pointer to the child's own (absolute) log.
106
- this.append(parent, "child", "launch", `→ ${id} · ${this.logFile(nodePath)}`);
107
+ // Launch reference — the parent's pointer to the child's own (absolute) log, plus where
108
+ // the action is defined, so `main` (and any parent) shows each child's script.
109
+ const defined = spec.definedAt ? ` · defined ${spec.definedAt}` : "";
110
+ this.append(parent, "child", "launch", `→ ${id} · ${this.logFile(nodePath)}${defined}`);
107
111
  for (const t of spec.tags)
108
112
  node.tag(t);
109
113
  const promise = currentNode.run(node, () => node.run(spec.body, spec.args));
@@ -185,8 +189,9 @@ export class ExecutionTree {
185
189
  process.on("uncaughtException", this.onUncaught);
186
190
  process.on("unhandledRejection", this.onUnhandled);
187
191
  this.streaming = this.rawStream.run(this.store);
188
- // Live, curated milestones to the terminal, through the pre-patch writer.
189
- this.liveRendering = new LiveRenderer(this.originalLog).run(this.store);
192
+ // Live, curated milestones to the terminal — a single in-place status line on a TTY.
193
+ // Straight to process.stdout, which patch-console doesn't intercept (no capture feedback).
194
+ this.liveRendering = new LiveRenderer(process.stdout).run(this.store);
190
195
  const rootPromise = currentNode.run(this.root, () => this.root.run(body, []));
191
196
  this.trackNode(rootPromise);
192
197
  await rootPromise;
@@ -272,6 +277,7 @@ export class ExecutionTree {
272
277
  id: node.id,
273
278
  uuid: node.uuid,
274
279
  name: node.name,
280
+ definedAt: node.definedAt,
275
281
  path: node.path,
276
282
  parentId: node.parentId,
277
283
  tags: [...node.tags],
@@ -1,5 +1,6 @@
1
1
  import { ExecutionTree } from "./ExecutionTree.js";
2
2
  import { FolderCache } from "../system/fs/FolderCache.js";
3
+ import { callerSite } from "../foundation/callsite.js";
3
4
  /**
4
5
  * A constructed-but-not-yet-run action. Inert: it only holds the name, body, args,
5
6
  * and any tags buffered before launch. `exec()` hands it to the tree, which creates
@@ -9,11 +10,13 @@ class Instance {
9
10
  name;
10
11
  body;
11
12
  args;
13
+ definedAt;
12
14
  tags = [];
13
- constructor(name, body, args) {
15
+ constructor(name, body, args, definedAt) {
14
16
  this.name = name;
15
17
  this.body = body;
16
18
  this.args = args;
19
+ this.definedAt = definedAt;
17
20
  }
18
21
  tag(name) {
19
22
  this.tags.push(name);
@@ -37,7 +40,7 @@ class Instance {
37
40
  await FolderCache.write(targets, fp);
38
41
  return result;
39
42
  });
40
- const cached = new Instance(this.name, body, innerArgs);
43
+ const cached = new Instance(this.name, body, innerArgs, this.definedAt);
41
44
  cached.tags.push(...this.tags);
42
45
  return cached;
43
46
  }
@@ -59,8 +62,9 @@ class Builder {
59
62
  }
60
63
  run(body) {
61
64
  const name = this.name;
62
- const create = (...args) => new Instance(name, body, args);
63
- return Object.assign(create, { actionName: name });
65
+ const definedAt = callerSite(); // the `.run(...)` call site — where this action lives
66
+ const create = (...args) => new Instance(name, body, args, definedAt);
67
+ return Object.assign(create, { actionName: name, definedAt });
64
68
  }
65
69
  }
66
70
  /**
package/dist/core/om.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ExecutionTree } from "./ExecutionTree.js";
2
+ import { callerSite } from "../foundation/callsite.js";
2
3
  /**
3
4
  * Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
4
5
  * body runs inside the root node's ambient scope, so any `instance.exec()` /
@@ -10,7 +11,7 @@ export function om(body) {
10
11
  if (ExecutionTree.current) {
11
12
  throw new Error("an om() run is already active in this process");
12
13
  }
13
- const tree = new ExecutionTree();
14
+ const tree = new ExecutionTree(callerSite()); // where om() was called — the run's script
14
15
  ExecutionTree.current = tree;
15
16
  const rootBody = (ctx) => body({
16
17
  signal: ctx.signal,
@@ -110,6 +110,8 @@ export type AnyActionInstance = ActionInstance<unknown, object, unknown>;
110
110
  export interface Action<Args extends unknown[], Result, Events extends object = NoEvents, Handle = void> {
111
111
  (...args: Args): ActionInstance<Result, Events, Handle>;
112
112
  readonly actionName: string;
113
+ /** `file:line` where this action was defined (the `.run(...)` site), for the log header. */
114
+ readonly definedAt: string | undefined;
113
115
  }
114
116
  /** Intermediate step from `action(name)`: declare events/handle, then provide the impl. */
115
117
  export interface ActionBuilderEvents<Events extends object, Handle = void> {
@@ -125,4 +127,6 @@ export interface LaunchSpec {
125
127
  readonly args: readonly unknown[];
126
128
  readonly tags: readonly string[];
127
129
  readonly body: Exec<object, unknown, unknown>;
130
+ /** `file:line` where the action was defined; carried onto the node for its log header. */
131
+ readonly definedAt: string | undefined;
128
132
  }
@@ -16,3 +16,12 @@ export interface ActionRef {
16
16
  }
17
17
  /** Render an {@link ActionRef} for a header/reference line: `name · path · [a, b]`. */
18
18
  export declare const renderRef: (ref: ActionRef) => string;
19
+ /**
20
+ * A compact inline label — the action **name with its tags**, `name [a, b]` — used
21
+ * wherever an action is referenced within a timeline line (a bubbled child milestone,
22
+ * a live status line) so its tags are always visible right next to the name.
23
+ */
24
+ export declare const label: (ref: {
25
+ readonly name: string;
26
+ readonly tags: readonly string[];
27
+ }) => string;
@@ -3,3 +3,9 @@ export const renderRef = (ref) => {
3
3
  const tags = ref.tags.length ? ` · [${ref.tags.join(", ")}]` : "";
4
4
  return `${ref.name} · ${ref.path}${tags}`;
5
5
  };
6
+ /**
7
+ * A compact inline label — the action **name with its tags**, `name [a, b]` — used
8
+ * wherever an action is referenced within a timeline line (a bubbled child milestone,
9
+ * a live status line) so its tags are always visible right next to the name.
10
+ */
11
+ export const label = (ref) => ref.tags.length ? `${ref.name} [${ref.tags.join(", ")}]` : ref.name;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `file:line` of the site that *called* the function invoking `callerSite` — the first stack
3
+ * frame outside both this module and that caller's module. So `action().run(...)` records
4
+ * where `.run(...)` was written, and `om(...)` records where the run was launched from. At
5
+ * runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
6
+ */
7
+ export declare const callerSite: () => string | undefined;
@@ -0,0 +1,38 @@
1
+ import { fileURLToPath } from "node:url";
2
+ /** `{ file, line }` from a V8 stack frame — handles `at fn (path:line:col)` and `at path:line:col`. */
3
+ const parseFrame = (frame) => {
4
+ const paren = frame.match(/\(([^()]+)\)\s*$/);
5
+ const loc = (paren ? paren[1] : frame.replace(/^\s*at\s+/, "")).trim();
6
+ const m = loc.match(/^(.+):(\d+):\d+$/) ?? loc.match(/^(.+):(\d+)$/);
7
+ return m ? { file: m[1], line: m[2] } : undefined;
8
+ };
9
+ const nativePath = (file) => {
10
+ if (!file.startsWith("file://"))
11
+ return file;
12
+ try {
13
+ return fileURLToPath(file);
14
+ }
15
+ catch {
16
+ return file;
17
+ }
18
+ };
19
+ /**
20
+ * `file:line` of the site that *called* the function invoking `callerSite` — the first stack
21
+ * frame outside both this module and that caller's module. So `action().run(...)` records
22
+ * where `.run(...)` was written, and `om(...)` records where the run was launched from. At
23
+ * runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
24
+ */
25
+ export const callerSite = () => {
26
+ const stack = new Error().stack;
27
+ if (!stack)
28
+ return undefined;
29
+ const frames = stack.split("\n").slice(1);
30
+ const self = parseFrame(frames[0] ?? "")?.file; // this module (callsite)
31
+ const caller = parseFrame(frames[1] ?? "")?.file; // the module that called us
32
+ for (const frame of frames) {
33
+ const at = parseFrame(frame);
34
+ if (at && at.file !== self && at.file !== caller)
35
+ return `${nativePath(at.file)}:${at.line}`;
36
+ }
37
+ return undefined;
38
+ };
@@ -1,13 +1,29 @@
1
1
  import type { LogStore } from "./log/LogStore.ts";
2
+ /** The terminal surface the renderer writes to (process.stdout satisfies it). */
3
+ export interface LiveTerminal {
4
+ write(text: string): unknown;
5
+ readonly isTTY?: boolean;
6
+ readonly columns?: number;
7
+ }
2
8
  /**
3
- * Renders a **curated** stream of milestones to the terminal, live — action
4
- * launches, completions, and errors, plus events, asserts, and snapshots. The bulk
5
- * output (console lines, proc stdout/stderr) is intentionally *not* shown live; it
6
- * lives in the per-action `.log` files. Writes through the pre-patch terminal
7
- * writer (so it can't feed back into console capture).
9
+ * Renders a **curated** stream of milestones to the terminal, live — action launches,
10
+ * completions, and errors, plus events, asserts, and snapshots. The bulk output (console
11
+ * lines, proc stdout/stderr) is intentionally *not* shown live; it lives in the per-action
12
+ * `.log` files.
13
+ *
14
+ * On a TTY it keeps a **single status line**, rewriting it in place as milestones arrive,
15
+ * so the terminal stays quiet — except lines worth keeping (errors, failures, run
16
+ * milestones), which are *committed* (printed with a newline) so they persist above the
17
+ * live line. Piped/redirected output (no TTY) falls back to appending every line, so logs
18
+ * stay readable. Writes through `process.stdout` directly, which `patch-console` doesn't
19
+ * intercept, so it can't feed back into console capture.
8
20
  */
9
21
  export declare class LiveRenderer {
10
- private readonly write;
11
- constructor(write: (line: string) => void);
22
+ private readonly term;
23
+ /** True while a transient status line is on screen (needs clearing or a closing newline). */
24
+ private pending;
25
+ /** Tags accumulated per node from `tag` entries, so milestones can show them by the name. */
26
+ private readonly tagsByNode;
27
+ constructor(term: LiveTerminal);
12
28
  run(store: LogStore): Promise<void>;
13
29
  }
@@ -1,56 +1,101 @@
1
+ /** Return to column 0 and clear the whole line — the in-place status-line reset. */
2
+ const CLEAR = "\r\x1b[2K";
1
3
  /**
2
- * Renders a **curated** stream of milestones to the terminal, live — action
3
- * launches, completions, and errors, plus events, asserts, and snapshots. The bulk
4
- * output (console lines, proc stdout/stderr) is intentionally *not* shown live; it
5
- * lives in the per-action `.log` files. Writes through the pre-patch terminal
6
- * writer (so it can't feed back into console capture).
4
+ * Renders a **curated** stream of milestones to the terminal, live — action launches,
5
+ * completions, and errors, plus events, asserts, and snapshots. The bulk output (console
6
+ * lines, proc stdout/stderr) is intentionally *not* shown live; it lives in the per-action
7
+ * `.log` files.
8
+ *
9
+ * On a TTY it keeps a **single status line**, rewriting it in place as milestones arrive,
10
+ * so the terminal stays quiet — except lines worth keeping (errors, failures, run
11
+ * milestones), which are *committed* (printed with a newline) so they persist above the
12
+ * live line. Piped/redirected output (no TTY) falls back to appending every line, so logs
13
+ * stay readable. Writes through `process.stdout` directly, which `patch-console` doesn't
14
+ * intercept, so it can't feed back into console capture.
7
15
  */
8
16
  export class LiveRenderer {
9
- write;
10
- constructor(write) {
11
- this.write = write;
17
+ term;
18
+ /** True while a transient status line is on screen (needs clearing or a closing newline). */
19
+ pending = false;
20
+ /** Tags accumulated per node from `tag` entries, so milestones can show them by the name. */
21
+ tagsByNode = new Map();
22
+ constructor(term) {
23
+ this.term = term;
12
24
  }
13
25
  async run(store) {
26
+ const tty = Boolean(this.term.isTTY);
14
27
  for await (const entry of store.subscribe({ replay: true })) {
15
- const line = format(entry);
16
- if (line !== null)
17
- this.write(line);
28
+ if (entry.level === "tag" && entry.source === "tag") {
29
+ const list = this.tagsByNode.get(entry.nodeId) ?? [];
30
+ list.push(entry.message);
31
+ this.tagsByNode.set(entry.nodeId, list);
32
+ continue; // tags aren't their own line — they decorate the node's milestones
33
+ }
34
+ const rendered = format(entry, this.tagsByNode.get(entry.nodeId));
35
+ if (rendered === null)
36
+ continue;
37
+ if (!tty) {
38
+ this.term.write(`${rendered.text}\n`); // no TTY: append, one line each
39
+ continue;
40
+ }
41
+ if (rendered.persist) {
42
+ // Commit a line that must survive: finish the transient line, then print permanently.
43
+ this.term.write(`${this.pending ? CLEAR : ""}${rendered.text}\n`);
44
+ this.pending = false;
45
+ }
46
+ else {
47
+ // Transient: overwrite the current status line in place, truncated to fit.
48
+ this.term.write(`${CLEAR}${truncate(rendered.text, this.term.columns)}`);
49
+ this.pending = true;
50
+ }
51
+ }
52
+ // Keep the last status line on screen for the summary that follows.
53
+ if (this.pending) {
54
+ this.term.write("\n");
55
+ this.pending = false;
18
56
  }
19
57
  }
20
58
  }
21
59
  const firstLine = (message) => message.split("\n", 1)[0] ?? message;
22
60
  /** ✓ ok · ⊘ cancelled (a clean stop) · ✗ failed. */
23
61
  const doneIcon = (status) => status === "ok" ? "✓" : status === "cancelled" ? "⊘" : "✗";
24
- /** Map a curated entry to a terminal line, or `null` to drop it from the live view. */
25
- const format = (e) => {
62
+ /** Truncate to the terminal width so an in-place line never wraps and breaks the `\r`. */
63
+ const truncate = (text, columns) => !columns || text.length < columns ? text : `${text.slice(0, Math.max(0, columns - 1))}…`;
64
+ /** The node's path with its accumulated tags appended: `main/probe_9f3c [ready, gate]`. */
65
+ const named = (path, tags) => tags && tags.length ? `${path} [${tags.join(", ")}]` : path;
66
+ /** Map a curated entry to a terminal line (and whether it persists), or `null` to drop it. */
67
+ const format = (e, tags) => {
26
68
  // Action launch — bubbled onto the parent as `→ <childId> · <log>`.
27
69
  if (e.level === "child" && e.source === "launch") {
28
70
  const child = e.message.replace(/^→\s*/, "").split(" · ")[0];
29
- return `▶ ${e.path}/${child}`;
71
+ return { text: `▶ ${e.path}/${child}`, persist: false };
30
72
  }
31
- // Lifecycle events: `launched` is already shown via the launch pointer above, so
32
- // drop the duplicate; `done · <status>` renders with the status icon.
73
+ // Lifecycle events: `launched` is already shown via the launch pointer above, so drop
74
+ // the duplicate; `done · <status>` renders with the status icon — a failure persists.
33
75
  if (e.level === "event" && e.source === "lifecycle") {
34
76
  if (e.message === "launched")
35
77
  return null;
36
78
  const status = e.message.replace(/^done · /, "");
37
- return `${doneIcon(status)} ${e.path} · ${status}`;
79
+ return { text: `${doneIcon(status)} ${named(e.path, tags)} · ${status}`, persist: status === "failed" };
38
80
  }
39
- // Run milestones (started / tearing down / finished).
81
+ // Run milestones (started / tearing down / finished) — few, and worth keeping.
40
82
  if (e.level === "run")
41
- return `· ${e.message}`;
42
- // An action's *own* failure — not proc stderr (source = proc name), console.error,
43
- // or a teardown cancellation (which is a clean stop, not an error).
83
+ return { text: `· ${e.message}`, persist: true };
84
+ // An action's *own* failure — not proc stderr, console.error, or a teardown cancellation.
44
85
  if (e.level === "error" && e.source === "error") {
45
- return `✗ ${e.path} · ${firstLine(e.message)}`;
86
+ return { text: `✗ ${named(e.path, tags)} · ${firstLine(e.message)}`, persist: true };
46
87
  }
88
+ // A cancellation milestone (⊘) — persist it so it doesn't flash away during teardown.
89
+ if (e.level === "event" && e.source === "cancel")
90
+ return { text: `⊘ ${named(e.path, tags)} · ${e.message}`, persist: true };
47
91
  // User-emitted events.
48
92
  if (e.level === "event")
49
- return `⚡ ${e.path} · ${e.message}`;
93
+ return { text: `⚡ ${named(e.path, tags)} · ${e.message}`, persist: false };
94
+ // Asserts: a failed one (⊭) persists, a pass is transient.
50
95
  if (e.level === "assert")
51
- return ` ${e.path} · ${e.message}`;
96
+ return { text: ` ${named(e.path, tags)} · ${e.message}`, persist: e.message.includes("⊭") };
52
97
  if (e.level === "snapshot")
53
- return `📸 ${e.path} · ${e.message}`;
98
+ return { text: `📸 ${named(e.path, tags)} · ${e.message}`, persist: false };
54
99
  // Everything else (info / console / proc output / tag / bubbled child events) → files only.
55
100
  return null;
56
101
  };
@@ -9,22 +9,25 @@ import { entryLine, groupByNode } from "./entryLine.js";
9
9
  */
10
10
  export async function writeNodeLogs(nodes, entries) {
11
11
  const byNode = groupByNode(entries, () => true);
12
+ const refByNode = new Map(nodes.map((n) => [n.id, n]));
13
+ const refOf = (id) => refByNode.get(id);
12
14
  await Promise.all(nodes.map(async (node) => {
13
- const lines = (byNode.get(node.id) ?? []).map(entryLine);
15
+ const lines = (byNode.get(node.id) ?? []).map((e) => entryLine(e, refOf));
14
16
  const body = [...header(node), "", ...lines, ""].join("\n");
15
17
  await mkdir(path.dirname(node.logFile), { recursive: true });
16
18
  await writeFile(node.logFile, body, "utf8");
17
19
  }));
18
20
  }
19
- /** The per-file metadata header: ref, args, start/finish times, duration, status. */
21
+ /** The per-file metadata header: ref, defining script, args, start/finish times, status. */
20
22
  const header = (node) => [
21
23
  `# ${renderRef(node)}`,
22
24
  `# log: ${node.logFile}`,
25
+ node.definedAt ? `# defined: ${node.definedAt}` : null,
23
26
  `# args: ${renderArgs(node.args)}`,
24
27
  `# started: ${stamp(node.startedAt)}`,
25
28
  `# finished: ${stamp(node.endedAt)} (${node.duration}ms)`,
26
29
  `# status: ${node.status}`,
27
- ];
30
+ ].filter((line) => line !== null);
28
31
  const stamp = (ms) => (ms ? new Date(ms).toISOString() : "-");
29
32
  const renderArgs = (args) => {
30
33
  try {
@@ -9,6 +9,7 @@ import { entryLine } from "./entryLine.js";
9
9
  */
10
10
  export async function writeRollup(file, nodes, entries, match, footer) {
11
11
  const refByNode = new Map(nodes.map((n) => [n.id, n]));
12
+ const refOf = (id) => refByNode.get(id);
12
13
  const chunks = [];
13
14
  let lastNode = null;
14
15
  for (const e of entries) {
@@ -21,7 +22,7 @@ export async function writeRollup(file, nodes, entries, match, footer) {
21
22
  chunks.push(`# ${ref ? renderRef(ref) : e.path}`);
22
23
  lastNode = e.nodeId;
23
24
  }
24
- chunks.push(entryLine(e));
25
+ chunks.push(entryLine(e, refOf));
25
26
  }
26
27
  if (footer)
27
28
  chunks.push("", footer);
@@ -1,10 +1,14 @@
1
+ import { type ActionRef } from "../../foundation/ActionRef.ts";
1
2
  import type { LogEntry } from "../../foundation/LogEntry.ts";
3
+ /** Resolve a node's reference (name + tags) by id, for naming a bubbled child. */
4
+ export type RefOf = (nodeId: string) => ActionRef | undefined;
2
5
  /**
3
6
  * The shared human-readable row: `[<seq>] <body>`. Only the run-global sequence
4
7
  * prefixes the line (date/path live in the file header + `raw.jsonl`). Event,
5
- * snapshot, and assert lines carry a leading icon; everything else keeps its
6
- * `source` (proc name, child id, `console`, …).
8
+ * snapshot, and assert lines carry a leading icon; a **bubbled child milestone** names
9
+ * the child by its {@link label} (name + tags) via `refOf`; everything else keeps its
10
+ * `source` (proc name, `console`, …).
7
11
  */
8
- export declare const entryLine: (e: LogEntry) => string;
12
+ export declare const entryLine: (e: LogEntry, refOf?: RefOf) => string;
9
13
  /** Group entries by `nodeId` (preserving order), keeping only those that `match`. */
10
14
  export declare const groupByNode: (entries: readonly LogEntry[], match: (e: LogEntry) => boolean) => Map<string, LogEntry[]>;
@@ -1,14 +1,17 @@
1
+ import { label } from "../../foundation/ActionRef.js";
1
2
  /**
2
3
  * The shared human-readable row: `[<seq>] <body>`. Only the run-global sequence
3
4
  * prefixes the line (date/path live in the file header + `raw.jsonl`). Event,
4
- * snapshot, and assert lines carry a leading icon; everything else keeps its
5
- * `source` (proc name, child id, `console`, …).
5
+ * snapshot, and assert lines carry a leading icon; a **bubbled child milestone** names
6
+ * the child by its {@link label} (name + tags) via `refOf`; everything else keeps its
7
+ * `source` (proc name, `console`, …).
6
8
  */
7
- export const entryLine = (e) => `[${e.sequence}] ${bodyOf(e)}`;
8
- const bodyOf = (e) => {
9
+ export const entryLine = (e, refOf) => `[${e.sequence}] ${bodyOf(e, refOf)}`;
10
+ const bodyOf = (e, refOf) => {
9
11
  switch (e.level) {
10
12
  case "event":
11
- return `⚡ ${e.message}`;
13
+ // A cancellation milestone reads ⊘; other emitted events keep ⚡.
14
+ return e.source === "cancel" ? `⊘ ${e.message}` : `⚡ ${e.message}`;
12
15
  case "snapshot":
13
16
  return `📸 ${e.message}`;
14
17
  case "assert":
@@ -20,7 +23,16 @@ const bodyOf = (e) => {
20
23
  // An action's *own* failure keeps a ✗; proc stderr / console.error is plain
21
24
  // output. (Cancellation no longer logs at this level — it's a clean stop.)
22
25
  return e.source === "error" ? `✗ ${e.message}` : `▪ ${e.message}`;
23
- // run/done milestones, tags, and bubbled child lines keep their `source` label.
26
+ case "child": {
27
+ // A bubbled child milestone carries the child's id as `source`: name it with its
28
+ // tags (`name [tags]`) so the parent timeline always shows them. The launch
29
+ // pointer (`source === "launch"`) keeps its `→ id · logfile` message as-is.
30
+ if (e.source === "launch")
31
+ return `${e.source} · ${e.message}`;
32
+ const ref = refOf?.(e.source);
33
+ return `${ref ? label(ref) : e.source} · ${e.message}`;
34
+ }
35
+ // run/done milestones, tags, … keep their `source` label.
24
36
  default:
25
37
  return `${e.source} · ${e.message}`;
26
38
  }
@@ -7,6 +7,8 @@ export interface NodeView {
7
7
  readonly id: string;
8
8
  readonly uuid: string;
9
9
  readonly name: string;
10
+ /** `file:line` where the action was defined (the `.run(...)` site); absent for the root. */
11
+ readonly definedAt?: string;
10
12
  readonly path: string;
11
13
  readonly parentId: string | null;
12
14
  readonly tags: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",