@grida/fx 0.1.0-alpha.2 → 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.
@@ -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-alpha.2";
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-alpha.2";
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";
@@ -0,0 +1,107 @@
1
+ /** Saved evidence and independent observation; neither owns execution or a viewer. */
2
+ import type { InspectOptions } from "./api.js";
3
+ import type { Graph, Inspection, RunEvent } from "./documents.js";
4
+ import { type CallOptions, type Exit } from "./engine.js";
5
+ import { FxError } from "./errors.js";
6
+ /** The complete recorded event envelope. Unknown events and fields remain readable. */
7
+ export interface ObservedRunEvent extends RunEvent {
8
+ kind: "fx-run-events-v1";
9
+ invocation_id: string;
10
+ plan: string;
11
+ offset_ms: number;
12
+ }
13
+ export interface RunSnapshot {
14
+ kind: "fx-run-snapshot-v1";
15
+ cursor: string;
16
+ plan: Graph;
17
+ events: ObservedRunEvent[];
18
+ [field: string]: unknown;
19
+ }
20
+ export interface RunEventBatch {
21
+ kind: "fx-run-event-batch-v1";
22
+ cursor: string;
23
+ events: ObservedRunEvent[];
24
+ has_more: boolean;
25
+ [field: string]: unknown;
26
+ }
27
+ export interface RunObservationFailure {
28
+ kind: "fx-run-observation-error-v1";
29
+ code: string;
30
+ message: string;
31
+ [field: string]: unknown;
32
+ }
33
+ /** A reader failure with the engine's stable code and complete public error document. */
34
+ export declare class RunObservationError extends FxError {
35
+ readonly code: string;
36
+ readonly document: RunObservationFailure;
37
+ constructor(document: RunObservationFailure, done: Exit);
38
+ }
39
+ export interface RunReadOptions {
40
+ /** Stops this reader only; it never cancels the separately owned workflow execution. */
41
+ readonly signal?: AbortSignal;
42
+ }
43
+ export interface RunExportOptions extends RunReadOptions {
44
+ /** Include recorded values after deliberate disclosure review. Default: false. */
45
+ readonly includeValues?: boolean;
46
+ /** Include recorded diagnostic messages. Default: false. */
47
+ readonly includeDiagnostics?: boolean;
48
+ }
49
+ /** The engine's wire receipt. Counts cover included artifacts, excluding manifest.json. */
50
+ export interface RunExportResult {
51
+ kind: "fx-run-export-result-v1";
52
+ manifest_sha256: string;
53
+ output: string;
54
+ files: number;
55
+ bytes: number;
56
+ omitted_artifacts: {
57
+ digest: string;
58
+ reason: string;
59
+ }[];
60
+ [field: string]: unknown;
61
+ }
62
+ export interface RunExportFailure {
63
+ kind: "fx-run-export-error-v1";
64
+ code: string;
65
+ message: string;
66
+ [field: string]: unknown;
67
+ }
68
+ export declare class RunExportError extends FxError {
69
+ readonly code: string;
70
+ readonly document: RunExportFailure;
71
+ constructor(document: RunExportFailure, done: Exit);
72
+ }
73
+ export interface RunEventsOptions extends RunReadOptions {
74
+ /** Opaque acknowledged cursor; omit to read from the beginning. */
75
+ readonly after?: string;
76
+ /** Maximum events per batch, 1–1024 (default: 256). */
77
+ readonly limit?: number;
78
+ }
79
+ export interface RunFollowOptions extends RunEventsOptions {
80
+ /** Poll only after draining readable batches (default: 1000 milliseconds). */
81
+ readonly pollIntervalMs?: number;
82
+ }
83
+ /** One resolved run folder and its sampled inspection. Reads stay pinned to this folder.
84
+ * Inspection and later snapshots are independent samples; only each snapshot and its cursor
85
+ * share one consistent prefix. No exit code or process-liveness claim is synthesized. */
86
+ export declare class RunRecord {
87
+ readonly runDir: string;
88
+ readonly inspection: Inspection;
89
+ private readonly context;
90
+ constructor(runDir: string, inspection: Inspection, context?: CallOptions);
91
+ /** Capture the saved plan, complete readable event prefix and matching attachment cursor. */
92
+ snapshot(options?: RunReadOptions): Promise<RunSnapshot>;
93
+ /** Export terminal, inactive evidence to a new directory, without execution or publication.
94
+ * Relative destinations resolve against the caller's current directory. Values and
95
+ * diagnostics are withheld by default; included media still needs publication review. */
96
+ export(destination: string, options?: RunExportOptions): Promise<RunExportResult>;
97
+ /** Read bounded events after a cursor, or from the beginning when after is omitted. */
98
+ events(options?: RunEventsOptions): Promise<RunEventBatch>;
99
+ /** Follow nonempty batches continuously, including later resumes, until break or abort.
100
+ * Terminal events end an invocation, not this reader, and do not certify owner cleanup.
101
+ * Start after snapshot.cursor to attach without missing events. Reader errors surface;
102
+ * no missing-run waits, automatic reattachment or execution retries are performed. */
103
+ follow(options?: RunFollowOptions): AsyncGenerator<RunEventBatch>;
104
+ }
105
+ /** Resolve a folder or recorded selector once, without planning, executing or starting a service.
106
+ * Later reads use the pinned absolute folder. The loading signal is not retained by the record. */
107
+ export declare function loadRun(target: string, options?: InspectOptions): Promise<RunRecord>;
package/dist/record.js ADDED
@@ -0,0 +1,246 @@
1
+ /** Saved evidence and independent observation; neither owns execution or a viewer. */
2
+ import { resolve } from "node:path";
3
+ import { call, failure } from "./engine.js";
4
+ import { FxError } from "./errors.js";
5
+ /** A reader failure with the engine's stable code and complete public error document. */
6
+ export class RunObservationError extends FxError {
7
+ code;
8
+ document;
9
+ constructor(document, done) {
10
+ super(document.message, {
11
+ exitCode: done.status, signal: done.signal, stdout: done.stdout,
12
+ stderr: done.stderr, args: done.args,
13
+ });
14
+ this.name = "RunObservationError";
15
+ this.code = document.code;
16
+ this.document = document;
17
+ }
18
+ }
19
+ export class RunExportError extends FxError {
20
+ code;
21
+ document;
22
+ constructor(document, done) {
23
+ super(document.message, {
24
+ exitCode: done.status, signal: done.signal, stdout: done.stdout,
25
+ stderr: done.stderr, args: done.args,
26
+ });
27
+ this.name = "RunExportError";
28
+ this.code = document.code;
29
+ this.document = document;
30
+ }
31
+ }
32
+ function object(value) {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+ function text(value) {
36
+ return typeof value === "string" && value !== "";
37
+ }
38
+ function cursor(value) {
39
+ return text(value) && !value.includes("\0");
40
+ }
41
+ function event(value) {
42
+ return object(value) && value.kind === "fx-run-events-v1" && text(value.event)
43
+ && text(value.invocation_id) && typeof value.plan === "string"
44
+ && /^[a-f0-9]{64}$/.test(value.plan) && Number.isSafeInteger(value.offset_ms)
45
+ && value.offset_ms >= 0;
46
+ }
47
+ function exportResult(done) {
48
+ let value;
49
+ try {
50
+ value = JSON.parse(done.stdout);
51
+ }
52
+ catch {
53
+ throw failure("export", done);
54
+ }
55
+ if (done.status === 2 && object(value) && value.kind === "fx-run-export-error-v1"
56
+ && text(value.code) && text(value.message)) {
57
+ throw new RunExportError(value, done);
58
+ }
59
+ if (done.status !== 0 || !object(value) || value.kind !== "fx-run-export-result-v1"
60
+ || typeof value.manifest_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.manifest_sha256)
61
+ || !cursor(value.output) || !Number.isSafeInteger(value.files) || value.files < 0
62
+ || !Number.isSafeInteger(value.bytes) || value.bytes < 0
63
+ || !Array.isArray(value.omitted_artifacts) || !value.omitted_artifacts.every((item) => object(item) && typeof item.digest === "string" && /^[a-f0-9]{64}$/.test(item.digest) && text(item.reason))) {
64
+ throw failure("export", done);
65
+ }
66
+ return value;
67
+ }
68
+ function response(done, snapshot, options = {}) {
69
+ let value;
70
+ try {
71
+ value = JSON.parse(done.stdout);
72
+ }
73
+ catch {
74
+ throw failure("observe", done);
75
+ }
76
+ if (done.status === 2 && object(value) && value.kind === "fx-run-observation-error-v1"
77
+ && text(value.code) && text(value.message)) {
78
+ throw new RunObservationError(value, done);
79
+ }
80
+ if (done.status !== 0 || !object(value) || !cursor(value.cursor)
81
+ || !Array.isArray(value.events) || !value.events.every(event)) {
82
+ throw failure("observe", done);
83
+ }
84
+ if (snapshot) {
85
+ if (value.kind !== "fx-run-snapshot-v1" || !object(value.plan)
86
+ || value.plan.kind !== "fx-graph-v1")
87
+ throw failure("observe", done);
88
+ return value;
89
+ }
90
+ if (value.kind !== "fx-run-event-batch-v1" || typeof value.has_more !== "boolean"
91
+ || value.events.length > (options.limit ?? 256)
92
+ || (value.events.length === 0 && (value.has_more || (options.after !== undefined && value.cursor !== options.after)))
93
+ || (value.events.length > 0 && value.cursor === options.after)) {
94
+ throw failure("observe", done);
95
+ }
96
+ return value;
97
+ }
98
+ function eventArgs(options) {
99
+ const args = [];
100
+ if (options.after !== undefined) {
101
+ if (!text(options.after) || options.after.includes("\0")) {
102
+ throw new TypeError("after is a nonempty opaque cursor without NUL");
103
+ }
104
+ args.push(`--after=${options.after}`);
105
+ }
106
+ if (options.limit !== undefined) {
107
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1024) {
108
+ throw new TypeError("limit is an integer from 1 to 1024");
109
+ }
110
+ args.push(`--limit=${options.limit}`);
111
+ }
112
+ return args;
113
+ }
114
+ function sleep(milliseconds, signal) {
115
+ signal?.throwIfAborted();
116
+ return new Promise((done, reject) => {
117
+ const finish = () => { signal?.removeEventListener("abort", aborted); done(); };
118
+ const timer = setTimeout(finish, milliseconds);
119
+ const aborted = () => {
120
+ clearTimeout(timer);
121
+ signal?.removeEventListener("abort", aborted);
122
+ reject(signal?.reason);
123
+ };
124
+ signal?.addEventListener("abort", aborted, { once: true });
125
+ });
126
+ }
127
+ /** One resolved run folder and its sampled inspection. Reads stay pinned to this folder.
128
+ * Inspection and later snapshots are independent samples; only each snapshot and its cursor
129
+ * share one consistent prefix. No exit code or process-liveness claim is synthesized. */
130
+ export class RunRecord {
131
+ runDir;
132
+ inspection;
133
+ context;
134
+ constructor(runDir, inspection, context = {}) {
135
+ this.runDir = resolve(context.cwd ?? process.cwd(), runDir);
136
+ this.inspection = inspection;
137
+ this.context = {
138
+ cwd: resolve(context.cwd ?? process.cwd()),
139
+ ...(context.env !== undefined ? { env: { ...context.env } } : {}),
140
+ };
141
+ }
142
+ /** Capture the saved plan, complete readable event prefix and matching attachment cursor. */
143
+ async snapshot(options = {}) {
144
+ const done = await call(["observe", this.runDir, "--snapshot"], {
145
+ ...this.context, ...(options.signal !== undefined ? { signal: options.signal } : {}),
146
+ });
147
+ return response(done, true);
148
+ }
149
+ /** Export terminal, inactive evidence to a new directory, without execution or publication.
150
+ * Relative destinations resolve against the caller's current directory. Values and
151
+ * diagnostics are withheld by default; included media still needs publication review. */
152
+ async export(destination, options = {}) {
153
+ if (!cursor(destination))
154
+ throw new TypeError("destination is a nonempty filesystem path without NUL");
155
+ for (const option of [options.includeValues, options.includeDiagnostics]) {
156
+ if (option !== undefined && typeof option !== "boolean")
157
+ throw new TypeError("disclosure options are true or false");
158
+ }
159
+ const args = ["export", this.runDir, `--out=${resolve(destination)}`, "--json"];
160
+ if (options.includeValues === true)
161
+ args.push("--include-values");
162
+ if (options.includeDiagnostics === true)
163
+ args.push("--include-diagnostics");
164
+ const done = await call(args, {
165
+ ...this.context, ...(options.signal !== undefined ? { signal: options.signal } : {}),
166
+ });
167
+ return exportResult(done);
168
+ }
169
+ /** Read bounded events after a cursor, or from the beginning when after is omitted. */
170
+ async events(options = {}) {
171
+ const args = eventArgs(options);
172
+ const done = await call(["observe", this.runDir, ...args], {
173
+ ...this.context, ...(options.signal !== undefined ? { signal: options.signal } : {}),
174
+ });
175
+ return response(done, false, options);
176
+ }
177
+ /** Follow nonempty batches continuously, including later resumes, until break or abort.
178
+ * Terminal events end an invocation, not this reader, and do not certify owner cleanup.
179
+ * Start after snapshot.cursor to attach without missing events. Reader errors surface;
180
+ * no missing-run waits, automatic reattachment or execution retries are performed. */
181
+ async *follow(options = {}) {
182
+ eventArgs(options);
183
+ const interval = options.pollIntervalMs ?? 1000;
184
+ if (!Number.isFinite(interval) || interval <= 0 || interval > 2_147_483_647) {
185
+ throw new TypeError("pollIntervalMs is positive, finite and at most 2147483647");
186
+ }
187
+ let after = options.after;
188
+ for (;;) {
189
+ options.signal?.throwIfAborted();
190
+ const batch = await this.events({
191
+ ...(after !== undefined ? { after } : {}),
192
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
193
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
194
+ });
195
+ // A slow consumer or one mutating its received document cannot change our position.
196
+ after = batch.cursor;
197
+ const more = batch.has_more;
198
+ if (batch.events.length > 0)
199
+ yield batch;
200
+ if (!more)
201
+ await sleep(interval, options.signal);
202
+ }
203
+ }
204
+ }
205
+ /** Resolve a folder or recorded selector once, without planning, executing or starting a service.
206
+ * Later reads use the pinned absolute folder. The loading signal is not retained by the record. */
207
+ export async function loadRun(target, options = {}) {
208
+ if (!text(target) || target.startsWith("-") || target.includes("\0")) {
209
+ throw new TypeError("target is a nonempty saved-run selector without a leading '-' or NUL");
210
+ }
211
+ if (options.verify !== undefined && typeof options.verify !== "boolean") {
212
+ throw new TypeError("verify is true or false");
213
+ }
214
+ const cwd = resolve(options.cwd ?? process.cwd());
215
+ const done = await call(["inspect", target, "--json", ...(options.verify === true ? ["--verify"] : [])], {
216
+ cwd,
217
+ ...(options.env !== undefined ? { env: options.env } : {}),
218
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
219
+ });
220
+ if (done.status !== 0 && !(options.verify === true && done.status === 1)) {
221
+ throw failure("inspect", done);
222
+ }
223
+ let inspection;
224
+ try {
225
+ inspection = JSON.parse(done.stdout);
226
+ }
227
+ catch {
228
+ throw failure("inspect", done);
229
+ }
230
+ if (!object(inspection) || !object(inspection.run))
231
+ throw failure("inspect", done);
232
+ if (done.status === 1) {
233
+ const verification = inspection.verification;
234
+ if (!object(verification) || verification.verified !== false
235
+ || !Array.isArray(verification.problems) || verification.problems.length === 0
236
+ || !verification.problems.every(text))
237
+ throw failure("inspect", done);
238
+ }
239
+ const folder = inspection.run.folder;
240
+ if (!text(folder) || folder.includes("\0")) {
241
+ throw failure("inspect", done);
242
+ }
243
+ return new RunRecord(resolve(cwd, folder), inspection, {
244
+ cwd, ...(options.env !== undefined ? { env: options.env } : {}),
245
+ });
246
+ }
package/dist/request.d.ts CHANGED
@@ -29,6 +29,10 @@ export interface RunOptions extends PlanOptions {
29
29
  /** The run folder, relative to `cwd` (`--run`); a folder holding a run of the same plan is
30
30
  * resumed. Default: a new folder under the project's runs folder. */
31
31
  readonly runDir?: string;
32
+ /** Create a named run (`--name`); an existing name is refused by the engine. */
33
+ readonly name?: string;
34
+ /** Continue an existing named run (`--resume`) with the same target and inputs. */
35
+ readonly resume?: string;
32
36
  /** Copy outputs out after the run (`--deliver OUTPUT=PATH`): `{ output: path }`, with `{key}`
33
37
  * in the path for each element of a collection. */
34
38
  readonly deliver?: Readonly<Record<string, string>>;