omkit 0.0.9 → 0.0.11
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/dist/actions/chrome-page.d.ts +1 -1
- package/dist/actions/chrome-page.js +1 -1
- package/dist/actions/command.d.ts +1 -1
- package/dist/actions/command.js +5 -6
- package/dist/actions/healthcheck.d.ts +1 -1
- package/dist/actions/healthcheck.js +1 -1
- package/dist/actions/prompt.d.ts +1 -1
- package/dist/actions/prompt.js +1 -1
- package/dist/actions/tail-log.d.ts +1 -1
- package/dist/actions/tail-log.js +1 -1
- package/dist/actions/until-log.d.ts +2 -2
- package/dist/actions/until-log.js +1 -1
- package/dist/actions/watch-dir.d.ts +1 -1
- package/dist/actions/watch-dir.js +1 -1
- package/dist/actions/watch.d.ts +1 -1
- package/dist/actions/watch.js +1 -1
- package/dist/index.d.ts +13 -17
- package/dist/index.js +15 -17
- package/dist/orchestration/action/action.d.ts +23 -0
- package/dist/orchestration/action/action.js +255 -0
- package/dist/{core/action.d.ts → orchestration/action/types.d.ts} +38 -27
- package/dist/orchestration/action/types.js +1 -0
- package/dist/{core → orchestration/events}/events.d.ts +9 -1
- package/dist/{core → orchestration/events}/events.js +14 -22
- package/dist/orchestration/spin/SpinHost.d.ts +105 -0
- package/dist/orchestration/spin/SpinHost.js +303 -0
- package/dist/orchestration/spin/spin.d.ts +24 -0
- package/dist/orchestration/spin/spin.js +56 -0
- package/dist/orchestration/spin/types.d.ts +56 -0
- package/dist/orchestration/spin/types.js +1 -0
- package/dist/output/Output.d.ts +24 -0
- package/dist/output/Output.js +27 -0
- package/dist/output/folder/RunFolder.d.ts +29 -0
- package/dist/output/folder/RunFolder.js +56 -0
- package/dist/output/index.d.ts +11 -0
- package/dist/output/index.js +11 -0
- package/dist/output/log/LogRenderer.d.ts +73 -0
- package/dist/output/log/LogRenderer.js +131 -0
- package/dist/{core/log-collector → output/log}/LogsCollector.d.ts +14 -0
- package/dist/{core/log-collector → output/log}/LogsCollector.js +24 -1
- package/dist/output/log/RunLog.d.ts +44 -0
- package/dist/output/log/RunLog.js +119 -0
- package/dist/output/snapshot/SnapshotStore.d.ts +49 -0
- package/dist/output/snapshot/SnapshotStore.js +61 -0
- package/dist/system/fs/FolderCache.d.ts +47 -0
- package/dist/system/fs/FolderCache.js +117 -0
- package/dist/system/fs/streams.d.ts +11 -0
- package/dist/system/fs/streams.js +14 -0
- package/dist/{core → system/process}/process.d.ts +9 -8
- package/dist/{core → system/process}/process.js +8 -9
- package/dist/utils/format.d.ts +9 -0
- package/dist/utils/format.js +9 -0
- package/dist/utils/serialize.d.ts +2 -0
- package/dist/utils/serialize.js +12 -0
- package/package.json +1 -1
- package/dist/core/action.js +0 -201
- package/dist/core/folder-cache.d.ts +0 -39
- package/dist/core/folder-cache.js +0 -109
- package/dist/core/log-collector/global.d.ts +0 -7
- package/dist/core/log-collector/global.js +0 -7
- package/dist/core/log-collector/render.d.ts +0 -68
- package/dist/core/log-collector/render.js +0 -132
- package/dist/core/markers.d.ts +0 -9
- package/dist/core/markers.js +0 -13
- package/dist/core/output.d.ts +0 -68
- package/dist/core/output.js +0 -213
- package/dist/core/run.d.ts +0 -88
- package/dist/core/run.js +0 -257
- package/dist/core/spin.d.ts +0 -72
- package/dist/core/spin.js +0 -45
- /package/dist/{core/helpers → utils}/AsyncQueue.d.ts +0 -0
- /package/dist/{core/helpers → utils}/AsyncQueue.js +0 -0
|
@@ -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 "
|
|
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
|
|
28
|
-
* pipeline injects one per action as `ctx.proc`, wired
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* follows the same shape: take the
|
|
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
|
|
36
|
-
* pipeline injects one per action as `ctx.proc`, wired
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* follows the same shape: take the
|
|
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 =
|
|
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,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.
|
|
3
|
+
"version": "0.0.11",
|
|
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",
|
package/dist/core/action.js
DELETED
|
@@ -1,201 +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
|
-
// Maps an instance's `.ref` promise back to the instance that produces it, so the
|
|
6
|
-
// run can catch a consumer launched before (or without) its `.ref` producer —
|
|
7
|
-
// which would otherwise await a handle that never arrives and hang silently.
|
|
8
|
-
const refOwners = new WeakMap();
|
|
9
|
-
/**
|
|
10
|
-
* If `value` is some action instance's `.ref` promise, the instance that owns it
|
|
11
|
-
* (the producer of that handle); otherwise `undefined`. Used by the run to verify
|
|
12
|
-
* a `.ref` dependency was launched before the action that consumes it.
|
|
13
|
-
*/
|
|
14
|
-
export const producerOfRef = (value) => typeof value === "object" && value !== null ? refOwners.get(value) : undefined;
|
|
15
|
-
/** A promise paired with its settlers, for the set-once handle. */
|
|
16
|
-
const deferred = () => {
|
|
17
|
-
let resolve;
|
|
18
|
-
let reject;
|
|
19
|
-
const promise = new Promise((res, rej) => {
|
|
20
|
-
resolve = res;
|
|
21
|
-
reject = rej;
|
|
22
|
-
});
|
|
23
|
-
return { promise, resolve, reject };
|
|
24
|
-
};
|
|
25
|
-
/** Duck-type an exit code off a thrown error (zx's ProcessOutput, command's exit error). */
|
|
26
|
-
const exitCodeOf = (error) => {
|
|
27
|
-
const code = error?.exitCode;
|
|
28
|
-
return typeof code === "number" ? code : undefined;
|
|
29
|
-
};
|
|
30
|
-
/**
|
|
31
|
-
* A promise for the next emit of `key`. It **never rejects**: it resolves with
|
|
32
|
-
* the event's payload, or with `undefined` if the action settles (`done` — which
|
|
33
|
-
* always fires) before `key` ever does. So an awaiter neither hangs nor throws:
|
|
34
|
-
* a waited-for event that won't come simply yields `undefined`. A retained
|
|
35
|
-
* snapshot resolves it immediately. (Not used for `key === "done"`, which reads
|
|
36
|
-
* straight from the settled Outcome.)
|
|
37
|
-
*/
|
|
38
|
-
const awaitEvent = (emitter, key) => new Promise((resolve) => {
|
|
39
|
-
const offs = [];
|
|
40
|
-
let settled = false;
|
|
41
|
-
// First of [the event | done] to fire wins; the rest are unsubscribed.
|
|
42
|
-
const settle = (act) => {
|
|
43
|
-
if (settled)
|
|
44
|
-
return;
|
|
45
|
-
settled = true;
|
|
46
|
-
for (const off of offs)
|
|
47
|
-
off();
|
|
48
|
-
act();
|
|
49
|
-
};
|
|
50
|
-
// listenOnce is keyed by E; the system `done` key is always present on an
|
|
51
|
-
// instance's event map, so reach it through a loosened view.
|
|
52
|
-
const listen = emitter.listenOnce;
|
|
53
|
-
offs.push(listen(key, (payload) => settle(() => resolve(payload))));
|
|
54
|
-
// `done` fires once on any settle (success or failure); if it beats `key`,
|
|
55
|
-
// the event will never come — resolve `undefined` rather than reject.
|
|
56
|
-
if (key !== "done") {
|
|
57
|
-
offs.push(listen("done", () => settle(() => resolve(undefined))));
|
|
58
|
-
}
|
|
59
|
-
// A retained snapshot fires a listen synchronously during registration; if
|
|
60
|
-
// that already settled us, drop any listeners registered afterwards.
|
|
61
|
-
if (settled)
|
|
62
|
-
for (const off of offs)
|
|
63
|
-
off();
|
|
64
|
-
});
|
|
65
|
-
/** Assembles an {@link Action} from a name and its implementation. */
|
|
66
|
-
const build = (name, exec) => {
|
|
67
|
-
const create = (...args) => {
|
|
68
|
-
// The emitter covers declared + system events; `ctx.emit` is narrowed to the
|
|
69
|
-
// declared ones, while the framework emits done/error through the same bus.
|
|
70
|
-
const emitter = events(name);
|
|
71
|
-
const emit = emitter.emit;
|
|
72
|
-
// Loosely typed for the framework's own emits (sidesteps EmitArgs for void results).
|
|
73
|
-
const systemEmit = emitter.emit;
|
|
74
|
-
// The handle lives on the instance as a set-once promise: `attach` resolves
|
|
75
|
-
// it, `instance.ref` awaits it. A second attach is a no-op (promise settled).
|
|
76
|
-
// If the action settles without ever attaching, `start` below settles it too
|
|
77
|
-
// (resolve on success, reject on error) so `ref` can't hang.
|
|
78
|
-
const handle = deferred();
|
|
79
|
-
// Attaching is a one-time lifecycle moment: resolve `ref` and emit `attached`
|
|
80
|
-
// (the *fact* the action became controllable — not the handle, which is just
|
|
81
|
-
// functions). Guarded so a second attach neither re-emits nor re-resolves.
|
|
82
|
-
let attached = false;
|
|
83
|
-
const attach = (value) => {
|
|
84
|
-
if (attached)
|
|
85
|
-
return;
|
|
86
|
-
attached = true;
|
|
87
|
-
handle.resolve(value);
|
|
88
|
-
systemEmit("attached");
|
|
89
|
-
};
|
|
90
|
-
// Most actions have a `void` handle and no one awaits `ref`; mark the promise
|
|
91
|
-
// handled so a settle-time rejection on such an instance doesn't surface as an
|
|
92
|
-
// unhandled rejection. Real awaiters of `ref` still receive the rejection.
|
|
93
|
-
void handle.promise.catch(() => { });
|
|
94
|
-
// The action's Outcome as a set-once promise. `start` resolves it (success or
|
|
95
|
-
// failure); `instance.done` / `once("done")` read it. It never rejects.
|
|
96
|
-
const settled = deferred();
|
|
97
|
-
const instance = {
|
|
98
|
-
name,
|
|
99
|
-
args,
|
|
100
|
-
ref: handle.promise,
|
|
101
|
-
done: settled.promise,
|
|
102
|
-
start: (system) =>
|
|
103
|
-
// `Promise.resolve().then` normalizes a sync result and routes a sync
|
|
104
|
-
// throw to the `error` path, so exec may be sync or async.
|
|
105
|
-
Promise.resolve()
|
|
106
|
-
.then(() => exec({
|
|
107
|
-
...system,
|
|
108
|
-
emit,
|
|
109
|
-
attach,
|
|
110
|
-
proc: createProc(system.logs, system.signal),
|
|
111
|
-
artifactsFolder: artifactsFolder(),
|
|
112
|
-
}, ...args))
|
|
113
|
-
.then((result) => {
|
|
114
|
-
const outcome = { ok: true, value: result };
|
|
115
|
-
settled.resolve(outcome);
|
|
116
|
-
systemEmit("done", outcome);
|
|
117
|
-
// Settle `ref` if the action finished without attaching, so awaiters
|
|
118
|
-
// resolve (with `undefined` for the default void handle) rather than
|
|
119
|
-
// hang. Idempotent — a no-op when a handle was already attached.
|
|
120
|
-
handle.resolve(undefined);
|
|
121
|
-
return outcome;
|
|
122
|
-
}, (error) => {
|
|
123
|
-
const exitCode = exitCodeOf(error);
|
|
124
|
-
const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
|
|
125
|
-
// Emit `error` (for `.on("error")` handlers), then settle `done` with
|
|
126
|
-
// the Outcome — the canonical failure signal that `.done`/`once` read.
|
|
127
|
-
systemEmit("error", error);
|
|
128
|
-
settled.resolve(outcome);
|
|
129
|
-
systemEmit("done", outcome);
|
|
130
|
-
// The action failed, so its handle will never arrive — reject `ref`
|
|
131
|
-
// with the same error instead of leaving awaiters hung. No-op if a
|
|
132
|
-
// handle was already attached before the failure.
|
|
133
|
-
handle.reject(error);
|
|
134
|
-
// Resolve (never rethrow): failure is the Outcome, not a rejection.
|
|
135
|
-
return outcome;
|
|
136
|
-
}),
|
|
137
|
-
on(key, handler) {
|
|
138
|
-
emitter.listen(key, handler);
|
|
139
|
-
},
|
|
140
|
-
once(key) {
|
|
141
|
-
// `done` always fires with the Outcome and never "fails" the await, so it
|
|
142
|
-
// reads straight from the settled promise; other keys race via awaitEvent
|
|
143
|
-
// (which resolves `undefined` if the action settles before the event).
|
|
144
|
-
return (key === "done" ? settled.promise : awaitEvent(emitter, key));
|
|
145
|
-
},
|
|
146
|
-
// Wrap this instance in a fresh one (reusing the engine for its own
|
|
147
|
-
// emitter/ref/on/once) that fingerprints `paths` and either skips or
|
|
148
|
-
// runs+records. The wrapper keeps this action's `name`, so log lines and
|
|
149
|
-
// the `launch <name>` line are unchanged. It forwards its injected
|
|
150
|
-
// logs/signal straight into the inner action's `start`.
|
|
151
|
-
withCache(...paths) {
|
|
152
|
-
// Validate + canonicalize eagerly here (at the call site), not lazily
|
|
153
|
-
// inside the run — a relative path fails fast where it's written, and
|
|
154
|
-
// the resolved paths give a stable cache key regardless of spelling.
|
|
155
|
-
const targets = resolvePaths(paths);
|
|
156
|
-
return action(name).run(async (ctx) => {
|
|
157
|
-
const fp = await fingerprint(targets);
|
|
158
|
-
if ((await read(targets)) === fp) {
|
|
159
|
-
ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
|
|
160
|
-
return undefined;
|
|
161
|
-
}
|
|
162
|
-
const outcome = await instance.start({ logs: ctx.logs, signal: ctx.signal });
|
|
163
|
-
// Re-throw the inner failure so the wrapper fails too (the framework
|
|
164
|
-
// re-wraps it into this wrapper's own Outcome); only record on success.
|
|
165
|
-
if (!outcome.ok)
|
|
166
|
-
throw outcome.error;
|
|
167
|
-
await write(targets, fp);
|
|
168
|
-
return outcome.value;
|
|
169
|
-
})();
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
// Register this instance as the producer of its `.ref`, so the run can catch
|
|
173
|
-
// a consumer built with this `.ref` that gets launched before this instance.
|
|
174
|
-
refOwners.set(instance.ref, instance);
|
|
175
|
-
return instance;
|
|
176
|
-
};
|
|
177
|
-
return Object.assign(create, { actionName: name });
|
|
178
|
-
};
|
|
179
|
-
const builder = (name) => ({
|
|
180
|
-
emits: () => builder(name),
|
|
181
|
-
ref: () => builder(name),
|
|
182
|
-
run: (exec) => build(name, exec),
|
|
183
|
-
});
|
|
184
|
-
/**
|
|
185
|
-
* Defines an action. Always returns a builder; provide the implementation via
|
|
186
|
-
* `.run(…)`, optionally after declaring events and/or a handle:
|
|
187
|
-
*
|
|
188
|
-
* action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
|
|
189
|
-
* action("Watch") // typed events + handle
|
|
190
|
-
* .emits<{ create: string }>()
|
|
191
|
-
* .ref<{ rescan(): void }>()
|
|
192
|
-
* .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
|
|
193
|
-
*
|
|
194
|
-
* `.run(…)` yields a callable that, when invoked, constructs an
|
|
195
|
-
* {@link ActionInstance} (deferred — the run executes it). Instances expose
|
|
196
|
-
* chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
|
|
197
|
-
* handle is awaited via `instance.ref`.
|
|
198
|
-
*/
|
|
199
|
-
export function action(name) {
|
|
200
|
-
return builder(name);
|
|
201
|
-
}
|
|
@@ -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>;
|