@grida/fx 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -7
- 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/dist/runs.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** A project's runs through the engine's public command line (spec/store.md §8 and §9): listing
|
|
2
|
+
* them, and removing named ones. The engine finds the project from cwd, selects, locks and
|
|
3
|
+
* removes; this module only reads what it prints. */
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { call, failure, USAGE_OR_ERROR } from "./engine.js";
|
|
7
|
+
import { FxError } from "./errors.js";
|
|
8
|
+
/** A removal the engine refused before removing anything, with its stable code, the RUN it
|
|
9
|
+
* names and the complete error document. Usage mistakes and malformed output reject with
|
|
10
|
+
* ordinary FxError; a refused or partial run does not reject (read each entry's `outcome`). */
|
|
11
|
+
export class RunRemovalError extends FxError {
|
|
12
|
+
code;
|
|
13
|
+
run;
|
|
14
|
+
result;
|
|
15
|
+
constructor(result, done) {
|
|
16
|
+
super(result.error.message, {
|
|
17
|
+
exitCode: done.status,
|
|
18
|
+
signal: done.signal,
|
|
19
|
+
stdout: done.stdout,
|
|
20
|
+
stderr: done.stderr,
|
|
21
|
+
args: done.args,
|
|
22
|
+
});
|
|
23
|
+
this.name = "RunRemovalError";
|
|
24
|
+
this.code = result.error.code;
|
|
25
|
+
this.run = result.error.run;
|
|
26
|
+
this.result = result;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const LIST_KIND = "fx-run-list-v1";
|
|
30
|
+
const REMOVAL_KIND = "fx-run-removal-v1";
|
|
31
|
+
const PLACEMENTS = ["allocated", "named", "explicit", "external"];
|
|
32
|
+
const STATES = [
|
|
33
|
+
"planned", "unfinished", "succeeded", "failed", "incomplete", "cancelled", "empty", "removing", "missing",
|
|
34
|
+
];
|
|
35
|
+
const PREVIEW_OUTCOMES = ["would_remove", "would_forget", "refused"];
|
|
36
|
+
const APPLIED_OUTCOMES = ["removed", "forgotten", "refused", "partial"];
|
|
37
|
+
const FAILED_OUTCOMES = ["refused", "partial"];
|
|
38
|
+
const RUN_CODES = ["active", "holds_pick", "changed", "not_removable", "io"];
|
|
39
|
+
const REMOVAL_SKIPS = ["named", "explicit", "external", "holds_pick", "young", "holds_runs"];
|
|
40
|
+
const ERROR_CODES = ["invalid_target", "ambiguous_target", "not_a_run"];
|
|
41
|
+
function object(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function nullableText(value) {
|
|
45
|
+
return value === null || typeof value === "string";
|
|
46
|
+
}
|
|
47
|
+
function count(value) {
|
|
48
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
49
|
+
}
|
|
50
|
+
function entry(value, cwd) {
|
|
51
|
+
if (!object(value))
|
|
52
|
+
throw new Error("run is not an object");
|
|
53
|
+
const { folder, workflow, source, name, placement, state, created_at, charged_usd, stand_in, stopped } = value;
|
|
54
|
+
if (typeof folder !== "string" || folder === "" || folder.includes("\0"))
|
|
55
|
+
throw new Error("invalid folder");
|
|
56
|
+
if (!nullableText(workflow) || !nullableText(source) || !nullableText(name) || !nullableText(created_at)) {
|
|
57
|
+
throw new Error("invalid run text field");
|
|
58
|
+
}
|
|
59
|
+
if (!PLACEMENTS.includes(placement) || !STATES.includes(state)) {
|
|
60
|
+
throw new Error("unknown placement or state");
|
|
61
|
+
}
|
|
62
|
+
if (charged_usd !== null && (typeof charged_usd !== "number" || !Number.isFinite(charged_usd))) {
|
|
63
|
+
throw new Error("invalid charged_usd");
|
|
64
|
+
}
|
|
65
|
+
if (typeof stand_in !== "boolean" || (stopped !== undefined && typeof stopped !== "string")) {
|
|
66
|
+
throw new Error("invalid stand_in or stopped");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
folder,
|
|
70
|
+
runDir: resolve(cwd, folder),
|
|
71
|
+
workflow,
|
|
72
|
+
source,
|
|
73
|
+
name,
|
|
74
|
+
placement: placement,
|
|
75
|
+
state: state,
|
|
76
|
+
created_at,
|
|
77
|
+
charged_usd,
|
|
78
|
+
stand_in,
|
|
79
|
+
...(stopped !== undefined ? { stopped } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function parseList(done, cwd) {
|
|
83
|
+
if (done.status !== 0)
|
|
84
|
+
throw new Error("unexpected status");
|
|
85
|
+
const value = JSON.parse(done.stdout);
|
|
86
|
+
if (!object(value) || value.kind !== LIST_KIND || !Array.isArray(value.runs) || !Array.isArray(value.skipped)) {
|
|
87
|
+
throw new Error("incompatible result");
|
|
88
|
+
}
|
|
89
|
+
// What could not be read is not returned, so its entries are not read: a future code must
|
|
90
|
+
// not break listing.
|
|
91
|
+
return value.runs.map((run) => entry(run, cwd));
|
|
92
|
+
}
|
|
93
|
+
function removalEntry(value, cwd, applied) {
|
|
94
|
+
const run = entry(value, cwd);
|
|
95
|
+
const { outcome, code, message, freed_bytes, cache_bytes } = value;
|
|
96
|
+
if (!(applied ? APPLIED_OUTCOMES : PREVIEW_OUTCOMES).includes(outcome)) {
|
|
97
|
+
throw new Error("unknown or mismatched outcome");
|
|
98
|
+
}
|
|
99
|
+
if (code !== undefined && !RUN_CODES.includes(code))
|
|
100
|
+
throw new Error("unknown code");
|
|
101
|
+
if (message !== undefined && typeof message !== "string")
|
|
102
|
+
throw new Error("invalid message");
|
|
103
|
+
if (FAILED_OUTCOMES.includes(outcome) && (code === undefined || message === undefined)) {
|
|
104
|
+
throw new Error("refusal lacks code or message");
|
|
105
|
+
}
|
|
106
|
+
if (!count(freed_bytes) || !count(cache_bytes))
|
|
107
|
+
throw new Error("invalid byte counts");
|
|
108
|
+
return {
|
|
109
|
+
...run,
|
|
110
|
+
outcome: outcome,
|
|
111
|
+
...(code !== undefined ? { code: code } : {}),
|
|
112
|
+
...(message !== undefined ? { message } : {}),
|
|
113
|
+
freed_bytes,
|
|
114
|
+
cache_bytes,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** The error document of exit status 2, or null when stdout holds none. */
|
|
118
|
+
function removalFailure(done) {
|
|
119
|
+
let value;
|
|
120
|
+
try {
|
|
121
|
+
value = JSON.parse(done.stdout);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (!object(value) || value.kind !== REMOVAL_KIND || value.applied !== false || !object(value.error)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const { code, message, run } = value.error;
|
|
130
|
+
if (!ERROR_CODES.includes(code) || typeof message !== "string" || !nullableText(run)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return { kind: REMOVAL_KIND, applied: false, error: { code: code, message, run } };
|
|
134
|
+
}
|
|
135
|
+
function parseRemoval(done, cwd, preview) {
|
|
136
|
+
const value = JSON.parse(done.stdout);
|
|
137
|
+
if (!object(value) || value.kind !== REMOVAL_KIND || "error" in value || value.applied !== !preview) {
|
|
138
|
+
throw new Error("incompatible result");
|
|
139
|
+
}
|
|
140
|
+
const { runs, skipped, freed_bytes, cache_bytes } = value;
|
|
141
|
+
if (!Array.isArray(runs) || !object(skipped) || !count(freed_bytes) || !count(cache_bytes)) {
|
|
142
|
+
throw new Error("missing or invalid fields");
|
|
143
|
+
}
|
|
144
|
+
for (const [reason, passed] of Object.entries(skipped)) {
|
|
145
|
+
if (!REMOVAL_SKIPS.includes(reason) || !count(passed) || passed < 1)
|
|
146
|
+
throw new Error("invalid skipped");
|
|
147
|
+
}
|
|
148
|
+
const entries = runs.map((run) => removalEntry(run, cwd, !preview));
|
|
149
|
+
const failed = entries.some((run) => FAILED_OUTCOMES.includes(run.outcome));
|
|
150
|
+
if (done.status !== (failed ? 1 : 0))
|
|
151
|
+
throw new Error("mismatched status");
|
|
152
|
+
return {
|
|
153
|
+
applied: !preview,
|
|
154
|
+
runs: entries,
|
|
155
|
+
skipped: { ...skipped },
|
|
156
|
+
freed_bytes,
|
|
157
|
+
cache_bytes,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function callOptions(options, cwd) {
|
|
161
|
+
return {
|
|
162
|
+
cwd,
|
|
163
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
164
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** The runs of the project found from cwd, newest first. Reads only: no lock, no writes.
|
|
168
|
+
* Folders the engine could not read are left out. */
|
|
169
|
+
export async function listRuns(options = {}) {
|
|
170
|
+
const args = ["runs", "list"];
|
|
171
|
+
if (options.workflow !== undefined) {
|
|
172
|
+
if (typeof options.workflow !== "string" || options.workflow === "" || options.workflow.includes("\0")) {
|
|
173
|
+
throw new TypeError("workflow is a nonempty workflow id without NUL");
|
|
174
|
+
}
|
|
175
|
+
args.push(`--workflow=${options.workflow}`);
|
|
176
|
+
}
|
|
177
|
+
const cwd = base(options.cwd);
|
|
178
|
+
const done = await call([...args, "--json"], callOptions(options, cwd));
|
|
179
|
+
try {
|
|
180
|
+
return parseList(done, cwd);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw failure("runs list", done);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** The working directory as the engine sees it: its real path, since the engine prints folders
|
|
187
|
+
* relative to its canonical working directory (resolved lexically when it cannot be read). */
|
|
188
|
+
function base(cwd) {
|
|
189
|
+
const given = resolve(cwd ?? process.cwd());
|
|
190
|
+
try {
|
|
191
|
+
return realpathSync(given);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return given;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Remove exactly the named runs: run folders (relative to cwd) or `WORKFLOW_ID/NAME` of named
|
|
198
|
+
* runs; select them with {@link listRuns} first. Every RUN is resolved before anything is removed,
|
|
199
|
+
* and one that is not a run rejects with {@link RunRemovalError}. A run that is active, or the last
|
|
200
|
+
* holding a pick, is refused without rejecting: read each entry's `outcome`. Aborting a removal
|
|
201
|
+
* may leave a run `partial`; removing it again finishes it. */
|
|
202
|
+
export async function removeRuns(runs, options = {}) {
|
|
203
|
+
if (!Array.isArray(runs) || runs.length === 0
|
|
204
|
+
|| !runs.every((run) => typeof run === "string" && run !== "" && !run.includes("\0"))) {
|
|
205
|
+
throw new TypeError("runs is a nonempty array of nonempty run folders or WORKFLOW_ID/NAME, without NUL");
|
|
206
|
+
}
|
|
207
|
+
if (options.preview !== undefined && typeof options.preview !== "boolean") {
|
|
208
|
+
throw new TypeError("preview is true or false");
|
|
209
|
+
}
|
|
210
|
+
const preview = options.preview === true;
|
|
211
|
+
const cwd = base(options.cwd);
|
|
212
|
+
const done = await call(["runs", "remove", ...(preview ? [] : ["--yes"]), "--json", "--", ...runs], callOptions(options, cwd));
|
|
213
|
+
if (done.status === USAGE_OR_ERROR) {
|
|
214
|
+
const refused = removalFailure(done);
|
|
215
|
+
if (refused !== null)
|
|
216
|
+
throw new RunRemovalError(refused, done);
|
|
217
|
+
throw failure("runs remove", done);
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return parseRemoval(done, cwd, preview);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
if (preview)
|
|
224
|
+
throw failure("runs remove", done);
|
|
225
|
+
const details = {
|
|
226
|
+
exitCode: done.status, signal: done.signal, stdout: done.stdout, stderr: done.stderr, args: done.args,
|
|
227
|
+
};
|
|
228
|
+
const what = done.status === 0 || done.status === 1
|
|
229
|
+
? "grida-fx runs remove printed a result that could not be read"
|
|
230
|
+
: failure("runs remove", done).message;
|
|
231
|
+
throw new FxError(`${what}; the removal may already have taken effect`, details);
|
|
232
|
+
}
|
|
233
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grida/fx",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Grida FX for JavaScript: the grida-fx command, and an SDK to build workflows and drive the engine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"grida",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"default": "./dist/index.js"
|
|
34
34
|
},
|
|
35
|
+
"./cli": {
|
|
36
|
+
"types": "./dist/cli.d.ts",
|
|
37
|
+
"default": "./dist/cli.js"
|
|
38
|
+
},
|
|
35
39
|
"./package.json": "./package.json"
|
|
36
40
|
},
|
|
37
41
|
"files": [
|
|
@@ -42,13 +46,13 @@
|
|
|
42
46
|
],
|
|
43
47
|
"sideEffects": false,
|
|
44
48
|
"optionalDependencies": {
|
|
45
|
-
"@grida/fx-darwin-arm64": "0.1
|
|
46
|
-
"@grida/fx-darwin-x64": "0.1
|
|
47
|
-
"@grida/fx-linux-arm64-gnu": "0.1
|
|
48
|
-
"@grida/fx-linux-x64-gnu": "0.1
|
|
49
|
+
"@grida/fx-darwin-arm64": "0.2.1",
|
|
50
|
+
"@grida/fx-darwin-x64": "0.2.1",
|
|
51
|
+
"@grida/fx-linux-arm64-gnu": "0.2.1",
|
|
52
|
+
"@grida/fx-linux-x64-gnu": "0.2.1"
|
|
49
53
|
},
|
|
50
54
|
"publishConfig": {
|
|
51
55
|
"access": "public",
|
|
52
|
-
"tag": "
|
|
56
|
+
"tag": "latest"
|
|
53
57
|
}
|
|
54
58
|
}
|