@grida/fx 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -5,10 +5,10 @@ files and to plan, price and run them. Grida FX is a workflow engine for generat
5
5
  pipelines: write a workflow file, and FX plans it, prices it before anything is spent, caches
6
6
  every paid call by its request, and records the run. It works with your own provider keys.
7
7
 
8
- This is a **preview** (`0.x` pre-releases under the npm tag `next`).
8
+ Stable releases start at `0.1.0` and use npm's default `latest` tag. Prereleases use `next`.
9
9
 
10
10
  ```sh
11
- npm install @grida/fx@next # or: npm install -g @grida/fx@next
11
+ npm install @grida/fx # or: npm install -g @grida/fx
12
12
  npx grida-fx --help
13
13
  ```
14
14
 
@@ -22,15 +22,21 @@ your machine:
22
22
  | Linux on x64, glibc 2.28 or later | `@grida/fx-linux-x64-gnu` |
23
23
  | Linux on arm64, glibc 2.28 or later | `@grida/fx-linux-arm64-gnu` |
24
24
 
25
- **Windows is not in the preview**: the engine's runner uses Unix process groups and signals. Use
25
+ **Native Windows is not supported yet**: the engine's runner uses Unix process groups and signals. Use
26
26
  WSL 2, which runs the Linux packages. Linux with musl (Alpine) is not supported either. Node 18 or
27
27
  later.
28
28
 
