@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 +40 -5
- package/dist/api.d.ts +2 -0
- package/dist/api.js +7 -2
- package/dist/cli.d.ts +32 -5
- package/dist/cli.js +40 -9
- package/dist/control.d.ts +42 -0
- package/dist/control.js +160 -0
- package/dist/documents.d.ts +5 -1
- package/dist/engine.d.ts +4 -3
- package/dist/engine.js +21 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/record.d.ts +107 -0
- package/dist/record.js +246 -0
- package/dist/request.d.ts +4 -0
- package/dist/request.js +14 -3
- package/dist/runs.d.ts +100 -0
- package/dist/runs.js +233 -0
- package/package.json +10 -6
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`, `
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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>;
|
package/dist/control.js
ADDED
|
@@ -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
|
+
}
|
package/dist/documents.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
22
|
-
* signal's reason
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
45
|
-
|
|
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.
|
|
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.
|
|
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";
|