omkit 0.0.8 → 0.0.10

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.
Files changed (72) hide show
  1. package/dist/actions/chrome-page.d.ts +1 -1
  2. package/dist/actions/chrome-page.js +1 -1
  3. package/dist/actions/command.d.ts +1 -1
  4. package/dist/actions/command.js +5 -6
  5. package/dist/actions/healthcheck.d.ts +1 -1
  6. package/dist/actions/healthcheck.js +1 -1
  7. package/dist/actions/prompt.d.ts +1 -1
  8. package/dist/actions/prompt.js +1 -1
  9. package/dist/actions/tail-log.d.ts +1 -1
  10. package/dist/actions/tail-log.js +1 -1
  11. package/dist/actions/until-log.d.ts +2 -2
  12. package/dist/actions/until-log.js +1 -1
  13. package/dist/actions/watch-dir.d.ts +1 -1
  14. package/dist/actions/watch-dir.js +1 -1
  15. package/dist/actions/watch.d.ts +1 -1
  16. package/dist/actions/watch.js +1 -1
  17. package/dist/index.d.ts +13 -17
  18. package/dist/index.js +15 -17
  19. package/dist/orchestration/action/action.d.ts +23 -0
  20. package/dist/orchestration/action/action.js +254 -0
  21. package/dist/{core/action.d.ts → orchestration/action/types.d.ts} +37 -26
  22. package/dist/orchestration/action/types.js +1 -0
  23. package/dist/{core → orchestration/events}/events.d.ts +9 -1
  24. package/dist/{core → orchestration/events}/events.js +14 -22
  25. package/dist/orchestration/spin/SpinHost.d.ts +105 -0
  26. package/dist/orchestration/spin/SpinHost.js +303 -0
  27. package/dist/orchestration/spin/spin.d.ts +24 -0
  28. package/dist/orchestration/spin/spin.js +56 -0
  29. package/dist/orchestration/spin/types.d.ts +56 -0
  30. package/dist/orchestration/spin/types.js +1 -0
  31. package/dist/output/Output.d.ts +24 -0
  32. package/dist/output/Output.js +27 -0
  33. package/dist/output/folder/RunFolder.d.ts +29 -0
  34. package/dist/output/folder/RunFolder.js +56 -0
  35. package/dist/output/index.d.ts +11 -0
  36. package/dist/output/index.js +11 -0
  37. package/dist/output/log/LogRenderer.d.ts +73 -0
  38. package/dist/output/log/LogRenderer.js +131 -0
  39. package/dist/{core/log-collector → output/log}/LogsCollector.d.ts +21 -5
  40. package/dist/{core/log-collector → output/log}/LogsCollector.js +41 -10
  41. package/dist/output/log/RunLog.d.ts +44 -0
  42. package/dist/output/log/RunLog.js +119 -0
  43. package/dist/output/snapshot/SnapshotStore.d.ts +49 -0
  44. package/dist/output/snapshot/SnapshotStore.js +61 -0
  45. package/dist/system/fs/FolderCache.d.ts +47 -0
  46. package/dist/system/fs/FolderCache.js +117 -0
  47. package/dist/system/fs/streams.d.ts +11 -0
  48. package/dist/system/fs/streams.js +14 -0
  49. package/dist/{core → system/process}/process.d.ts +9 -8
  50. package/dist/{core → system/process}/process.js +8 -9
  51. package/dist/utils/format.d.ts +9 -0
  52. package/dist/utils/format.js +9 -0
  53. package/dist/utils/serialize.d.ts +2 -0
  54. package/dist/utils/serialize.js +12 -0
  55. package/package.json +1 -1
  56. package/dist/core/action.js +0 -188
  57. package/dist/core/folder-cache.d.ts +0 -39
  58. package/dist/core/folder-cache.js +0 -109
  59. package/dist/core/log-collector/global.d.ts +0 -7
  60. package/dist/core/log-collector/global.js +0 -7
  61. package/dist/core/log-collector/render.d.ts +0 -57
  62. package/dist/core/log-collector/render.js +0 -111
  63. package/dist/core/markers.d.ts +0 -9
  64. package/dist/core/markers.js +0 -13
  65. package/dist/core/output.d.ts +0 -64
  66. package/dist/core/output.js +0 -190
  67. package/dist/core/run.d.ts +0 -88
  68. package/dist/core/run.js +0 -240
  69. package/dist/core/spin.d.ts +0 -72
  70. package/dist/core/spin.js +0 -36
  71. /package/dist/{core/helpers → utils}/AsyncQueue.d.ts +0 -0
  72. /package/dist/{core/helpers → utils}/AsyncQueue.js +0 -0
