omkit 0.0.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/dist/actions/chrome-page.d.ts +44 -0
  4. package/dist/actions/chrome-page.js +94 -0
  5. package/dist/actions/command.d.ts +26 -0
  6. package/dist/actions/command.js +32 -0
  7. package/dist/actions/healthcheck.d.ts +55 -0
  8. package/dist/actions/healthcheck.js +94 -0
  9. package/dist/actions/index.d.ts +22 -0
  10. package/dist/actions/index.js +14 -0
  11. package/dist/actions/prompt.d.ts +54 -0
  12. package/dist/actions/prompt.js +99 -0
  13. package/dist/actions/tail-log.d.ts +30 -0
  14. package/dist/actions/tail-log.js +124 -0
  15. package/dist/actions/until-log.d.ts +24 -0
  16. package/dist/actions/until-log.js +49 -0
  17. package/dist/actions/watch-dir.d.ts +35 -0
  18. package/dist/actions/watch-dir.js +159 -0
  19. package/dist/actions/watch.d.ts +33 -0
  20. package/dist/actions/watch.js +80 -0
  21. package/dist/core/action.d.ts +105 -0
  22. package/dist/core/action.js +133 -0
  23. package/dist/core/events.d.ts +23 -0
  24. package/dist/core/events.js +61 -0
  25. package/dist/core/helpers/AsyncQueue.d.ts +13 -0
  26. package/dist/core/helpers/AsyncQueue.js +50 -0
  27. package/dist/core/log-collector/LogsCollector.d.ts +63 -0
  28. package/dist/core/log-collector/LogsCollector.js +110 -0
  29. package/dist/core/log-collector/global.d.ts +7 -0
  30. package/dist/core/log-collector/global.js +7 -0
  31. package/dist/core/log-collector/render.d.ts +57 -0
  32. package/dist/core/log-collector/render.js +111 -0
  33. package/dist/core/markers.d.ts +9 -0
  34. package/dist/core/markers.js +13 -0
  35. package/dist/core/output.d.ts +55 -0
  36. package/dist/core/output.js +163 -0
  37. package/dist/core/process.d.ts +21 -0
  38. package/dist/core/process.js +73 -0
  39. package/dist/core/run.d.ts +95 -0
  40. package/dist/core/run.js +212 -0
  41. package/dist/core/spin.d.ts +58 -0
  42. package/dist/core/spin.js +29 -0
  43. package/dist/index.d.ts +25 -0
  44. package/dist/index.js +25 -0
  45. package/package.json +59 -0