29
+ FX is also the `fx` verb of the npm `grida` command: a `grida` release that pins this package
30
+ runs its engine as `grida fx <verb>`. For that host this package exports
31
+ its launcher as `@grida/fx/cli` (`launch`, `HOST_CONTRACT`); see
32
+ [spec/distribution.md](https://github.com/gridaco/fx/blob/main/spec/distribution.md). The SDK
33
+ stays `import … from "@grida/fx"`.
34
+
29
35
  `grida-fx` reports what is missing when it finds no engine. If you install with
30
36
  `--omit=optional`, or need a binary of your own, set `GRIDA_FX_BIN` to the path of a `grida-fx`
31
37
  binary: the command and the SDK use it first.
32
38
 
33
- Some steps run on Python in the preview (your own Python nodes, and the built-in local types such
39
+ Some steps run on Python (your own Python nodes, and the built-in local types such
34
40
  as `image.resize`): those projects also need `pip install grida`, or `GRIDA_FX_PYTHON`
35
41
  pointing at a Python that has them. A project whose steps are all paid built-ins and `select`
36
42
  needs only this package. See [Getting started](https://github.com/gridaco/fx/blob/main/docs/guide/01-getting-started.md).
@@ -58,14 +64,43 @@ const icon = result.outputs.icon; // a RunFile: digest, kind, path, read()
58
64
  runs, and reads the run back: `ok`, `incomplete`, `chargedUsd`, `failed`, `outputs`, `steps`.
59
65
  A failed step does not reject.
60
66
  - `project(runDir)` and `inspect(runOrWorkflowId, { verify })`: a run's record and summary.
67
+ - `loadRun(target, { verify })`: a `RunRecord` with a pinned absolute `runDir` and
68
+ engine `inspection`. Its `snapshot()`, `events({ after, limit })` and async
69
+ `follow({ after, pollIntervalMs, signal })` methods use the same public reader as
70
+ the CLI. Following yields nonempty bounded batches until broken or aborted,
71
+ including across resumes. It owns no execution process or exit code.
72
+ - `inspectControl(run)` and `cancel(run, { invocation, wait, timeout })`: exact-target local
73
+ control, independent of viewer/service lifetime. Results keep their `lower_snake_case` wire
74
+ fields; structured failures raise `RunControlError` with `code` and `result`.
75
+ - `listRuns({ workflow })`: the project's runs, newest first, each with its `folder`, a `runDir`
76
+ resolved against `cwd`, and `lower_snake_case` wire fields (`state`, `placement`,
77
+ `charged_usd`, …). `removeRuns(runs, { preview })`: removes exactly the runs given (filter
78
+ `listRuns` yourself); `preview: true` removes nothing. A refused or partial run is an entry's
79
+ `outcome`, not a rejection; a RUN that is not a run raises `RunRemovalError` with `code` and
80
+ `run`, before anything is removed.
61
81
  - `binary()` and `engineVersion()`: the engine in use.
62
82
 
63
83
  A target is a workflow file, a workflow id, or a builder `file.py:function`. Options:
64
84
  `inputs` (values), `inputFiles`, `routes`, `args` (a builder's arguments), `maxUsd`, `cwd`,
65
85
  `env` (for example `GRIDA_FX_PYTHON`) and `signal` (an `AbortSignal`); `run` also takes `live`,
66
- `yesUpTo`, `runDir` and `deliver`. Relative paths start at `cwd`. The engine's errors reject with
86
+ `yesUpTo`, `deliver`, and one of `name` (create-only), `resume` (require-existing),
87
+ or `runDir` (explicit-folder create/resume). Relative paths start at `cwd`. The engine's errors reject with
67
88
  `FxError`, which carries its `exitCode` and `stderr`.
68
89
 
90
+ Aborting a `run` requests graceful cancellation once with SIGTERM and waits for engine cleanup
91
+ before rejecting. There is no automatic runner force timer; stuck cleanup can keep the promise
92
+ pending. Aborting only a control waiter ends that wait without retracting accepted cancellation.
93
+ See [Stopping and continuing a run](../../docs/guide/08-run-control.md) for guarded requests,
94
+ wait timeouts, local completion and unknown remote outcomes. These APIs describe current source;
95
+ check your installed version for availability.
96
+
97
+ Observation abort stops only its reader. Apply an entire batch before checkpointing
98
+ its opaque cursor; `RunObservationError` keeps a reader's `code` and `document`.
99
+ Inspection and a later snapshot are independent samples. See
100
+ [Working with runs from an SDK](../../docs/guide/09-sdk-runs.md) for saved/named
101
+ records and observing another process. SDK execution still suppresses viewer
102
+ hosting and returns after completion.
103
+
69
104
  ## Writing workflows
70
105
 
71
106
  ```ts
package/dist/api.d.ts CHANGED
@@ -90,6 +90,8 @@ export declare class RunResult {
90
90
  readonly inspection: Inspection;
91
91
  constructor(runDir: string, exit: Exit, projection: Projection, inspection: Inspection);
92
92
  private get finished();
93
+ /** The initialized invocation represented by this result's projection. */
94
+ get invocationId(): string | null;
93
95
  /** Whether every step that had to run succeeded and the run ended normally. */
94
96
  get ok(): boolean;
95
97
  /** Whether the run stopped before everything ran (running it again continues it). */
package/dist/api.js CHANGED
@@ -165,6 +165,11 @@ export class RunResult {
165
165
  get finished() {
166
166
  return this.projection.run?.run_finished;
167
167
  }
168
+ /** The initialized invocation represented by this result's projection. */
169
+ get invocationId() {
170
+ const value = this.projection.run?.run_started?.invocation_id;
171
+ return typeof value === "string" ? value : null;
172
+ }
168
173
  /** Whether every step that had to run succeeded and the run ended normally. */
169
174
  get ok() {
170
175
  return this.finished?.ok === true;
@@ -234,7 +239,7 @@ export function decode(encoded, placed) {
234
239
  throw new FxError(`the run's record holds a value in no known form: ${JSON.stringify(encoded)}`);
235
240
  }
236
241
  /** The options `run` still takes with a {@link Plan}. */
237
- const RUN_ONLY = ["live", "yesUpTo", "runDir", "deliver", "signal"];
242
+ const RUN_ONLY = ["live", "yesUpTo", "runDir", "name", "resume", "deliver", "signal"];
238
243
  /**
239
244
  * Runs `target`, or a plan, after planning it: a plan with problems rejects with
240
245
  * {@link PlanRefused} before any folder exists. `live: true` admits paid calls, within a
@@ -261,7 +266,7 @@ export async function run(target, options = {}) {
261
266
  if (!planned.ok) {
262
267
  throw new PlanRefused(planned);
263
268
  }
264
- const done = await withInputsFile(request, (inputsFile) => call(["run", ...planningArgs(request, inputsFile), ...own], callOptions(request, signal)));
269
+ const done = await withInputsFile(request, (inputsFile) => call(["run", ...planningArgs(request, inputsFile), ...own, "--no-view"], callOptions(request, signal)));
265
270
  if (done.status === INTERRUPTED) {
266
271
  throw new FxError("the run was interrupted", {
267
272
  exitCode: done.status,
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,13 @@
1
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.
2
+ * The command launcher: it finds the engine (`binary.ts`) and runs it with the given arguments,
3
+ * this process's standard streams and working directory, and ends as the engine ended: with its
4
+ * exit status, or by the same signal. `@grida/fx`'s own `grida-fx` command is {@link main}; a
5
+ * host command that carries FX as a verb of its own (the npm `grida` command's `grida fx`) calls
6
+ * {@link launch} through the `@grida/fx/cli` export (spec/distribution.md).
7
+ *
8
+ * The invoker. {@link launch} tells the engine which command started it (`GRIDA_FX_INVOKER`), so
9
+ * usage, the error prefix and the hints the engine prints name the command as its user types it:
10
+ * `grida-fx` or `grida fx`. This launcher's own messages use the same name.
5
11
  *
6
12
  * Signals. On Unix the engine runs in a process group (and session) of its own, so a signal meant
7
13
  * for the command reaches the engine exactly once, through this process: SIGINT, SIGTERM, SIGHUP
@@ -12,13 +18,34 @@
12
18
  * input, so it does not need the terminal's foreground group.
13
19
  */
14
20
  import { type ChildProcess } from "node:child_process";
21
+ /**
22
+ * The version of the contract between `@grida/fx/cli` and a host command: {@link launch}'s
23
+ * signature and behaviour, the invokers it accepts, and what the engine does with each. A host
24
+ * pins `@grida/fx` exactly and refuses a contract it was not written for.
25
+ */
26
+ export declare const HOST_CONTRACT = 1;
27
+ /** The environment variable that tells the engine which command started it. */
28
+ export declare const INVOKER_VARIABLE = "GRIDA_FX_INVOKER";
29
+ /** A command that launches the engine from JavaScript: `@grida/fx`'s `grida-fx`, or `grida`'s
30
+ * `grida fx`. (`python -m grida.fx` is the Python distribution's.) */
31
+ export type Invoker = "grida-fx" | "grida";
32
+ /** The command each invoker's user types, without the verb. */
33
+ export declare const COMMANDS: Readonly<Record<Invoker, string>>;
34
+ /** Options of {@link launch}. */
35
+ export interface LaunchOptions {
36
+ /** The command that started the engine (default: `grida-fx`). */
37
+ readonly invoker?: Invoker;
38
+ }
15
39
  /** Signals passed on to the engine. */
16
40
  export declare const FORWARDED: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
17
41
  /** The same signal again within this many milliseconds is a repeat of one interruption. */
18
42
  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. */
43
+ /** `@grida/fx`'s `grida-fx` command: {@link launch} as the `grida-fx` invoker. */
21
44
  export declare function main(argv: readonly string[]): void;
45
+ /** Runs the engine with `argv` (the arguments after the command's name, and after `fx` for
46
+ * `grida fx`) and ends this process as the engine ended. Throws a `TypeError`, before anything
47
+ * runs, for an invoker this contract does not know. */
48
+ export declare function launch(argv: readonly string[], options?: LaunchOptions): void;
22
49
  /** Passes signals on to `child` (module comment); `remove` stops. */
23
50
  export declare function forwardSignals(child: ChildProcess, unix: boolean): {
24
51
  remove(): void;
package/dist/cli.js CHANGED
@@ -1,7 +1,13 @@
1
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.
2
+ * The command launcher: it finds the engine (`binary.ts`) and runs it with the given arguments,
3
+ * this process's standard streams and working directory, and ends as the engine ended: with its
4
+ * exit status, or by the same signal. `@grida/fx`'s own `grida-fx` command is {@link main}; a
5
+ * host command that carries FX as a verb of its own (the npm `grida` command's `grida fx`) calls
6
+ * {@link launch} through the `@grida/fx/cli` export (spec/distribution.md).
7
+ *
8
+ * The invoker. {@link launch} tells the engine which command started it (`GRIDA_FX_INVOKER`), so
9
+ * usage, the error prefix and the hints the engine prints name the command as its user types it:
10
+ * `grida-fx` or `grida fx`. This launcher's own messages use the same name.
5
11
  *
6
12
  * Signals. On Unix the engine runs in a process group (and session) of its own, so a signal meant
7
13
  * for the command reaches the engine exactly once, through this process: SIGINT, SIGTERM, SIGHUP
@@ -14,29 +20,54 @@
14
20
  import { spawn } from "node:child_process";
15
21
  import { constants } from "node:os";
16
22
  import { binary } from "./binary.js";
23
+ /**
24
+ * The version of the contract between `@grida/fx/cli` and a host command: {@link launch}'s
25
+ * signature and behaviour, the invokers it accepts, and what the engine does with each. A host
26
+ * pins `@grida/fx` exactly and refuses a contract it was not written for.
27
+ */
28
+ export const HOST_CONTRACT = 1;
29
+ /** The environment variable that tells the engine which command started it. */
30
+ export const INVOKER_VARIABLE = "GRIDA_FX_INVOKER";
31
+ /** The command each invoker's user types, without the verb. */
32
+ export const COMMANDS = {
33
+ "grida-fx": "grida-fx",
34
+ grida: "grida fx",
35
+ };
17
36
  /** Signals passed on to the engine. */
18
37
  export const FORWARDED = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
19
38
  /** The same signal again within this many milliseconds is a repeat of one interruption. */
20
39
  export const REPEAT_MS = 250;
21
40
  /** The exit status of the command's own failures (no engine for this machine, …). */
22
41
  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. */
42
+ /** `@grida/fx`'s `grida-fx` command: {@link launch} as the `grida-fx` invoker. */
25
43
  export function main(argv) {
44
+ launch(argv, { invoker: "grida-fx" });
45
+ }
46
+ /** Runs the engine with `argv` (the arguments after the command's name, and after `fx` for
47
+ * `grida fx`) and ends this process as the engine ended. Throws a `TypeError`, before anything
48
+ * runs, for an invoker this contract does not know. */
49
+ export function launch(argv, options = {}) {
50
+ const invoker = options.invoker ?? "grida-fx";
51
+ if (!Object.hasOwn(COMMANDS, invoker)) {
52
+ throw new TypeError(`unknown invoker ${JSON.stringify(invoker)}: @grida/fx/cli contract ${HOST_CONTRACT} ` +
53
+ `knows ${Object.keys(COMMANDS).join(", ")}`);
54
+ }
55
+ const command = COMMANDS[invoker];
56
+ const env = { ...process.env, [INVOKER_VARIABLE]: invoker };
26
57
  let engine;
27
58
  try {
28
- engine = binary();
59
+ engine = binary({ env });
29
60
  }
30
61
  catch (error) {
31
- process.stderr.write(`grida-fx: ${error instanceof Error ? error.message : String(error)}\n`);
62
+ process.stderr.write(`${command}: ${error instanceof Error ? error.message : String(error)}\n`);
32
63
  process.exit(FAILED);
33
64
  }
34
65
  const unix = process.platform !== "win32";
35
- const child = spawn(engine, [...argv], { stdio: "inherit", detached: unix });
66
+ const child = spawn(engine, [...argv], { stdio: "inherit", detached: unix, env });
36
67
  const listeners = forwardSignals(child, unix);
37
68
  child.once("error", (error) => {
38
69
  listeners.remove();
39
- process.stderr.write(`grida-fx: could not start the engine (${engine}): ${error.message}\n`);
70
+ process.stderr.write(`${command}: could not start the engine (${engine}): ${error.message}\n`);
40
71
  process.exit(FAILED);
41
72
  });
42
73
  child.once("exit", (status, signal) => {
@@ -0,0 +1,42 @@
1
+ /** Exact-target run control through the engine's public command line (spec/control.md).
2
+ * The engine resolves and authenticates the owner and verifies cleanup. */
3
+ import { type CallOptions, type Exit } from "./engine.js";
4
+ import { FxError } from "./errors.js";
5
+ /** The engine's fx-run-control-v1 response. Wire fields retain lower_snake_case names. */
6
+ export interface RunControlResult {
7
+ readonly kind: "fx-run-control-v1";
8
+ readonly operation: "inspect" | "cancel";
9
+ readonly invocation_id: string | null;
10
+ readonly outcome: "inspected" | "accepted" | "already_requested" | "already_terminal" | "finishing" | "completed" | "error";
11
+ readonly request_status: "accepted" | "not_accepted" | "unknown";
12
+ readonly recorded_state: "planned" | "unfinished" | "succeeded" | "failed" | "cancelled" | "unknown";
13
+ readonly cleanup: "pending" | "complete" | "unknown";
14
+ readonly external_completion: "not_verified";
15
+ readonly code?: string;
16
+ readonly message?: string;
17
+ readonly availability?: "available" | "unavailable" | "unsupported" | "unknown";
18
+ readonly can_cancel?: boolean;
19
+ }
20
+ /** A structured control failure, retaining its stable code and complete result.
21
+ * Unsupported binaries and malformed responses reject with ordinary FxError. */
22
+ export declare class RunControlError extends FxError {
23
+ readonly result: RunControlResult;
24
+ readonly code: string;
25
+ constructor(result: RunControlResult, done: Exit);
26
+ }
27
+ /** Options of cancel. Relative targets start at cwd, as on the command line. */
28
+ export interface CancelOptions extends CallOptions {
29
+ /** Expected-current invocation guard. A successor is refused, never automatically selected. */
30
+ readonly invocation?: string;
31
+ /** Verify local completion, with a default timeout of thirty seconds. */
32
+ readonly wait?: boolean;
33
+ /** Positive integer followed by s, m or h (e.g. "5s"); requires wait: true. This bounds
34
+ * waiting only and never terminates the run or retracts an accepted cancellation. */
35
+ readonly timeout?: string;
36
+ }
37
+ /** Inspect one exact target's invocation and authenticated control availability. A bare
38
+ * workflow ID is refused by the engine; no latest-run selection is implied. */
39
+ export declare function inspectControl(run: string, options?: CallOptions): Promise<RunControlResult>;
40
+ /** Request cancellation of one invocation. Aborting this control client only ends its wait;
41
+ * an accepted cancellation remains effective in the separately owned runner. */
42
+ export declare function cancel(run: string, options?: CancelOptions): Promise<RunControlResult>;
@@ -0,0 +1,160 @@
1
+ /** Exact-target run control through the engine's public command line (spec/control.md).
2
+ * The engine resolves and authenticates the owner and verifies cleanup. */
3
+ import { call, failure } from "./engine.js";
4
+ import { FxError } from "./errors.js";
5
+ /** A structured control failure, retaining its stable code and complete result.
6
+ * Unsupported binaries and malformed responses reject with ordinary FxError. */
7
+ export class RunControlError extends FxError {
8
+ result;
9
+ code;
10
+ constructor(result, done) {
11
+ super(result.message ?? `run control failed: ${result.code}`, {
12
+ exitCode: done.status,
13
+ signal: done.signal,
14
+ stdout: done.stdout,
15
+ stderr: done.stderr,
16
+ args: done.args,
17
+ });
18
+ this.name = "RunControlError";
19
+ this.result = result;
20
+ this.code = result.code ?? "";
21
+ }
22
+ }
23
+ const MAX_RESULT_BYTES = 16 * 1024;
24
+ const REQUIRED = [
25
+ "kind", "operation", "invocation_id", "outcome", "request_status", "recorded_state",
26
+ "cleanup", "external_completion",
27
+ ];
28
+ const OPTIONAL = ["code", "message", "availability", "can_cancel"];
29
+ const ERROR_STATUS = {
30
+ unavailable: 1,
31
+ wait_timeout: 1,
32
+ owner_lost: 1,
33
+ acknowledgment_unknown: 1,
34
+ completion_unverified: 1,
35
+ invalid_target: 2,
36
+ ambiguous_target: 2,
37
+ invocation_mismatch: 2,
38
+ unsupported_version: 2,
39
+ unauthorized: 2,
40
+ record_error: 2,
41
+ };
42
+ const ENUMS = {
43
+ outcome: ["inspected", "accepted", "already_requested", "already_terminal", "finishing", "completed", "error"],
44
+ request_status: ["accepted", "not_accepted", "unknown"],
45
+ recorded_state: ["planned", "unfinished", "succeeded", "failed", "cancelled", "unknown"],
46
+ cleanup: ["pending", "complete", "unknown"],
47
+ external_completion: ["not_verified"],
48
+ };
49
+ function target(run) {
50
+ if (typeof run !== "string" || run === "" || run.startsWith("-") || run.includes("\0")) {
51
+ throw new TypeError("run is a nonempty explicit run folder or exact WORKFLOW_ID/NAME, without a leading '-' or NUL");
52
+ }
53
+ return run;
54
+ }
55
+ function cancelOptions(options) {
56
+ if (options.wait !== undefined && typeof options.wait !== "boolean") {
57
+ throw new TypeError("wait is true or false");
58
+ }
59
+ const args = ["--source=sdk"];
60
+ if (options.invocation !== undefined) {
61
+ if (typeof options.invocation !== "string" || options.invocation === "" || options.invocation.includes("\0")) {
62
+ throw new TypeError("invocation is a nonempty invocation ID");
63
+ }
64
+ args.push(`--invocation=${options.invocation}`);
65
+ }
66
+ if (options.wait === true) {
67
+ args.push("--wait");
68
+ }
69
+ if (options.timeout !== undefined) {
70
+ if (options.wait !== true) {
71
+ throw new TypeError("timeout requires wait: true");
72
+ }
73
+ if (typeof options.timeout !== "string" || !/^[0-9]+[smh]$/.test(options.timeout) || /^0+[smh]$/.test(options.timeout)) {
74
+ throw new TypeError("timeout is a positive integer followed by s, m or h");
75
+ }
76
+ args.push(`--timeout=${options.timeout}`);
77
+ }
78
+ return args;
79
+ }
80
+ function parseResult(done, operation, wait) {
81
+ if (Buffer.byteLength(done.stdout, "utf8") > MAX_RESULT_BYTES) {
82
+ throw new Error("oversized result");
83
+ }
84
+ const value = JSON.parse(done.stdout);
85
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
86
+ throw new Error("not an object");
87
+ }
88
+ const data = value;
89
+ if (!REQUIRED.every((name) => name in data)) {
90
+ throw new Error("missing fields");
91
+ }
92
+ for (const [name, field] of Object.entries(data)) {
93
+ if (![...REQUIRED, ...OPTIONAL].includes(name))
94
+ continue;
95
+ if (name === "can_cancel") {
96
+ if (typeof field !== "boolean")
97
+ throw new Error("can_cancel is not boolean");
98
+ }
99
+ else if (name !== "invocation_id" || field !== null) {
100
+ if (typeof field !== "string" || field === "" || field.length > (name === "invocation_id" ? 256 : 4096)) {
101
+ throw new Error(`invalid ${name}`);
102
+ }
103
+ }
104
+ }
105
+ if (data.kind !== "fx-run-control-v1" || data.operation !== operation) {
106
+ throw new Error("incompatible result");
107
+ }
108
+ if (Object.entries(ENUMS).some(([name, values]) => !values.includes(data[name]))) {
109
+ throw new Error("unknown result enum");
110
+ }
111
+ if (data.outcome === "completed" && (data.invocation_id === null || data.cleanup !== "complete"
112
+ || !["succeeded", "failed", "cancelled"].includes(data.recorded_state))) {
113
+ throw new Error("completion lacks invocation, cleanup or terminal evidence");
114
+ }
115
+ if (operation === "inspect") {
116
+ if (!["available", "unavailable", "unsupported", "unknown"].includes(data.availability) || typeof data.can_cancel !== "boolean") {
117
+ throw new Error("missing or unknown inspection fields");
118
+ }
119
+ }
120
+ else if ("availability" in data || "can_cancel" in data) {
121
+ throw new Error("inspection fields on cancellation");
122
+ }
123
+ if (data.outcome === "error") {
124
+ if (ERROR_STATUS[data.code] !== done.status || !("message" in data)) {
125
+ throw new Error("unknown code or mismatched error status");
126
+ }
127
+ }
128
+ else {
129
+ const allowed = operation === "inspect" ? ["inspected"] : wait ? ["completed"] : ["accepted", "already_requested", "already_terminal", "finishing"];
130
+ if (done.status !== 0 || !allowed.includes(data.outcome) || "code" in data || "message" in data) {
131
+ throw new Error("mismatched success outcome or status");
132
+ }
133
+ }
134
+ return data;
135
+ }
136
+ async function control(operation, run, options, args, wait = false) {
137
+ const verb = operation === "inspect" ? "inspect" : "cancel";
138
+ const done = await call([verb, target(run), ...args, "--json"], options);
139
+ let result;
140
+ try {
141
+ result = parseResult(done, operation, wait);
142
+ }
143
+ catch {
144
+ throw failure(verb, done);
145
+ }
146
+ if (result.outcome === "error") {
147
+ throw new RunControlError(result, done);
148
+ }
149
+ return result;
150
+ }
151
+ /** Inspect one exact target's invocation and authenticated control availability. A bare
152
+ * workflow ID is refused by the engine; no latest-run selection is implied. */
153
+ export async function inspectControl(run, options = {}) {
154
+ return control("inspect", run, options, ["--control"]);
155
+ }
156
+ /** Request cancellation of one invocation. Aborting this control client only ends its wait;
157
+ * an accepted cancellation remains effective in the separately owned runner. */
158
+ export async function cancel(run, options = {}) {
159
+ return control("cancel", run, options, cancelOptions(options), options.wait === true);
160
+ }
@@ -79,12 +79,16 @@ export interface Graph {
79
79
  };
80
80
  };
81
81
  instances: GraphInstance[];
82
- /** Repeats whose instances a later phase expands, with their worst case. */
82
+ /** Repeats whose instances a later phase expands, with their worst case. `step` is the
83
+ * declared step's path and `waiting_on` what its list needs; both are absent in documents
84
+ * written before they existed. */
83
85
  pending: {
84
86
  path: string;
87
+ step?: string;
85
88
  max: number;
86
89
  phase: number;
87
90
  high_usd: number;
91
+ waiting_on?: string[];
88
92
  }[];
89
93
  estimate: PriceRange & {
90
94
  ceiling_usd: number | null;
package/dist/engine.d.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * The engine runs in the caller's process group, so a Ctrl-C at the terminal reaches it as it
5
5
  * reaches the caller, and it stops a run as it would on its own: do not also abort the call on
6
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.
7
+ * code: a run gets one idempotent SIGTERM and retains ownership until engine cleanup ends, without
8
+ * an automatic force deadline. Other commands retain their ten-second SIGKILL fallback.
8
9
  *
9
10
  * A call ends when the engine exits, not when the last holder of its pipes closes them: a process
10
11
  * a node body started may outlive the engine on purpose.
@@ -18,8 +19,8 @@ export interface CallOptions {
18
19
  /** Variables over `process.env` for the engine (`undefined` removes one), e.g.
19
20
  * `GRIDA_FX_PYTHON`. `GRIDA_FX_BIN` here also chooses the binary. */
20
21
  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. */
22
+ /** Interrupts the engine once. A run waits for engine cleanup before rejecting with the
23
+ * signal's reason; other commands retain a ten-second force fallback. */
23
24
  readonly signal?: AbortSignal;
24
25
  }
25
26
  /** How a call ended. */
package/dist/engine.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * The engine runs in the caller's process group, so a Ctrl-C at the terminal reaches it as it
5
5
  * reaches the caller, and it stops a run as it would on its own: do not also abort the call on
6
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.
7
+ * code: a run gets one idempotent SIGTERM and retains ownership until engine cleanup ends, without
8
+ * an automatic force deadline. Other commands retain their ten-second SIGKILL fallback.
8
9
  *
9
10
  * A call ends when the engine exits, not when the last holder of its pipes closes them: a process
10
11
  * a node body started may outlive the engine on purpose.
@@ -16,16 +17,22 @@ import { FxError } from "./errors.js";
16
17
  export const USAGE_OR_ERROR = 2;
17
18
  /** The exit status of an interrupted command. */
18
19
  export const INTERRUPTED = 130;
19
- /** How long a stopped engine has to exit before it is killed. */
20
+ /** The stop grace for non-run commands. Run cleanup has no automatic force deadline. */
20
21
  const STOP_GRACE_MS = 10_000;
21
22
  /** How long the pipes are still read once the engine has exited. */
22
23
  const DRAIN_MS = 500;
23
- /** How the engine prefixes an error on stderr. */
24
+ /** How the engine prefixes an error on stderr: the SDK always runs it as the `grida-fx` invoker
25
+ * (spec/distribution.md §2), whichever command started this process. */
24
26
  const ERROR_PREFIX = "grida-fx: ";
25
27
  /** Runs the engine with `args` and collects what it prints. */
26
28
  export async function call(args, options = {}) {
27
29
  const cwd = options.cwd ?? process.cwd();
28
30
  const env = mergeEnvironment(options.env);
31
+ // A value inherited from a launcher (a node body run under `grida fx`) would change the prefix.
32
+ env.GRIDA_FX_INVOKER = "grida-fx";
33
+ if (args[0] === "run") {
34
+ env.GRIDA_FX_CANCEL_SOURCE = "sdk";
35
+ }
29
36
  const engine = findEngine(machineLookup(env, cwd));
30
37
  options.signal?.throwIfAborted();
31
38
  return new Promise((resolvePromise, reject) => {
@@ -39,13 +46,21 @@ export async function call(args, options = {}) {
39
46
  child.stdout.on("data", (chunk) => stdout.push(chunk));
40
47
  child.stderr.on("data", (chunk) => stderr.push(chunk));
41
48
  let killTimer;
49
+ let interrupted = false;
42
50
  const onAbort = () => {
43
- if (child.exitCode === null && child.signalCode === null) {
44
- child.kill("SIGINT");
45
- killTimer = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
51
+ if (!interrupted && child.exitCode === null && child.signalCode === null) {
52
+ interrupted = true;
53
+ child.kill(args[0] === "run" ? "SIGTERM" : "SIGINT");
54
+ if (args[0] !== "run") {
55
+ killTimer = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
56
+ }
46
57
  }
47
58
  };
48
59
  options.signal?.addEventListener("abort", onAbort, { once: true });
60
+ // An abort between the initial check and listener registration must still stop this child.
61
+ if (options.signal?.aborted) {
62
+ onAbort();
63
+ }
49
64
  const finish = () => {
50
65
  options.signal?.removeEventListener("abort", onAbort);
51
66
  if (killTimer !== undefined) {
package/dist/index.d.ts CHANGED
@@ -3,9 +3,12 @@
3
3
  * the engine package for the machine); this package drives it: plan, price and run workflows,
4
4
  * read their runs, and write workflow documents.
5
5
  */
6
- export declare const version = "0.1.0";
6
+ export declare const version = "0.2.0";
7
7
  export { engineVersion, expand, identity, type InspectOptions, inspect, Plan, PlanRefused, plan, price, project, RunFile, RunResult, type RunValue, run, } from "./api.js";
8
8
  export { BINARY_VARIABLE, type BinaryOptions, binary, type Environment } from "./binary.js";
9
+ export { cancel, type CancelOptions, inspectControl, RunControlError, type RunControlResult } from "./control.js";
10
+ export { loadRun, type ObservedRunEvent, type RunEventBatch, type RunEventsOptions, type RunFollowOptions, RunObservationError, type RunObservationFailure, type RunReadOptions, RunRecord, type RunSnapshot, RunExportError, type RunExportFailure, type RunExportOptions, type RunExportResult, } from "./record.js";
11
+ export { listRuns, type ListRunsOptions, type RemoveRunsOptions, removeRuns, type RunEntry, type RunPlacement, type RunRemoval, type RunRemovalEntry, RunRemovalError, type RunRemovalFailure, type RunRemovalSkip, type RunState, } from "./runs.js";
9
12
  export type { Encoded, FileRef, Graph, GraphInstance, Identities, InspectedStep, Inspection, Price, PricePhase, PriceRange, Problem, ProjectedInstance, Projection, RunEvent, RunFinished, WithValue, } from "./documents.js";
10
13
  export type { CallOptions } from "./engine.js";
11
14
  export { FxError, type FxErrorDetails } from "./errors.js";
package/dist/index.js CHANGED
@@ -3,9 +3,12 @@
3
3
  * the engine package for the machine); this package drives it: plan, price and run workflows,
4
4
  * read their runs, and write workflow documents.
5
5
  */
6
- export const version = "0.1.0";
6
+ export const version = "0.2.0";
7
7
  export { engineVersion, expand, identity, inspect, Plan, PlanRefused, plan, price, project, RunFile, RunResult, run, } from "./api.js";
8
8
  export { BINARY_VARIABLE, binary } from "./binary.js";
9
+ export { cancel, inspectControl, RunControlError } from "./control.js";
10
+ export { loadRun, RunObservationError, RunRecord, RunExportError, } from "./record.js";
11
+ export { listRuns, removeRuns, RunRemovalError, } from "./runs.js";
9
12
  export { FxError } from "./errors.js";
10
13
  export { platformFor, platforms } from "./platforms.js";
11
14
  export { expr, workflow, } from "./workflow.js";