@@ -0,0 +1,49 @@
1
+ import type { Logger } from "../log/LogsCollector.ts";
2
+ import type { RunFolder } from "../folder/RunFolder.ts";
3
+ /** A written (or in-flight) snapshot file and where to find it. */
4
+ export interface SnapshotRef {
5
+ /** Absolute path to the snapshot file. */
6
+ readonly file: string;
7
+ /** Path relative to cwd — what a log line references. */
8
+ readonly rel: string;
9
+ /** Resolves once the file is flushed to disk. */
10
+ readonly written: Promise<void>;
11
+ }
12
+ /**
13
+ * Writes sequenced snapshot files into the run folder and (for {@link snapshot})
14
+ * drops a `[snapshot]` pointer line into the timeline.
15
+ *
16
+ * Sequence numbers make repeated snapshots of the same name a history rather than an
17
+ * overwrite. Paths are computed synchronously so a caller can reference the file in
18
+ * the same tick while the write proceeds in the background. Depends on a
19
+ * {@link RunFolder} (where the files land) and a {@link Logger} (only `snapshot`
20
+ * writes a timeline line).
21
+ */
22
+ export declare class SnapshotStore {
23
+ private seq;
24
+ private readonly folder;
25
+ private readonly log;
26
+ constructor(folder: RunFolder, log: Logger);
27
+ /**
28
+ * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) and returns its
29
+ * path — **without** logging a pointer line. The path is computed synchronously while
30
+ * the write proceeds in the background. Use {@link snapshot} when you also want a
31
+ * `[snapshot]` line in the timeline; the event bus uses this directly to link a
32
+ * non-string payload from its own line.
33
+ */
34
+ captureJson(name: string, value: unknown): SnapshotRef;
35
+ /**
36
+ * Writes raw text `lines` as a sequenced `.log` snapshot file — the text sibling of
37
+ * {@link captureJson}. {@link RunLog.write} uses it to off-load a collapsed output run
38
+ * from `run.log`, keeping the burst's full text one click away.
39
+ */
40
+ captureText(name: string, lines: readonly string[]): SnapshotRef;
41
+ /**
42
+ * Captures a value as a JSON snapshot (via {@link captureJson}) and records a
43
+ * `[snapshot] <name>: <path>` line in the timeline at the point it's taken. Callable
44
+ * any time, anywhere in the run; resolves with the file path once written.
45
+ */
46
+ snapshot(name: string, value: unknown): Promise<string>;
47
+ /** Shared writer: sequence + sanitize the name, write in the background, return its refs. */
48
+ private capture;
49
+ }
@@ -0,0 +1,61 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pad } from "../../utils/format.js";
4
+ import { serialize } from "../../utils/serialize.js";
5
+ /**
6
+ * Writes sequenced snapshot files into the run folder and (for {@link snapshot})
7
+ * drops a `[snapshot]` pointer line into the timeline.
8
+ *
9
+ * Sequence numbers make repeated snapshots of the same name a history rather than an
10
+ * overwrite. Paths are computed synchronously so a caller can reference the file in
11
+ * the same tick while the write proceeds in the background. Depends on a
12
+ * {@link RunFolder} (where the files land) and a {@link Logger} (only `snapshot`
13
+ * writes a timeline line).
14
+ */
15
+ export class SnapshotStore {
16
+ seq = 0;
17
+ folder;
18
+ log;
19
+ constructor(folder, log) {
20
+ this.folder = folder;
21
+ this.log = log;
22
+ }
23
+ /**
24
+ * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) and returns its
25
+ * path — **without** logging a pointer line. The path is computed synchronously while
26
+ * the write proceeds in the background. Use {@link snapshot} when you also want a
27
+ * `[snapshot]` line in the timeline; the event bus uses this directly to link a
28
+ * non-string payload from its own line.
29
+ */
30
+ captureJson(name, value) {
31
+ return this.capture(name, "json", `${serialize(value)}\n`);
32
+ }
33
+ /**
34
+ * Writes raw text `lines` as a sequenced `.log` snapshot file — the text sibling of
35
+ * {@link captureJson}. {@link RunLog.write} uses it to off-load a collapsed output run
36
+ * from `run.log`, keeping the burst's full text one click away.
37
+ */
38
+ captureText(name, lines) {
39
+ return this.capture(name, "log", lines.length ? `${lines.join("\n")}\n` : "");
40
+ }
41
+ /**
42
+ * Captures a value as a JSON snapshot (via {@link captureJson}) and records a
43
+ * `[snapshot] <name>: <path>` line in the timeline at the point it's taken. Callable
44
+ * any time, anywhere in the run; resolves with the file path once written.
45
+ */
46
+ snapshot(name, value) {
47
+ const { file, rel, written } = this.captureJson(name, value);
48
+ // Record the pointer synchronously, so it sits at the right spot in the timeline.
49
+ this.log.append({ source: "snapshot", level: "snapshot", message: `${name}: ${rel}` });
50
+ return written.then(() => file);
51
+ }
52
+ /** Shared writer: sequence + sanitize the name, write in the background, return its refs. */
53
+ capture(name, ext, body) {
54
+ const safe = name.replace(/[^\w.-]+/g, "-");
55
+ const file = this.folder.resolve(`${safe}-${pad(++this.seq)}.${ext}`);
56
+ const written = this.folder.ensure().then(() => writeFile(file, body, "utf8"));
57
+ // Normalize to forward slashes so the reference is portable in the log artifact.
58
+ const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
59
+ return { file, rel, written };
60
+ }
61
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * An input-fingerprint cache: hash the mtime+size of one or more **input**
3
+ * targets (the files/folders an action reads — *not* what it produces), so an
4
+ * expensive action can be skipped when its inputs are unchanged since its last
5
+ * successful run. Backs the `.withCache(...)` method on action instances.
6
+ *
7
+ * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
8
+ * one JSON file per set of inputs, named by a hash of their absolute paths.
9
+ *
10
+ * Stateless — the cache is the filesystem, so the methods are `static`. Internal
11
+ * cross-references go through the class name (not `this`), so a caller may pull a
12
+ * method off the class (`const fp = FolderCache.fingerprint`) without losing it.
13
+ */
14
+ export declare class FolderCache {
15
+ /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
16
+ static readonly dir: string;
17
+ /**
18
+ * Require every path to be **absolute** (a relative path is a developer error —
19
+ * throws, naming it) and return them canonicalized with `resolve`, so different
20
+ * spellings of the same target (`.`/`..`/trailing slash) collapse to one stable
21
+ * fingerprint prefix and cache key. `resolve` on an already-absolute path only
22
+ * normalizes — it never falls back to the cwd. Each target may be a file or a
23
+ * folder (see {@link FolderCache.fingerprint}).
24
+ */
25
+ static resolvePaths(paths: string[]): string[];
26
+ /**
27
+ * Fingerprints one or more **input** targets by mtime+size. Each target may be
28
+ * a **file** (fingerprinted directly) or a **folder** (walked recursively, its
29
+ * files fingerprinted). Every path must be absolute (throws otherwise). A
30
+ * missing target contributes nothing — so an input that doesn't exist yet is
31
+ * naturally a miss, not an error. The entry list is sorted for determinism
32
+ * before hashing.
33
+ */
34
+ static fingerprint(paths: string[]): Promise<string>;
35
+ /**
36
+ * Reads the fingerprint stored for these targets, or `undefined` when there's
37
+ * no cache (absent, unreadable, or malformed — all treated as a miss).
38
+ */
39
+ static read(paths: string[]): Promise<string | undefined>;
40
+ /**
41
+ * Records the fingerprint for these targets after a successful run. Writes
42
+ * atomically (temp file + rename) so a concurrent read never sees a half-file.
43
+ */
44
+ static write(paths: string[], fp: string): Promise<void>;
45
+ /** The on-disk filename for a set of targets — a hash of their absolute paths. */
46
+ private static entryPath;
47
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ /**
5
+ * An input-fingerprint cache: hash the mtime+size of one or more **input**
6
+ * targets (the files/folders an action reads — *not* what it produces), so an
7
+ * expensive action can be skipped when its inputs are unchanged since its last
8
+ * successful run. Backs the `.withCache(...)` method on action instances.
9
+ *
10
+ * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
11
+ * one JSON file per set of inputs, named by a hash of their absolute paths.
12
+ *
13
+ * Stateless — the cache is the filesystem, so the methods are `static`. Internal
14
+ * cross-references go through the class name (not `this`), so a caller may pull a
15
+ * method off the class (`const fp = FolderCache.fingerprint`) without losing it.
16
+ */
17
+ export class FolderCache {
18
+ /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
19
+ static dir = join(process.cwd(), "node_modules", ".cache", "omkit");
20
+ /**
21
+ * Require every path to be **absolute** (a relative path is a developer error —
22
+ * throws, naming it) and return them canonicalized with `resolve`, so different
23
+ * spellings of the same target (`.`/`..`/trailing slash) collapse to one stable
24
+ * fingerprint prefix and cache key. `resolve` on an already-absolute path only
25
+ * normalizes — it never falls back to the cwd. Each target may be a file or a
26
+ * folder (see {@link FolderCache.fingerprint}).
27
+ */
28
+ static resolvePaths(paths) {
29
+ return paths.map((p) => {
30
+ if (!isAbsolute(p)) {
31
+ throw new Error(`withCache: path must be absolute, got "${p}"`);
32
+ }
33
+ return resolve(p);
34
+ });
35
+ }
36
+ /**
37
+ * Fingerprints one or more **input** targets by mtime+size. Each target may be
38
+ * a **file** (fingerprinted directly) or a **folder** (walked recursively, its
39
+ * files fingerprinted). Every path must be absolute (throws otherwise). A
40
+ * missing target contributes nothing — so an input that doesn't exist yet is
41
+ * naturally a miss, not an error. The entry list is sorted for determinism
42
+ * before hashing.
43
+ */
44
+ static async fingerprint(paths) {
45
+ const targets = FolderCache.resolvePaths(paths);
46
+ const entries = [];
47
+ const record = (target, rel, mtimeMs, size) => {
48
+ entries.push(`${target}\0${rel}\0${mtimeMs}\0${size}`);
49
+ };
50
+ for (const target of targets) {
51
+ let root;
52
+ try {
53
+ root = await stat(target);
54
+ }
55
+ catch {
56
+ continue; // missing/unreadable — contributes no entries
57
+ }
58
+ // A file target fingerprints itself (empty rel — there's no walk).
59
+ if (root.isFile()) {
60
+ record(target, "", root.mtimeMs, root.size);
61
+ continue;
62
+ }
63
+ // A folder target: walk it and fingerprint every file inside.
64
+ let rels;
65
+ try {
66
+ rels = await readdir(target, { recursive: true });
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ for (const rel of rels) {
72
+ const abs = join(target, rel);
73
+ try {
74
+ const stats = await stat(abs);
75
+ if (stats.isFile())
76
+ record(target, rel, stats.mtimeMs, stats.size);
77
+ }
78
+ catch {
79
+ // entry vanished between readdir and stat — skip
80
+ }
81
+ }
82
+ }
83
+ entries.sort();
84
+ return createHash("sha1").update(entries.join("\n")).digest("hex");
85
+ }
86
+ /**
87
+ * Reads the fingerprint stored for these targets, or `undefined` when there's
88
+ * no cache (absent, unreadable, or malformed — all treated as a miss).
89
+ */
90
+ static async read(paths) {
91
+ try {
92
+ const raw = await readFile(FolderCache.entryPath(paths), "utf8");
93
+ const entry = JSON.parse(raw);
94
+ return typeof entry.fingerprint === "string" ? entry.fingerprint : undefined;
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ /**
101
+ * Records the fingerprint for these targets after a successful run. Writes
102
+ * atomically (temp file + rename) so a concurrent read never sees a half-file.
103
+ */
104
+ static async write(paths, fp) {
105
+ await mkdir(FolderCache.dir, { recursive: true });
106
+ const target = FolderCache.entryPath(paths);
107
+ const entry = { folders: paths, fingerprint: fp, ts: Date.now() };
108
+ const tmp = `${target}.${process.pid}.tmp`;
109
+ await writeFile(tmp, JSON.stringify(entry, null, 2));
110
+ await rename(tmp, target);
111
+ }
112
+ /** The on-disk filename for a set of targets — a hash of their absolute paths. */
113
+ static entryPath(paths) {
114
+ const key = createHash("sha1").update(FolderCache.resolvePaths(paths).sort().join("\0")).digest("hex");
115
+ return join(FolderCache.dir, `${key}.json`);
116
+ }
117
+ }
@@ -0,0 +1,11 @@
1
+ import type { createWriteStream } from "node:fs";
2
+ /**
3
+ * Flush and close a write stream, resolving once its file descriptor is released.
4
+ *
5
+ * Resolves on `close` *or* `error` — a stream error still frees the file, and a
6
+ * caller waiting to re-read or truncate that file must not hang because the write
7
+ * end failed. The one shared fs primitive behind both `run.jsonl` and `run.log`
8
+ * writers, where "the bytes are on disk and the fd is closed" is the exact barrier
9
+ * the finalize step waits on.
10
+ */
11
+ export declare const closeStream: (stream: ReturnType<typeof createWriteStream>) => Promise<void>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Flush and close a write stream, resolving once its file descriptor is released.
3
+ *
4
+ * Resolves on `close` *or* `error` — a stream error still frees the file, and a
5
+ * caller waiting to re-read or truncate that file must not hang because the write
6
+ * end failed. The one shared fs primitive behind both `run.jsonl` and `run.log`
7
+ * writers, where "the bytes are on disk and the fd is closed" is the exact barrier
8
+ * the finalize step waits on.
9
+ */
10
+ export const closeStream = (stream) => new Promise((resolve) => {
11
+ stream.on("close", () => resolve());
12
+ stream.on("error", () => resolve()); // a stream error still frees the file; don't hang
13
+ stream.end();
14
+ });
@@ -1,5 +1,6 @@
1
1
  import { type Options, type ProcessPromise } from "zx";
2
- import type { Logger } from "./log-collector/LogsCollector.ts";
2
+ import type { Logger } from "../../output/log/LogsCollector.ts";
3
+ import type { SnapshotStore } from "../../output/snapshot/SnapshotStore.ts";
3
4
  /**
4
5
  * Force-kill a process **and its whole tree, synchronously**.
5
6
  *
@@ -24,11 +25,11 @@ export declare const killTree: (pid: number) => void;
24
25
  */
25
26
  export type Proc = (name: string, opts?: Partial<Options>) => (pieces: TemplateStringsArray, ...args: unknown[]) => ProcessPromise;
26
27
  /**
27
- * Builds a {@link Proc} bound to a specific log sink and abort signal. The action
28
- * pipeline injects one per action as `ctx.proc`, wired to that action's logger
29
- * and the run's signal — so actions just describe the command, with no ambient
30
- * lookup and nothing threaded through. A custom source (e.g. a CDP connection)
31
- * follows the same shape: take the action's `logs`/`signal`, push into the log,
32
- * and hook teardown.
28
+ * Builds a {@link Proc} bound to a specific log sink, abort signal, and the run's
29
+ * snapshot store. The action pipeline injects one per action as `ctx.proc`, wired
30
+ * to that action's logger, the run's signal, and the run's `output.snapshots` — so
31
+ * actions just describe the command, with no ambient lookup and nothing threaded
32
+ * through. A custom source (e.g. a CDP connection) follows the same shape: take the
33
+ * action's `logs`/`signal`, push into the log, and hook teardown.
33
34
  */
34
- export declare const createProc: (logs: Logger, signal: AbortSignal) => Proc;
35
+ export declare const createProc: (logs: Logger, signal: AbortSignal, snapshots: SnapshotStore) => Proc;
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import os from "node:os";
3
3
  import { $, kill, usePowerShell } from "zx";
4
- import { captureSnapshot } from "./output.js";
5
4
  // zx defaults to bash, which isn't present on a stock Windows box.
6
5
  // Use Windows PowerShell there so zx spawns and quotes for the right shell.
7
6
  if (os.platform() === "win32")
@@ -32,14 +31,14 @@ export const killTree = (pid) => {
32
31
  }
33
32
  };
34
33
  /**
35
- * Builds a {@link Proc} bound to a specific log sink and abort signal. The action
36
- * pipeline injects one per action as `ctx.proc`, wired to that action's logger
37
- * and the run's signal — so actions just describe the command, with no ambient
38
- * lookup and nothing threaded through. A custom source (e.g. a CDP connection)
39
- * follows the same shape: take the action's `logs`/`signal`, push into the log,
40
- * and hook teardown.
34
+ * Builds a {@link Proc} bound to a specific log sink, abort signal, and the run's
35
+ * snapshot store. The action pipeline injects one per action as `ctx.proc`, wired
36
+ * to that action's logger, the run's signal, and the run's `output.snapshots` — so
37
+ * actions just describe the command, with no ambient lookup and nothing threaded
38
+ * through. A custom source (e.g. a CDP connection) follows the same shape: take the
39
+ * action's `logs`/`signal`, push into the log, and hook teardown.
41
40
  */
42
- export const createProc = (logs, signal) => (name, opts) => {
41
+ export const createProc = (logs, signal, snapshots) => (name, opts) => {
43
42
  // quiet: zx must not echo the child's output to our terminal — the collector
44
43
  // owns all output (otherwise every line prints twice: raw from zx, formatted
45
44
  // from the drain). Caller opts can still override.
@@ -64,7 +63,7 @@ export const createProc = (logs, signal) => (name, opts) => {
64
63
  // spread sits before `cwd` so this resolved value wins even when opts omits
65
64
  // it). This is the "where" that makes a launch line reproducible.
66
65
  const cwd = opts?.cwd ?? process.cwd();
67
- const snap = captureSnapshot(`proc-${name}-start`, { cmd: child.cmd, ...safeOpts, cwd });
66
+ const snap = snapshots.captureJson(`proc-${name}-start`, { cmd: child.cmd, ...safeOpts, cwd });
68
67
  logs.append({
69
68
  source: name,
70
69
  level: "event",
@@ -0,0 +1,9 @@
1
+ /** Zero-pad a number to `width` digits (e.g. `pad(7) → "07"`). */
2
+ export declare const pad: (n: number, width?: number) => string;
3
+ /** A date as `YYYY-MM-DD`. */
4
+ export declare const ymd: (d: Date) => string;
5
+ /**
6
+ * A clock time as `HH<sep>MM<sep>SS`. Default `:` for display; pass `-` for a
7
+ * filesystem-safe form (folder names can't contain colons on every platform).
8
+ */
9
+ export declare const hms: (d: Date, sep?: string) => string;
@@ -0,0 +1,9 @@
1
+ /** Zero-pad a number to `width` digits (e.g. `pad(7) → "07"`). */
2
+ export const pad = (n, width = 2) => String(n).padStart(width, "0");
3
+ /** A date as `YYYY-MM-DD`. */
4
+ export const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
5
+ /**
6
+ * A clock time as `HH<sep>MM<sep>SS`. Default `:` for display; pass `-` for a
7
+ * filesystem-safe form (folder names can't contain colons on every platform).
8
+ */
9
+ export const hms = (d, sep = ":") => `${pad(d.getHours())}${sep}${pad(d.getMinutes())}${sep}${pad(d.getSeconds())}`;
@@ -0,0 +1,2 @@
1
+ /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
2
+ export declare const serialize: (value: unknown) => string;
@@ -0,0 +1,12 @@
1
+ /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
2
+ export const serialize = (value) => {
3
+ try {
4
+ // JSON.stringify returns undefined for functions/undefined — fall back to a string.
5
+ return JSON.stringify(value, null, 2) ?? JSON.stringify(String(value));
6
+ }
7
+ catch {
8
+ // Circular or otherwise non-serializable: keep a best-effort representation
9
+ // rather than throwing and losing the value entirely.
10
+ return JSON.stringify(String(value));
11
+ }
12
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
@@ -1,188 +0,0 @@
1
- import { events } from "./events.js";
2
- import { createProc } from "./process.js";
3
- import { fingerprint, read, write, resolvePaths } from "./folder-cache.js";
4
- import { artifactsFolder } from "./output.js";
5
- /** A promise paired with its settlers, for the set-once handle. */
6
- const deferred = () => {
7
- let resolve;
8
- let reject;
9
- const promise = new Promise((res, rej) => {
10
- resolve = res;
11
- reject = rej;
12
- });
13
- return { promise, resolve, reject };
14
- };
15
- /** Duck-type an exit code off a thrown error (zx's ProcessOutput, command's exit error). */
16
- const exitCodeOf = (error) => {
17
- const code = error?.exitCode;
18
- return typeof code === "number" ? code : undefined;
19
- };
20
- /**
21
- * A promise for the next emit of `key`. It races the event against the
22
- * instance's terminal events: if the action errors first it rejects with that
23
- * error, and if it completes (`done`) without ever emitting `key` it rejects too
24
- * — so an awaiter can never hang. A retained snapshot resolves it immediately.
25
- */
26
- const awaitEvent = (emitter, name, key) => new Promise((resolve, reject) => {
27
- const offs = [];
28
- let settled = false;
29
- // First of [the event | error | done] to fire wins; the rest are unsubscribed.
30
- const settle = (act) => {
31
- if (settled)
32
- return;
33
- settled = true;
34
- for (const off of offs)
35
- off();
36
- act();
37
- };
38
- // listenOnce is keyed by E; the system keys (error/done) are always present
39
- // on an instance's event map, so reach them through a loosened view.
40
- const listen = emitter.listenOnce;
41
- offs.push(listen(key, (payload) => settle(() => resolve(payload))));
42
- if (key !== "error") {
43
- offs.push(listen("error", (err) => settle(() => reject(err))));
44
- }
45
- if (key !== "done") {
46
- offs.push(listen("done", () => settle(() => reject(new Error(`${name}: settled before "${String(key)}"`)))));
47
- }
48
- // A retained snapshot fires a listen synchronously during registration; if
49
- // that already settled us, drop any listeners registered afterwards.
50
- if (settled)
51
- for (const off of offs)
52
- off();
53
- });
54
- /** Assembles an {@link Action} from a name and its implementation. */
55
- const build = (name, exec) => {
56
- const create = (...args) => {
57
- // The emitter covers declared + system events; `ctx.emit` is narrowed to the
58
- // declared ones, while the framework emits done/error through the same bus.
59
- const emitter = events(name);
60
- const emit = emitter.emit;
61
- // Loosely typed for the framework's own emits (sidesteps EmitArgs for void results).
62
- const systemEmit = emitter.emit;
63
- // The handle lives on the instance as a set-once promise: `attach` resolves
64
- // it, `instance.ref` awaits it. A second attach is a no-op (promise settled).
65
- // If the action settles without ever attaching, `start` below settles it too
66
- // (resolve on success, reject on error) so `ref` can't hang.
67
- const handle = deferred();
68
- // Attaching is a one-time lifecycle moment: resolve `ref` and emit `attached`
69
- // (the *fact* the action became controllable — not the handle, which is just
70
- // functions). Guarded so a second attach neither re-emits nor re-resolves.
71
- let attached = false;
72
- const attach = (value) => {
73
- if (attached)
74
- return;
75
- attached = true;
76
- handle.resolve(value);
77
- systemEmit("attached");
78
- };
79
- // Most actions have a `void` handle and no one awaits `ref`; mark the promise
80
- // handled so a settle-time rejection on such an instance doesn't surface as an
81
- // unhandled rejection. Real awaiters of `ref` still receive the rejection.
82
- void handle.promise.catch(() => { });
83
- // The action's Outcome as a set-once promise. `start` resolves it (success or
84
- // failure); `instance.done` / `once("done")` read it. It never rejects.
85
- const settled = deferred();
86
- const instance = {
87
- name,
88
- args,
89
- ref: handle.promise,
90
- done: settled.promise,
91
- start: (system) =>
92
- // `Promise.resolve().then` normalizes a sync result and routes a sync
93
- // throw to the `error` path, so exec may be sync or async.
94
- Promise.resolve()
95
- .then(() => exec({
96
- ...system,
97
- emit,
98
- attach,
99
- proc: createProc(system.logs, system.signal),
100
- artifactsFolder: artifactsFolder(),
101
- }, ...args))
102
- .then((result) => {
103
- const outcome = { ok: true, value: result };
104
- settled.resolve(outcome);
105
- systemEmit("done", outcome);
106
- // Settle `ref` if the action finished without attaching, so awaiters
107
- // resolve (with `undefined` for the default void handle) rather than
108
- // hang. Idempotent — a no-op when a handle was already attached.
109
- handle.resolve(undefined);
110
- return outcome;
111
- }, (error) => {
112
- const exitCode = exitCodeOf(error);
113
- const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
114
- // `error` first, so a pending `once(<declaredEvent>)` rejects on it
115
- // (settled before its event) rather than resolving on `done`.
116
- systemEmit("error", error);
117
- settled.resolve(outcome);
118
- systemEmit("done", outcome);
119
- // The action failed, so its handle will never arrive — reject `ref`
120
- // with the same error instead of leaving awaiters hung. No-op if a
121
- // handle was already attached before the failure.
122
- handle.reject(error);
123
- // Resolve (never rethrow): failure is the Outcome, not a rejection.
124
- return outcome;
125
- }),
126
- on(key, handler) {
127
- emitter.listen(key, handler);
128
- },
129
- once(key) {
130
- // `done` always fires with the Outcome and never "fails" the await, so it
131
- // reads straight from the settled promise; other keys race via awaitEvent.
132
- return (key === "done"
133
- ? settled.promise
134
- : awaitEvent(emitter, name, key));
135
- },
136
- // Wrap this instance in a fresh one (reusing the engine for its own
137
- // emitter/ref/on/once) that fingerprints `paths` and either skips or
138
- // runs+records. The wrapper keeps this action's `name`, so log lines and
139
- // the `launch <name>` line are unchanged. It forwards its injected
140
- // logs/signal straight into the inner action's `start`.
141
- withCache(...paths) {
142
- // Validate + canonicalize eagerly here (at the call site), not lazily
143
- // inside the run — a relative path fails fast where it's written, and
144
- // the resolved paths give a stable cache key regardless of spelling.
145
- const targets = resolvePaths(paths);
146
- return action(name).run(async (ctx) => {
147
- const fp = await fingerprint(targets);
148
- if ((await read(targets)) === fp) {
149
- ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
150
- return undefined;
151
- }
152
- const outcome = await instance.start({ logs: ctx.logs, signal: ctx.signal });
153
- // Re-throw the inner failure so the wrapper fails too (the framework
154
- // re-wraps it into this wrapper's own Outcome); only record on success.
155
- if (!outcome.ok)
156
- throw outcome.error;
157
- await write(targets, fp);
158
- return outcome.value;
159
- })();
160
- },
161
- };
162
- return instance;
163
- };
164
- return Object.assign(create, { actionName: name });
165
- };
166
- const builder = (name) => ({
167
- emits: () => builder(name),
168
- ref: () => builder(name),
169
- run: (exec) => build(name, exec),
170
- });
171
- /**
172
- * Defines an action. Always returns a builder; provide the implementation via
173
- * `.run(…)`, optionally after declaring events and/or a handle:
174
- *
175
- * action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
176
- * action("Watch") // typed events + handle
177
- * .emits<{ create: string }>()
178
- * .ref<{ rescan(): void }>()
179
- * .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
180
- *
181
- * `.run(…)` yields a callable that, when invoked, constructs an
182
- * {@link ActionInstance} (deferred — the run executes it). Instances expose
183
- * chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
184
- * handle is awaited via `instance.ref`.
185
- */
186
- export function action(name) {
187
- return builder(name);
188
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * An input-fingerprint cache: hash the mtime+size of one or more **input**
3
- * targets (the files/folders an action reads — *not* what it produces), so an
4
- * expensive action can be skipped when its inputs are unchanged since its last
5
- * successful run. Backs the `.withCache(...)` method on action instances.
6
- *
7
- * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
8
- * one JSON file per set of inputs, named by a hash of their absolute paths.
9
- */
10
- /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
11
- export declare const cacheDir: string;
12
- /**
13
- * Require every path to be **absolute** (a relative path is a developer error —
14
- * throws, naming it) and return them canonicalized with `resolve`, so different
15
- * spellings of the same target (`.`/`..`/trailing slash) collapse to one stable
16
- * fingerprint prefix and cache key. `resolve` on an already-absolute path only
17
- * normalizes — it never falls back to the cwd. Each target may be a file or a
18
- * folder (see {@link fingerprint}).
19
- */
20
- export declare const resolvePaths: (paths: string[]) => string[];
21
- /**
22
- * Fingerprints one or more **input** targets by mtime+size. Each target may be
23
- * a **file** (fingerprinted directly) or a **folder** (walked recursively, its
24
- * files fingerprinted). Every path must be absolute (throws otherwise). A
25
- * missing target contributes nothing — so an input that doesn't exist yet is
26
- * naturally a miss, not an error. The entry list is sorted for determinism
27
- * before hashing.
28
- */
29
- export declare const fingerprint: (paths: string[]) => Promise<string>;
30
- /**
31
- * Reads the fingerprint stored for these targets, or `undefined` when there's
32
- * no cache (absent, unreadable, or malformed — all treated as a miss).
33
- */
34
- export declare const read: (paths: string[]) => Promise<string | undefined>;
35
- /**
36
- * Records the fingerprint for these targets after a successful run. Writes
37
- * atomically (temp file + rename) so a concurrent read never sees a half-file.
38
- */
39
- export declare const write: (paths: string[], fp: string) => Promise<void>;