@grida/fx 0.0.0-stage → 0.1.0-alpha.2

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/binary.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Finding the engine, the `grida-fx` binary. The `grida-fx` command this package installs and
3
+ * every function of the SDK find it the same way:
4
+ *
5
+ * 1. `GRIDA_FX_BIN`, when set and not empty: the path of a binary (relative to the working
6
+ * directory). It must be a file.
7
+ * 2. The engine package for this machine (`@grida/fx-darwin-arm64`, …; see `platforms.ts`), an
8
+ * optional dependency of `@grida/fx`: its `bin/grida-fx`.
9
+ *
10
+ * Nothing else: the command never looks on `PATH`, where it would find itself.
11
+ */
12
+ import { statSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { FxError } from "./errors.js";
17
+ import { platformFor, unsupportedReason } from "./platforms.js";
18
+ /** The environment variable naming the binary to use. */
19
+ export const BINARY_VARIABLE = "GRIDA_FX_BIN";
20
+ /**
21
+ * The engine's path, as the module comment says; throws an {@link FxError} that says what to do
22
+ * when there is none.
23
+ */
24
+ export function findEngine(lookup) {
25
+ const configured = lookup.env[BINARY_VARIABLE];
26
+ if (configured) {
27
+ const path = resolve(lookup.cwd, expandHome(configured));
28
+ if (!lookup.isFile(path)) {
29
+ throw new FxError(`${BINARY_VARIABLE} is ${configured}, which is not a file`);
30
+ }
31
+ return path;
32
+ }
33
+ const platform = platformFor(lookup.os, lookup.cpu);
34
+ if (platform === null) {
35
+ throw new FxError(`${unsupportedReason(lookup.os, lookup.cpu)} Set ${BINARY_VARIABLE} to a grida-fx binary ` +
36
+ `to use one built for this machine.`);
37
+ }
38
+ let packageJson = null;
39
+ try {
40
+ packageJson = lookup.resolvePackageJson(`${platform.package}/package.json`);
41
+ }
42
+ catch {
43
+ packageJson = null;
44
+ }
45
+ if (packageJson !== null) {
46
+ const engine = join(dirname(packageJson), "bin", "grida-fx");
47
+ if (lookup.isFile(engine)) {
48
+ return engine;
49
+ }
50
+ throw new FxError(`${platform.package} is installed without its engine (${engine} is missing): reinstall ` +
51
+ `@grida/fx, or set ${BINARY_VARIABLE} to a grida-fx binary`);
52
+ }
53
+ if (platform.libc === "glibc" && lookup.isMusl()) {
54
+ throw new FxError(`the preview of grida-fx needs glibc 2.28 or later on Linux, and this system's C library ` +
55
+ `is musl (Alpine Linux, for one), so npm left out ${platform.package}. Use a glibc-based ` +
56
+ `system or image (Debian, Ubuntu, …), or set ${BINARY_VARIABLE} to a grida-fx binary.`);
57
+ }
58
+ throw new FxError(`the engine package ${platform.package} is not installed. npm installs it with @grida/fx, ` +
59
+ `as an optional dependency, on ${platform.label}; it is left out when optional ` +
60
+ `dependencies are omitted (--omit=optional, --no-optional, or a lockfile made without ` +
61
+ `them). Reinstall @grida/fx with optional dependencies, or set ${BINARY_VARIABLE} to a ` +
62
+ `grida-fx binary.`);
63
+ }
64
+ /** The `grida-fx` binary the SDK drives, found as the module comment says. */
65
+ export function binary(options = {}) {
66
+ return findEngine(machineLookup(mergeEnvironment(options.env), options.cwd ?? process.cwd()));
67
+ }
68
+ /** The lookup of this machine and this installation of `@grida/fx`. */
69
+ export function machineLookup(env, cwd) {
70
+ const require = createRequire(import.meta.url);
71
+ return {
72
+ env,
73
+ os: process.platform,
74
+ cpu: process.arch,
75
+ cwd,
76
+ resolvePackageJson: (name) => require.resolve(name),
77
+ isFile,
78
+ isMusl,
79
+ };
80
+ }
81
+ /** `process.env` with `overrides` over it; a variable given `undefined` is removed. */
82
+ export function mergeEnvironment(overrides) {
83
+ const merged = {};
84
+ for (const [name, value] of Object.entries(process.env)) {
85
+ if (value !== undefined) {
86
+ merged[name] = value;
87
+ }
88
+ }
89
+ for (const [name, value] of Object.entries(overrides ?? {})) {
90
+ if (value === undefined) {
91
+ delete merged[name];
92
+ }
93
+ else {
94
+ merged[name] = value;
95
+ }
96
+ }
97
+ return merged;
98
+ }
99
+ function isFile(path) {
100
+ try {
101
+ return statSync(path).isFile();
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ /** Whether the running Node was built against musl: its report names no glibc version. */
108
+ function isMusl() {
109
+ try {
110
+ const report = process.report?.getReport();
111
+ return report?.header !== undefined && report.header.glibcVersionRuntime === undefined;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function expandHome(path) {
118
+ return path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path;
119
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The `grida-fx` command that `@grida/fx` installs: it finds the engine (`binary.ts`) and runs it
3
+ * with this process's arguments, standard streams and working directory, and ends as the engine
4
+ * ended: with its exit status, or by the same signal.
5
+ *
6
+ * Signals. On Unix the engine runs in a process group (and session) of its own, so a signal meant
7
+ * for the command reaches the engine exactly once, through this process: SIGINT, SIGTERM, SIGHUP
8
+ * and SIGQUIT are passed on. The engine reads a second interruption as "end now", so the same
9
+ * signal arriving again within {@link REPEAT_MS} is passed on once: a launcher such as `npm exec`
10
+ * forwards the terminal's Ctrl-C to a process that already received it. Ctrl-Z (SIGTSTP) stops
11
+ * the engine and then this process; SIGCONT continues both. The engine never reads standard
12
+ * input, so it does not need the terminal's foreground group.
13
+ */
14
+ import { type ChildProcess } from "node:child_process";
15
+ /** Signals passed on to the engine. */
16
+ export declare const FORWARDED: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
17
+ /** The same signal again within this many milliseconds is a repeat of one interruption. */
18
+ export declare const REPEAT_MS = 250;
19
+ /** Runs the engine with `argv` (the arguments after the command's name) and ends this process as
20
+ * the engine ended. */
21
+ export declare function main(argv: readonly string[]): void;
22
+ /** Passes signals on to `child` (module comment); `remove` stops. */
23
+ export declare function forwardSignals(child: ChildProcess, unix: boolean): {
24
+ remove(): void;
25
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The `grida-fx` command that `@grida/fx` installs: it finds the engine (`binary.ts`) and runs it
3
+ * with this process's arguments, standard streams and working directory, and ends as the engine
4
+ * ended: with its exit status, or by the same signal.
5
+ *
6
+ * Signals. On Unix the engine runs in a process group (and session) of its own, so a signal meant
7
+ * for the command reaches the engine exactly once, through this process: SIGINT, SIGTERM, SIGHUP
8
+ * and SIGQUIT are passed on. The engine reads a second interruption as "end now", so the same
9
+ * signal arriving again within {@link REPEAT_MS} is passed on once: a launcher such as `npm exec`
10
+ * forwards the terminal's Ctrl-C to a process that already received it. Ctrl-Z (SIGTSTP) stops
11
+ * the engine and then this process; SIGCONT continues both. The engine never reads standard
12
+ * input, so it does not need the terminal's foreground group.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { constants } from "node:os";
16
+ import { binary } from "./binary.js";
17
+ /** Signals passed on to the engine. */
18
+ export const FORWARDED = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
19
+ /** The same signal again within this many milliseconds is a repeat of one interruption. */
20
+ export const REPEAT_MS = 250;
21
+ /** The exit status of the command's own failures (no engine for this machine, …). */
22
+ const FAILED = 1;
23
+ /** Runs the engine with `argv` (the arguments after the command's name) and ends this process as
24
+ * the engine ended. */
25
+ export function main(argv) {
26
+ let engine;
27
+ try {
28
+ engine = binary();
29
+ }
30
+ catch (error) {
31
+ process.stderr.write(`grida-fx: ${error instanceof Error ? error.message : String(error)}\n`);
32
+ process.exit(FAILED);
33
+ }
34
+ const unix = process.platform !== "win32";
35
+ const child = spawn(engine, [...argv], { stdio: "inherit", detached: unix });
36
+ const listeners = forwardSignals(child, unix);
37
+ child.once("error", (error) => {
38
+ listeners.remove();
39
+ process.stderr.write(`grida-fx: could not start the engine (${engine}): ${error.message}\n`);
40
+ process.exit(FAILED);
41
+ });
42
+ child.once("exit", (status, signal) => {
43
+ listeners.remove();
44
+ if (signal !== null) {
45
+ raise(signal);
46
+ return;
47
+ }
48
+ process.exit(status ?? FAILED);
49
+ });
50
+ // Should this process end some other way first, the engine is not left running alone.
51
+ process.once("exit", () => {
52
+ if (child.exitCode === null && child.signalCode === null) {
53
+ child.kill("SIGTERM");
54
+ }
55
+ });
56
+ }
57
+ /** Passes signals on to `child` (module comment); `remove` stops. */
58
+ export function forwardSignals(child, unix) {
59
+ const last = new Map();
60
+ const handlers = new Map();
61
+ const pass = (signal) => {
62
+ const now = Date.now();
63
+ const before = last.get(signal);
64
+ last.set(signal, now);
65
+ if (before !== undefined && now - before < REPEAT_MS) {
66
+ return;
67
+ }
68
+ if (child.exitCode === null && child.signalCode === null) {
69
+ child.kill(signal);
70
+ }
71
+ };
72
+ for (const signal of FORWARDED) {
73
+ if (!unix && signal !== "SIGINT" && signal !== "SIGTERM") {
74
+ continue;
75
+ }
76
+ handlers.set(signal, () => pass(signal));
77
+ }
78
+ if (unix) {
79
+ handlers.set("SIGTSTP", () => {
80
+ if (child.exitCode === null && child.signalCode === null) {
81
+ child.kill("SIGSTOP");
82
+ }
83
+ process.kill(process.pid, "SIGSTOP");
84
+ });
85
+ handlers.set("SIGCONT", () => {
86
+ if (child.exitCode === null && child.signalCode === null) {
87
+ child.kill("SIGCONT");
88
+ }
89
+ });
90
+ }
91
+ for (const [signal, handler] of handlers) {
92
+ process.on(signal, handler);
93
+ }
94
+ return {
95
+ remove() {
96
+ for (const [signal, handler] of handlers) {
97
+ process.removeListener(signal, handler);
98
+ }
99
+ },
100
+ };
101
+ }
102
+ /** Ends this process by `signal`, as the engine ended; by status 128 + its number when this
103
+ * process outlives it (Node itself handles a few signals). */
104
+ function raise(signal) {
105
+ const number = constants.signals[signal] ?? 0;
106
+ process.removeAllListeners(signal);
107
+ try {
108
+ process.kill(process.pid, signal);
109
+ }
110
+ catch {
111
+ // Fall through to the exit status.
112
+ }
113
+ setTimeout(() => process.exit(128 + number), 100);
114
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The JSON documents the engine prints, as TypeScript types: written by hand from `spec/schemas/`
3
+ * (fx-graph-v1, fx-run-events-v1) and the command line (`price`, `identity`, `project`,
4
+ * `inspect --json`). Their fields keep the contracts' `lower_snake_case`. They cover what a
5
+ * program reads; the schemas are complete.
6
+ */
7
+ import type { JsonValue } from "./workflow.js";
8
+ /** A problem that refuses a plan. */
9
+ export interface Problem {
10
+ where: string;
11
+ message: string;
12
+ }
13
+ /** An amount range in US dollars. */
14
+ export interface PriceRange {
15
+ low_usd: number;
16
+ high_usd: number;
17
+ }
18
+ /** A with-value of the graph: a plain value, or `{ pending: [<instance ids>] }` while it waits on
19
+ * what runs before it. */
20
+ export type WithValue = JsonValue;
21
+ /** One step instance of the expanded graph. */
22
+ export interface GraphInstance {
23
+ /** The instance id, e.g. `draw#1` or `entity['ada'].draw#1`. */
24
+ id: string;
25
+ /** The step path, with repeat keys. */
26
+ path: string;
27
+ /** The declared step's path, without repeat keys. */
28
+ step: string;
29
+ /** One take per regenerating level, outermost first. */
30
+ take: number[];
31
+ uses: string;
32
+ /** The type identity; null when the type could not be resolved. */
33
+ type: string | null;
34
+ with: {
35
+ [name: string]: WithValue;
36
+ };
37
+ /** Each capability the type calls, with the route bound to it. */
38
+ routes: {
39
+ [capability: string]: {
40
+ route: string;
41
+ fingerprint: string;
42
+ };
43
+ };
44
+ state: "planned" | "maybe" | "absent" | "blocked" | "failed" | "done";
45
+ /** The step identity; null while a with-value is pending. */
46
+ identity: string | null;
47
+ phase: number;
48
+ key: string | null;
49
+ judges: string | null;
50
+ judged_by: string[];
51
+ waiting_on: string[];
52
+ needs: string[];
53
+ reads: string[];
54
+ price: PriceRange;
55
+ view?: boolean | string;
56
+ [field: string]: unknown;
57
+ }
58
+ /** `grida-fx expand`, and `plan --json`: the expanded graph (fx-graph-v1). */
59
+ export interface Graph {
60
+ kind: "fx-graph-v1";
61
+ workflow: {
62
+ id: string;
63
+ title: string;
64
+ description?: string;
65
+ file?: string;
66
+ };
67
+ /** Each distinct `uses`, with its type identity (and a project type's source digests). */
68
+ types?: {
69
+ [uses: string]: {
70
+ identity: string;
71
+ source?: {
72
+ files: {
73
+ [label: string]: string;
74
+ };
75
+ resources: {
76
+ [path: string]: string;
77
+ };
78
+ };
79
+ };
80
+ };
81
+ instances: GraphInstance[];
82
+ /** Repeats whose instances a later phase expands, with their worst case. */
83
+ pending: {
84
+ path: string;
85
+ max: number;
86
+ phase: number;
87
+ high_usd: number;
88
+ }[];
89
+ estimate: PriceRange & {
90
+ ceiling_usd: number | null;
91
+ };
92
+ /** Why the plan is refused; empty when it is not. */
93
+ problems: Problem[];
94
+ [field: string]: unknown;
95
+ }
96
+ /** `grida-fx identity`: each live instance's identity, null while it is pending. */
97
+ export type Identities = {
98
+ [instanceId: string]: string | null;
99
+ };
100
+ /** One phase of {@link Price}. */
101
+ export interface PricePhase extends PriceRange {
102
+ phase: number;
103
+ steps: number;
104
+ /** The fewest and the most provider calls. */
105
+ calls: [number, number];
106
+ /** What the phase leaves to later phases (repeats over a run-time list). */
107
+ then: string[];
108
+ }
109
+ /** `grida-fx price`: the plan's price by phase. */
110
+ export interface Price {
111
+ phases: PricePhase[];
112
+ estimate: PriceRange;
113
+ ceiling_usd: number | null;
114
+ }
115
+ /** A file in a run's record: by digest. */
116
+ export interface FileRef {
117
+ digest: string;
118
+ /** The file kind (`image/png`, `json`, …). */
119
+ kind: string;
120
+ name: string;
121
+ size: number;
122
+ /** A collection item's key. */
123
+ key?: string;
124
+ }
125
+ /** A value in a run's record (fx-run-events-v1 `encoded`). */
126
+ export type Encoded = {
127
+ file: FileRef;
128
+ } | {
129
+ collection: [string, Encoded][];
130
+ } | {
131
+ list: Encoded[];
132
+ } | {
133
+ none: true;
134
+ } | {
135
+ value: JsonValue;
136
+ };
137
+ /** One event of a run's `events.jsonl` (fx-run-events-v1), without `kind`; only the common fields
138
+ * are typed. */
139
+ export interface RunEvent {
140
+ event: string;
141
+ plan?: string;
142
+ [field: string]: unknown;
143
+ }
144
+ /** The `run_finished` event. */
145
+ export interface RunFinished extends RunEvent {
146
+ event: "run_finished";
147
+ ok: boolean;
148
+ incomplete: boolean;
149
+ stopped: string | null;
150
+ charged_usd: number;
151
+ failed: string[];
152
+ outputs: {
153
+ [name: string]: Encoded;
154
+ };
155
+ }
156
+ /** One instance in {@link Projection}. */
157
+ export interface ProjectedInstance {
158
+ state: "running" | "succeeded" | "failed" | "skipped";
159
+ path: string;
160
+ /** Succeeded: whether its result came from the cache. */
161
+ cache?: "hit" | "miss" | null;
162
+ /** Succeeded: the facts its body reported. */
163
+ facts?: {
164
+ [name: string]: JsonValue;
165
+ };
166
+ /** Failed or skipped: why. */
167
+ error?: string | null;
168
+ }
169
+ /** `grida-fx project <run>`: a run's record projected to its state. */
170
+ export interface Projection {
171
+ instances: {
172
+ [instanceId: string]: ProjectedInstance;
173
+ };
174
+ run: {
175
+ run_started?: RunEvent;
176
+ run_finished?: RunFinished;
177
+ run_cancelled?: RunEvent & {
178
+ reason?: string;
179
+ charged_usd?: number;
180
+ };
181
+ };
182
+ }
183
+ /** One step of {@link Inspection}. */
184
+ export interface InspectedStep {
185
+ id: string;
186
+ path: string;
187
+ state: "succeeded" | "failed" | "skipped" | "running" | "pending";
188
+ error?: string;
189
+ cache?: "hit" | "miss";
190
+ /** Its files, where the run placed them: paths relative to the run folder. */
191
+ files: {
192
+ path: string;
193
+ digest: string;
194
+ size: number;
195
+ }[];
196
+ }
197
+ /** `grida-fx inspect <run> --json`: a run's summary. */
198
+ export interface Inspection {
199
+ run: {
200
+ workflow: string;
201
+ /** The run folder, as it was named. */
202
+ folder: string;
203
+ state: "planned" | "unfinished" | "succeeded" | "failed" | "cancelled";
204
+ /** What the run was charged; null before it ended. */
205
+ charged_usd: number | null;
206
+ steps: InspectedStep[];
207
+ };
208
+ /** With `verify`: whether every placed file still matches its record. */
209
+ verification?: {
210
+ verified: boolean;
211
+ problems: string[];
212
+ };
213
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The JSON documents the engine prints, as TypeScript types: written by hand from `spec/schemas/`
3
+ * (fx-graph-v1, fx-run-events-v1) and the command line (`price`, `identity`, `project`,
4
+ * `inspect --json`). Their fields keep the contracts' `lower_snake_case`. They cover what a
5
+ * program reads; the schemas are complete.
6
+ */
7
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Running the engine and reading what it prints.
3
+ *
4
+ * The engine runs in the caller's process group, so a Ctrl-C at the terminal reaches it as it
5
+ * reaches the caller, and it stops a run as it would on its own: do not also abort the call on
6
+ * that Ctrl-C (a second interruption makes the engine end at once). An `AbortSignal` stops it from
7
+ * code: the engine gets SIGINT, and SIGKILL if it has not exited 10 seconds later.
8
+ *
9
+ * A call ends when the engine exits, not when the last holder of its pipes closes them: a process
10
+ * a node body started may outlive the engine on purpose.
11
+ */
12
+ import { type Environment } from "./binary.js";
13
+ import { FxError } from "./errors.js";
14
+ /** Options every call takes. */
15
+ export interface CallOptions {
16
+ /** The engine's working directory (default: the process's). Relative paths start here. */
17
+ readonly cwd?: string;
18
+ /** Variables over `process.env` for the engine (`undefined` removes one), e.g.
19
+ * `GRIDA_FX_PYTHON`. `GRIDA_FX_BIN` here also chooses the binary. */
20
+ readonly env?: Environment;
21
+ /** Stops the engine (SIGINT, then SIGKILL after 10 seconds); the call rejects with the
22
+ * signal's reason once it has exited. */
23
+ readonly signal?: AbortSignal;
24
+ }
25
+ /** How a call ended. */
26
+ export interface Exit {
27
+ /** The exit status; null when a signal ended the engine. */
28
+ readonly status: number | null;
29
+ readonly signal: string | null;
30
+ readonly stdout: string;
31
+ readonly stderr: string;
32
+ readonly args: readonly string[];
33
+ }
34
+ /** The exit status of an unreadable input or a usage mistake. */
35
+ export declare const USAGE_OR_ERROR = 2;
36
+ /** The exit status of an interrupted command. */
37
+ export declare const INTERRUPTED = 130;
38
+ /** Runs the engine with `args` and collects what it prints. */
39
+ export declare function call(args: readonly string[], options?: CallOptions): Promise<Exit>;
40
+ /** The error of a call that did not end as `verb` should. */
41
+ export declare function failure(verb: string, done: Exit): FxError;
42
+ /** The JSON document a verb prints, read when it exits with one of `statuses`. */
43
+ export declare function document<T>(verb: string, args: readonly string[], options: CallOptions, statuses?: readonly number[]): Promise<T>;
package/dist/engine.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Running the engine and reading what it prints.
3
+ *
4
+ * The engine runs in the caller's process group, so a Ctrl-C at the terminal reaches it as it
5
+ * reaches the caller, and it stops a run as it would on its own: do not also abort the call on
6
+ * that Ctrl-C (a second interruption makes the engine end at once). An `AbortSignal` stops it from
7
+ * code: the engine gets SIGINT, and SIGKILL if it has not exited 10 seconds later.
8
+ *
9
+ * A call ends when the engine exits, not when the last holder of its pipes closes them: a process
10
+ * a node body started may outlive the engine on purpose.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { findEngine, machineLookup, mergeEnvironment } from "./binary.js";
14
+ import { FxError } from "./errors.js";
15
+ /** The exit status of an unreadable input or a usage mistake. */
16
+ export const USAGE_OR_ERROR = 2;
17
+ /** The exit status of an interrupted command. */
18
+ export const INTERRUPTED = 130;
19
+ /** How long a stopped engine has to exit before it is killed. */
20
+ const STOP_GRACE_MS = 10_000;
21
+ /** How long the pipes are still read once the engine has exited. */
22
+ const DRAIN_MS = 500;
23
+ /** How the engine prefixes an error on stderr. */
24
+ const ERROR_PREFIX = "grida-fx: ";
25
+ /** Runs the engine with `args` and collects what it prints. */
26
+ export async function call(args, options = {}) {
27
+ const cwd = options.cwd ?? process.cwd();
28
+ const env = mergeEnvironment(options.env);
29
+ const engine = findEngine(machineLookup(env, cwd));
30
+ options.signal?.throwIfAborted();
31
+ return new Promise((resolvePromise, reject) => {
32
+ const child = spawn(engine, [...args], {
33
+ cwd,
34
+ env,
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ const stdout = [];
38
+ const stderr = [];
39
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
40
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
41
+ let killTimer;
42
+ const onAbort = () => {
43
+ if (child.exitCode === null && child.signalCode === null) {
44
+ child.kill("SIGINT");
45
+ killTimer = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
46
+ }
47
+ };
48
+ options.signal?.addEventListener("abort", onAbort, { once: true });
49
+ const finish = () => {
50
+ options.signal?.removeEventListener("abort", onAbort);
51
+ if (killTimer !== undefined) {
52
+ clearTimeout(killTimer);
53
+ }
54
+ };
55
+ child.once("error", (error) => {
56
+ finish();
57
+ reject(new FxError(`grida-fx could not be started (${engine}): ${error.message}`, {
58
+ args,
59
+ cause: error,
60
+ }));
61
+ });
62
+ child.once("exit", (status, signal) => {
63
+ finish();
64
+ // What the engine wrote before it exited is in the pipes: read it, then stop reading.
65
+ const closed = new Promise((done) => child.once("close", () => done()));
66
+ const drained = new Promise((done) => setTimeout(done, DRAIN_MS).unref());
67
+ void Promise.race([closed, drained]).then(() => {
68
+ child.stdout.destroy();
69
+ child.stderr.destroy();
70
+ if (options.signal?.aborted) {
71
+ reject(options.signal.reason);
72
+ return;
73
+ }
74
+ resolvePromise({
75
+ status,
76
+ signal,
77
+ stdout: Buffer.concat(stdout).toString("utf8"),
78
+ stderr: Buffer.concat(stderr).toString("utf8"),
79
+ args,
80
+ });
81
+ });
82
+ });
83
+ });
84
+ }
85
+ /** The error of a call that did not end as `verb` should. */
86
+ export function failure(verb, done) {
87
+ const details = {
88
+ exitCode: done.status,
89
+ signal: done.signal,
90
+ stdout: done.stdout,
91
+ stderr: done.stderr,
92
+ args: done.args,
93
+ };
94
+ if (done.status === USAGE_OR_ERROR) {
95
+ let text = done.stderr.trim() || done.stdout.trim();
96
+ if (text.startsWith(ERROR_PREFIX)) {
97
+ text = text.slice(ERROR_PREFIX.length);
98
+ }
99
+ return new FxError(text || `grida-fx exited with status ${done.status}`, details);
100
+ }
101
+ if (done.status === INTERRUPTED) {
102
+ return new FxError(`grida-fx ${verb} was interrupted`, details);
103
+ }
104
+ const how = done.signal !== null ? `was ended by ${done.signal}` : `exited with status ${done.status}`;
105
+ const tail = (done.stderr.trim() || done.stdout.trim()).slice(-2000);
106
+ return new FxError(`grida-fx ${verb} ${how}${tail ? `: ${tail}` : ""}`, details);
107
+ }
108
+ /** The JSON document a verb prints, read when it exits with one of `statuses`. */
109
+ export async function document(verb, args, options, statuses = [0, 1]) {
110
+ const done = await call([verb, ...args], options);
111
+ if (done.status === null || !statuses.includes(done.status)) {
112
+ throw failure(verb, done);
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(done.stdout);
117
+ }
118
+ catch {
119
+ throw failure(verb, done);
120
+ }
121
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
122
+ throw new FxError(`grida-fx ${verb} printed no JSON object`, {
123
+ exitCode: done.status,
124
+ stdout: done.stdout,
125
+ stderr: done.stderr,
126
+ args: done.args,
127
+ });
128
+ }
129
+ return parsed;
130
+ }
@@ -0,0 +1,33 @@
1
+ /** Errors of `@grida/fx`. */
2
+ /** What the engine left behind when it failed. */
3
+ export interface FxErrorDetails {
4
+ /** The engine's exit status; `null` when it never started or ended by a signal. */
5
+ readonly exitCode?: number | null;
6
+ /** The signal that ended the engine, if one did. */
7
+ readonly signal?: string | null;
8
+ /** What the engine wrote on stderr. */
9
+ readonly stderr?: string;
10
+ /** What the engine wrote on stdout. */
11
+ readonly stdout?: string;
12
+ /** The arguments the engine was given (after the binary). */
13
+ readonly args?: readonly string[];
14
+ readonly cause?: unknown;
15
+ }
16
+ /**
17
+ * The engine stopped with an error (exit status 2: unreadable input or a usage mistake), would
18
+ * not run (`refused: …`), was interrupted (130), or could not be found or started. `message` is
19
+ * the engine's own message, without its `grida-fx: ` prefix.
20
+ */
21
+ export declare class FxError extends Error {
22
+ /** The engine's exit status; `null` when it never started or ended by a signal. */
23
+ readonly exitCode: number | null;
24
+ /** The signal that ended the engine, if one did. */
25
+ readonly signal: string | null;
26
+ /** What the engine wrote on stderr. */
27
+ readonly stderr: string;
28
+ /** What the engine wrote on stdout. */
29
+ readonly stdout: string;
30
+ /** The arguments the engine was given (after the binary). */
31
+ readonly args: readonly string[];
32
+ constructor(message: string, details?: FxErrorDetails);
33
+ }