@@ -0,0 +1,212 @@
1
+ import { relative } from "node:path";
2
+ import { log as logs } from "./log-collector/global.js";
3
+ import { createRenderer } from "./log-collector/render.js";
4
+ import { streamLog, writeLog } from "./output.js";
5
+ import { marker } from "./markers.js";
6
+ /** One run per process — a single world over the one global log. See {@link run}. */
7
+ let started = false;
8
+ /** Narrate a run-lifecycle milestone into the global log (renders as a `[run]` line). */
9
+ const narrate = (message) => logs.append({ source: "run", level: "run", message });
10
+ /** The entry script, relative to where node was invoked (so the log says what produced it). */
11
+ const scriptPath = () => {
12
+ const entry = process.argv[1];
13
+ return entry ? relative(process.cwd(), entry) : "(unknown)";
14
+ };
15
+ /** A constructed instance is an object carrying a `start` function. */
16
+ const isActionInstance = (value) => typeof value === "object" &&
17
+ value !== null &&
18
+ typeof value.start === "function";
19
+ /** An *uncalled* action: the callable `action(name).run(…)` returns before it's invoked. */
20
+ const isUncalledAction = (value) => typeof value === "function" && typeof value.actionName === "string";
21
+ /**
22
+ * Guard each launch argument: an action *instance* is required, not the action
23
+ * itself. Forgetting the trailing `()` is an easy slip (the callable has no
24
+ * `.start`), and silently treating it as something else would report a false
25
+ * green — so fail loudly with the fix.
26
+ */
27
+ function assertInstance(value) {
28
+ if (isActionInstance(value))
29
+ return;
30
+ const hint = isUncalledAction(value)
31
+ ? ` — that looks like an action that wasn't invoked; construct an instance by calling it: action("…").run(…)()`
32
+ : "";
33
+ throw new Error(`run() expects action instances; received ${typeof value}${hint}`);
34
+ }
35
+ export function run(...args) {
36
+ if (started) {
37
+ throw new Error("run() may only be called once per process — attach more actions to the existing run with its .run(…) method.");
38
+ }
39
+ // Peel an optional leading options bag off the variadic actions, keeping the
40
+ // bare `run(...actions)` call form working unchanged. Options are a plain
41
+ // object (no `.start`); a *function* first arg is never options — it's an
42
+ // uncalled action, which `assertInstance` below reports clearly.
43
+ const first = args[0];
44
+ const hasOptions = first !== undefined && !isActionInstance(first) && typeof first !== "function";
45
+ const options = hasOptions ? first : {};
46
+ const actions = (hasOptions ? args.slice(1) : args);
47
+ const failFast = options.failFast ?? true;
48
+ // Validate eagerly, before the one-run latch flips, so a bad argument throws
49
+ // cleanly and leaves the process's single run slot still usable.
50
+ for (const instance of actions)
51
+ assertInstance(instance);
52
+ started = true;
53
+ const controller = new AbortController();
54
+ // The log closes when the run tears down, so every `logs.subscribe(…)` ends on
55
+ // its own — no subscriber has to watch the signal.
56
+ logs.endOn(controller.signal);
57
+ // ── Lifecycle state ──────────────────────────────────────────────────────
58
+ // The single source of truth for "where in its life is this run". Only
59
+ // `beginClosing` and the finalize step below mutate it, and only forward.
60
+ let state = "open";
61
+ // Genuine failures (not teardown cascade). Frozen into the verdict at close.
62
+ const failures = [];
63
+ // Every launched action's promise lives here until it settles; the drain loop
64
+ // empties this set. Daemons keep it non-empty until the controller aborts.
65
+ const inFlight = new Set();
66
+ /**
67
+ * Move `open → closing`: abort the signal (unwinding procs, waits, and
68
+ * daemons), narrate the cause once, and stop accepting new actions. Idempotent
69
+ * — only the first caller transitions; later triggers (a second failure, a
70
+ * `cancel()` during teardown) are no-ops. This is the *only* path to abort, so
71
+ * every teardown reason flows through one place.
72
+ */
73
+ const beginClosing = (reason) => {
74
+ if (state !== "open")
75
+ return;
76
+ state = "closing";
77
+ narrate(`tearing down · ${reason}`);
78
+ controller.abort();
79
+ };
80
+ /** Record a genuine fault and, under fail-fast, kick off teardown. */
81
+ const recordFailure = (action, error) => {
82
+ failures.push({ action, error });
83
+ if (failFast)
84
+ beginClosing(`failed: ${action}`);
85
+ };
86
+ // Ctrl-C tears the run down gracefully through the lifecycle, like cancel().
87
+ const onSigint = () => beginClosing("interrupted (SIGINT)");
88
+ process.once("SIGINT", onSigint);
89
+ // A throw or rejection that escaped every action's own handling. Without a
90
+ // handler Node prints it and exits *immediately* — before the run can flush its
91
+ // log, which leaves a silent, empty log directory and no verdict. So catch it,
92
+ // log it, fold it into the verdict, and drive teardown so the log still flushes
93
+ // and the exit code reflects the fault — degrading a stray throw to a recorded
94
+ // failure instead of a hard crash. Removed at finalize, so it only governs
95
+ // while this run owns the process.
96
+ const onFatal = (kind) => (error) => {
97
+ logs.append({
98
+ source: kind,
99
+ level: "error",
100
+ message: error instanceof Error ? (error.stack ?? error.message) : String(error),
101
+ });
102
+ // Genuine fault vs. teardown fallout: only one that surfaces *before* the
103
+ // signal aborts is the run's own (mirrors `launch`'s cascade rule); a throw
104
+ // during unwind is logged but doesn't pollute the verdict. Either way tear
105
+ // down — an uncaught fault leaves the process state undefined, so we always
106
+ // finish and flush, even under `failFast: false`.
107
+ if (!controller.signal.aborted)
108
+ failures.push({ action: kind, error });
109
+ beginClosing(kind);
110
+ };
111
+ const onUncaught = onFatal("uncaughtException");
112
+ const onUnhandled = onFatal("unhandledRejection");
113
+ process.on("uncaughtException", onUncaught);
114
+ process.on("unhandledRejection", onUnhandled);
115
+ narrate(`run started · ${scriptPath()}`);
116
+ // Start streaming log entries to disk immediately — incremental writes survive
117
+ // hard kills where the final writeLog never runs.
118
+ void streamLog(logs).catch(() => { });
119
+ const launch = (instance) => {
120
+ narrate(`launch ${instance.name}`);
121
+ // The action logs into the one shared store (entries carry their `source`;
122
+ // grouping is applied at render time) and gets the run's signal directly; the
123
+ // framework injects a `proc` bound to both, so there's no ambient context.
124
+ const promise = instance.start({ logs, signal: controller.signal }).catch((error) => {
125
+ // Always record the fault in the action's own log, under its name.
126
+ logs.append({
127
+ source: instance.name,
128
+ level: "error",
129
+ message: error instanceof Error ? (error.stack ?? error.message) : String(error),
130
+ });
131
+ // Genuine failure vs. teardown fallout: if the signal was already aborted
132
+ // when this surfaced, the rejection is almost certainly the abort killing
133
+ // the action (a cascade), not its own fault — so it must not pollute the
134
+ // verdict. The *first* real error is caught here before `recordFailure`
135
+ // triggers the abort, so `signal.aborted` is still false for it.
136
+ if (!controller.signal.aborted)
137
+ recordFailure(instance.name, error);
138
+ });
139
+ inFlight.add(promise);
140
+ void promise.finally(() => inFlight.delete(promise));
141
+ };
142
+ for (const instance of actions)
143
+ launch(instance);
144
+ // Drives the run to `closed`. The loop re-checks `inFlight` because actions can
145
+ // be added (or daemons can settle on abort) while we await. A synchronous
146
+ // `.run(…)` from a settling action's lifecycle event lands before that action's
147
+ // `finally` removes it, so `inFlight` never spuriously hits 0 mid-handoff.
148
+ const done = (async () => {
149
+ for (;;) {
150
+ if (inFlight.size === 0) {
151
+ // Nothing left to run. If still open, every action finished on its own —
152
+ // natural completion; tear down so daemons/listeners unwind uniformly.
153
+ // If already closing, teardown is simply complete.
154
+ if (state === "open")
155
+ beginClosing("completed");
156
+ break;
157
+ }
158
+ await Promise.allSettled([...inFlight]);
159
+ }
160
+ // ── Finalize (reached exactly once) ──────────────────────────────────────
161
+ state = "closed";
162
+ process.off("SIGINT", onSigint);
163
+ process.off("uncaughtException", onUncaught);
164
+ process.off("unhandledRejection", onUnhandled);
165
+ narrate("finished");
166
+ const file = await writeLog(logs);
167
+ // Direct to the terminal — the file's already written, so this line isn't in it.
168
+ console.log(marker("run", `log → ${file}`));
169
+ const result = { ok: failures.length === 0, failures };
170
+ // One process = one run = one verdict; reflect pass/fail in the exit code so
171
+ // shells and CI see it without parsing the log. Only set it on failure, so a
172
+ // code a failing action's own handler may have set isn't clobbered to 0.
173
+ if (!result.ok)
174
+ process.exitCode = 1;
175
+ return result;
176
+ })();
177
+ // `done` resolves with the verdict and never rejects; guard anyway so a stray
178
+ // finalize error can't surface as an unhandled rejection.
179
+ done.catch(() => { });
180
+ const drain = (write = (message) => console.log(message)) => {
181
+ void (async () => {
182
+ // A renderer applies grouping/indentation as entries stream by; its state
183
+ // spans the replayed history and live entries as one continuous stream. The
184
+ // subscription ends itself when the run tears down (the collector closes it),
185
+ // so this loop simply runs to completion.
186
+ const render = createRenderer();
187
+ for await (const entry of logs.subscribe({ replay: true }))
188
+ write(render(entry));
189
+ })().catch(() => { });
190
+ return self;
191
+ };
192
+ const self = {
193
+ logs,
194
+ get state() {
195
+ return state;
196
+ },
197
+ done,
198
+ signal: controller.signal,
199
+ run(...more) {
200
+ if (state !== "open") {
201
+ throw new Error(`run.run(…) rejected: the run is "${state}", not "open". Launch actions before ` +
202
+ `teardown, or from a lifecycle event of an action that is still running.`);
203
+ }
204
+ for (const instance of more)
205
+ launch(instance);
206
+ return self;
207
+ },
208
+ cancel: () => beginClosing("cancelled"),
209
+ drain,
210
+ };
211
+ return self;
212
+ }
@@ -0,0 +1,58 @@
1
+ import { type AnyActionInstance } from "./action.ts";
2
+ import { type Run, type RunOptions } from "./run.ts";
3
+ /**
4
+ * Launch an action into the spin's run and get the same instance back, so you
5
+ * can `await` its `.done` / `.ref` or subscribe with `.on` / `.once`. A nod is
6
+ * how the body waves an action in.
7
+ */
8
+ export type Nod = <I extends AnyActionInstance>(instance: I) => I;
9
+ /** Tunes a {@link spin} — the run's options plus how it drains. */
10
+ export interface SpinOptions extends RunOptions {
11
+ /**
12
+ * Stream the log to the console automatically from the start. Default `true`.
13
+ * Set `false` when you need to drain later via {@link SpinContext.drain} —
14
+ * e.g. after an interactive `prompt`, so the stream doesn't write over it.
15
+ */
16
+ drain?: boolean;
17
+ }
18
+ /** What a {@link spin} body receives. */
19
+ export interface SpinContext {
20
+ /** Launch an action into the run; returns the instance for awaiting/chaining. */
21
+ readonly nod: Nod;
22
+ /**
23
+ * Start streaming the run's log to the console now. Only needed when the spin
24
+ * was created with `drain: false`; idempotent, so it's safe to call regardless.
25
+ */
26
+ readonly drain: Run["drain"];
27
+ /**
28
+ * Tear the run down (abort the signal, stopping daemons). Use it to end a run
29
+ * whose work is finished but which is held open by a daemon — e.g. once a
30
+ * one-shot build completes while a watcher is still running.
31
+ */
32
+ readonly cancel: Run["cancel"];
33
+ /** The run's abort signal — honor it to stop early. */
34
+ readonly signal: AbortSignal;
35
+ }
36
+ /** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
37
+ export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
38
+ /**
39
+ * Runs a linear orchestration. You pass a plain (async) function; tswm hosts it
40
+ * in a single in-flight action and hands it a {@link Nod}. Because that host
41
+ * stays in-flight while your body `await`s, the run never idles shut between
42
+ * steps — so you write `await` / `if` control flow instead of nested `done`
43
+ * callbacks. When the body returns, the daemons it nodded in keep the run alive
44
+ * until teardown.
45
+ *
46
+ * spin({ failFast: false }, async ({ nod }) => {
47
+ * if ((await nod(prompt({ … })).done) === "yes") await nod(test).done;
48
+ * nod(devServer); // fire-and-forget daemon
49
+ * const chrome = nod(chromedriver({ … }));
50
+ * nod(chromePage(chrome.ref)); // data deps via .ref
51
+ * });
52
+ *
53
+ * Auto-drains by default; pass `drain: false` and call `ctx.drain()` yourself to
54
+ * stream the log only from a chosen point (e.g. after a `prompt`). Returns the
55
+ * {@link Run}, so `.done` is available on it too.
56
+ */
57
+ export declare function spin(body: SpinBody): Run;
58
+ export declare function spin(options: SpinOptions, body: SpinBody): Run;
@@ -0,0 +1,29 @@
1
+ import { action } from "./action.js";
2
+ import { run } from "./run.js";
3
+ export function spin(a, b) {
4
+ const [options, body] = typeof a === "function" ? [{}, a] : [a, b];
5
+ const { drain: autoDrain = true, ...runOptions } = options;
6
+ // `run(...)` below assigns `host` synchronously, before the action's async body
7
+ // ever runs — so the closures here capture the run safely.
8
+ let host;
9
+ // One-shot drain: auto-drain and a manual ctx.drain() can't double-subscribe.
10
+ let drained = false;
11
+ const startDrain = (write) => {
12
+ if (!drained) {
13
+ drained = true;
14
+ host.drain(write);
15
+ }
16
+ return host;
17
+ };
18
+ const hosted = action("Spin").run(async ({ signal }) => {
19
+ const nod = (instance) => {
20
+ host.run(instance);
21
+ return instance;
22
+ };
23
+ await body({ nod, drain: startDrain, cancel: () => host.cancel(), signal });
24
+ });
25
+ host = run(runOptions, hosted());
26
+ if (autoDrain)
27
+ startDrain();
28
+ return host;
29
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * tswm — the operational/runtime layer to tskb's knowledge layer.
3
+ *
4
+ * The core engine: define typed {@link action}s, launch them under one
5
+ * {@link run}, and share a single append-only log. Reusable actions built on
6
+ * this engine live at the `tswm/actions` entry point.
7
+ *
8
+ * This is a curated public surface — internal helpers (the async queue, the log
9
+ * renderer, marker prefixing) are intentionally not exported.
10
+ */
11
+ export { action } from "./core/action.ts";
12
+ export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, } from "./core/action.ts";
13
+ export { run } from "./core/run.ts";
14
+ export type { Run, RunResult, RunOptions, RunState, ActionFailure } from "./core/run.ts";
15
+ export { spin } from "./core/spin.ts";
16
+ export type { Nod, SpinOptions, SpinContext, SpinBody } from "./core/spin.ts";
17
+ export { events } from "./core/events.ts";
18
+ export type { Emitter, EventHandler, EventMeta } from "./core/events.ts";
19
+ export { createProc } from "./core/process.ts";
20
+ export type { Proc } from "./core/process.ts";
21
+ export { LogsCollector } from "./core/log-collector/LogsCollector.ts";
22
+ export type { Logger, LogEntry, LogInput, SubscribeOptions, } from "./core/log-collector/LogsCollector.ts";
23
+ export { log } from "./core/log-collector/global.ts";
24
+ export { snapshot, captureSnapshot } from "./core/output.ts";
25
+ export type { SnapshotRef } from "./core/output.ts";
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * tswm — the operational/runtime layer to tskb's knowledge layer.
3
+ *
4
+ * The core engine: define typed {@link action}s, launch them under one
5
+ * {@link run}, and share a single append-only log. Reusable actions built on
6
+ * this engine live at the `tswm/actions` entry point.
7
+ *
8
+ * This is a curated public surface — internal helpers (the async queue, the log
9
+ * renderer, marker prefixing) are intentionally not exported.
10
+ */
11
+ // ── Actions ────────────────────────────────────────────────────────────────
12
+ export { action } from "./core/action.js";
13
+ // ── Runs ───────────────────────────────────────────────────────────────────
14
+ export { run } from "./core/run.js";
15
+ // ── Spin (linear orchestration) ──────────────────────────────────────────────
16
+ export { spin } from "./core/spin.js";
17
+ // ── Events ─────────────────────────────────────────────────────────────────
18
+ export { events } from "./core/events.js";
19
+ // ── Child processes ────────────────────────────────────────────────────────
20
+ export { createProc } from "./core/process.js";
21
+ // ── The log ────────────────────────────────────────────────────────────────
22
+ export { LogsCollector } from "./core/log-collector/LogsCollector.js";
23
+ export { log } from "./core/log-collector/global.js";
24
+ // ── Snapshots ──────────────────────────────────────────────────────────────
25
+ export { snapshot, captureSnapshot } from "./core/output.js";
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "omkit",
3
+ "version": "0.0.1",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "LICENSE"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./actions": {
18
+ "types": "./dist/actions/index.d.ts",
19
+ "import": "./dist/actions/index.js"
20
+ }
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsc --watch",
25
+ "clean": "rimraf dist",
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "npm run clean && npm run build"
28
+ },
29
+ "author": "Dimitar Mihaylov",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/mitk0936/tskb.git",
34
+ "directory": "packages/omkit"
35
+ },
36
+ "engines": {
37
+ "node": ">=20.11"
38
+ },
39
+ "keywords": [
40
+ "typescript",
41
+ "orchestration",
42
+ "actions",
43
+ "simulation",
44
+ "pipeline",
45
+ "process",
46
+ "watch",
47
+ "logging",
48
+ "developer-tools"
49
+ ],
50
+ "dependencies": {
51
+ "playwright-core": "^1.49.0",
52
+ "zx": "^8.8.5"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^24",
56
+ "rimraf": "^6.0.1",
57
+ "typescript": "^5.9.3"
58
+ }
59
+ }