clyops-jobs 0.3.0 → 0.5.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 +19 -3
- package/clyops-jobs.cjs +112 -10
- package/dist/artifacts.d.ts +4 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +1 -0
- package/dist/functions.d.ts +27 -5
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +30 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/queue.d.ts +12 -2
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +15 -2
- package/dist/records.d.ts +25 -5
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +8 -0
- package/dist/template.d.ts +3 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +1 -0
- package/dist/util.d.ts +7 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +6 -1
- package/package.json +3 -2
- package/src/artifacts.ts +3 -0
- package/src/functions.ts +55 -4
- package/src/queue.ts +23 -3
- package/src/records.ts +26 -3
- package/src/template.ts +2 -0
- package/src/util.ts +7 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ and no `node_modules`. It includes clyops-tools. `require('clyops-jobs')` loads
|
|
|
30
30
|
- `functions.NAME.script` is the tool, relative to a script root.
|
|
31
31
|
- `key` names the options block (default: the function name). Options are
|
|
32
32
|
`deepMerge(defaults[key], config[key])`, keyed like the tool's options (`out_dir`, `out-dir`)
|
|
33
|
-
and mapped onto its command line through `--help-json-schema` ([spec §13](
|
|
33
|
+
and mapped onto its command line through `--help-json-schema` ([spec §13](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#13-json-input-toargv)).
|
|
34
34
|
- `positionals_order` (optional) is `first` (default) or `last`, for wrappers needing a specific order. `--` is only emitted for positional values starting with `-`.
|
|
35
35
|
- `positionals` (optional) lists the tool's positional arguments explicitly; otherwise they're
|
|
36
36
|
options keyed by argument name.
|
|
@@ -63,14 +63,24 @@ record = transitionJobRecord(record, { status: 'done', completedAt: timestampIso
|
|
|
63
63
|
`runScriptFunction` options: `configRoot` (relative path options resolve against it),
|
|
64
64
|
`context`, `controlled` (options the trigger sets itself, default `['help']`), `defaultPositional`
|
|
65
65
|
(a template for the first argument when the config gives none, e.g. `'${media.path}'`),
|
|
66
|
-
`timeoutMs`, `env`, `cwd`, `signal`, `onStderr`. Its `command` shows secret options as `***`.
|
|
66
|
+
`timeoutMs`, `env`, `cwd`, `signal`, `onStderr`, `onProgress`, `onLog`. Its `command` shows secret options as `***`.
|
|
67
|
+
|
|
68
|
+
When the tool declares the well-known [roles](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#14-roles-on-file-descriptors),
|
|
69
|
+
the run wires them up: each `progress` object goes to `onProgress` (and the last one is the run's
|
|
70
|
+
`progress`), `log` lines to `onLog`, and the `meta` document is parsed into the run's `meta`.
|
|
71
|
+
`updateJobProgress(record, update)` puts an update on a record as `progress` (and its `stage`, if
|
|
72
|
+
any) without a history entry; the queue's tasks get it as `progress(update)`:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
queue.add('transcribe', ({ signal, progress }) => runScriptFunction(fn, { configRoot, context, signal, onProgress: progress }));
|
|
76
|
+
```
|
|
67
77
|
`configureLogging({module, warn, verbose})` routes warnings about config keys that match no option.
|
|
68
78
|
|
|
69
79
|
## Job records
|
|
70
80
|
|
|
71
81
|
```
|
|
72
82
|
{ job_id, ...fields, function, status, stage, started_at, updated_at, completed_at, error,
|
|
73
|
-
result_key, history }
|
|
83
|
+
result_key, progress?, history }
|
|
74
84
|
```
|
|
75
85
|
|
|
76
86
|
`status` moves `pending` → `processing` → `done` | `error`. `newJobId(seed, prefix = 'job')`,
|
|
@@ -93,3 +103,9 @@ ESM only, Node 20+.
|
|
|
93
103
|
A missing or unreadable configured stdin file rejects `runScriptFunction()`
|
|
94
104
|
with its input-file error. It fails that job and stops its tool; the host worker
|
|
95
105
|
stays alive.
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
Read the [Node guide](https://clyops.io/node-tools.html) and
|
|
110
|
+
[complete public API reference](https://clyops.io/reference/node/index.html).
|
|
111
|
+
Type declarations and documentation comments are included in the package.
|
package/clyops-jobs.cjs
CHANGED
|
@@ -41,6 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
newJobId: () => newJobId,
|
|
42
42
|
newJobRecord: () => newJobRecord,
|
|
43
43
|
objectValue: () => objectValue,
|
|
44
|
+
parseRoleJson: () => parseRoleJson,
|
|
44
45
|
pathParts: () => pathParts,
|
|
45
46
|
readJson: () => readJson,
|
|
46
47
|
renderConfigValue: () => renderConfigValue,
|
|
@@ -56,6 +57,7 @@ __export(index_exports, {
|
|
|
56
57
|
timestampIso: () => timestampIso,
|
|
57
58
|
transitionJobRecord: () => transitionJobRecord,
|
|
58
59
|
truthy: () => truthy,
|
|
60
|
+
updateJobProgress: () => updateJobProgress,
|
|
59
61
|
writeJson: () => writeJson
|
|
60
62
|
});
|
|
61
63
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -102,7 +104,18 @@ function copyFileEnsuringDir(source, destination) {
|
|
|
102
104
|
function pathParts(file) {
|
|
103
105
|
const name = (0, import_node_path.basename)(file);
|
|
104
106
|
const ext = (0, import_node_path.extname)(name);
|
|
105
|
-
return {
|
|
107
|
+
return {
|
|
108
|
+
/** Resolved filesystem path or HTTP endpoint path, according to the containing type. */
|
|
109
|
+
path: file,
|
|
110
|
+
/** Directory represented by this group or specified in .clyops settings. */
|
|
111
|
+
dir: (0, import_node_path.dirname)(file),
|
|
112
|
+
/** Program, command, option, or argument name as declared in its containing type. */
|
|
113
|
+
name,
|
|
114
|
+
/** Filename without its extension. */
|
|
115
|
+
basename: ext ? name.slice(0, -ext.length) : name,
|
|
116
|
+
/** Filename extension including the leading dot. */
|
|
117
|
+
ext: ext.replace(/^\./, "")
|
|
118
|
+
};
|
|
106
119
|
}
|
|
107
120
|
function objectValue(value) {
|
|
108
121
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -195,9 +208,8 @@ function loadSchema(file, opts = {}) {
|
|
|
195
208
|
// packages/tools/src/argv.ts
|
|
196
209
|
var import_node_fs3 = require("node:fs");
|
|
197
210
|
var import_node_path2 = require("node:path");
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
};
|
|
211
|
+
|
|
212
|
+
// packages/schema/src/names.ts
|
|
201
213
|
function inputKey(name) {
|
|
202
214
|
return name.replace(/-/g, "_");
|
|
203
215
|
}
|
|
@@ -209,6 +221,12 @@ function isBareFlag(option) {
|
|
|
209
221
|
const choices = [...option.choices].sort().join(",");
|
|
210
222
|
return option.isFlag && choices !== "false,true";
|
|
211
223
|
}
|
|
224
|
+
|
|
225
|
+
// packages/tools/src/argv.ts
|
|
226
|
+
var InputError = class extends Error {
|
|
227
|
+
/** HTTP status used by adapters to report invalid caller input. */
|
|
228
|
+
status = 400;
|
|
229
|
+
};
|
|
212
230
|
function isPathValued(validation, type) {
|
|
213
231
|
return type === "path" || validation === "path" || validation.startsWith("file:") || validation.startsWith("dir:");
|
|
214
232
|
}
|
|
@@ -326,6 +344,7 @@ function redactArgv(schema, argv) {
|
|
|
326
344
|
var import_node_child_process2 = require("node:child_process");
|
|
327
345
|
var import_node_stream = require("node:stream");
|
|
328
346
|
var import_node_string_decoder = require("node:string_decoder");
|
|
347
|
+
var ROLE_NAME = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
|
329
348
|
var ABORT_GRACE_MS = 200;
|
|
330
349
|
var STOP_WAIT_MS = 1e3;
|
|
331
350
|
function terminateTree(child, signal) {
|
|
@@ -348,10 +367,15 @@ function terminateTree(child, signal) {
|
|
|
348
367
|
}
|
|
349
368
|
function start(file, argv, opts = {}) {
|
|
350
369
|
const started = Date.now();
|
|
370
|
+
const roles = process.platform === "win32" ? [] : Object.entries(opts.roles ?? {});
|
|
371
|
+
for (const [name] of roles) if (!ROLE_NAME.test(name)) throw new Error(`Invalid role name '${name}'`);
|
|
372
|
+
const env = { ...opts.env ?? process.env };
|
|
373
|
+
delete env.CLYOPS_FDS;
|
|
374
|
+
if (roles.length) env.CLYOPS_FDS = roles.map(([name], i) => `${name}:${i + 3}`).join(",");
|
|
351
375
|
const child = (0, import_node_child_process2.spawn)(file, argv, {
|
|
352
376
|
cwd: opts.cwd,
|
|
353
|
-
env
|
|
354
|
-
stdio: [opts.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"],
|
|
377
|
+
env,
|
|
378
|
+
stdio: [opts.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe", ...roles.map(() => "pipe")],
|
|
355
379
|
// POSIX detached children lead a new session/group, still with owned pipes.
|
|
356
380
|
detached: process.platform !== "win32",
|
|
357
381
|
windowsHide: true
|
|
@@ -378,6 +402,26 @@ function start(file, argv, opts = {}) {
|
|
|
378
402
|
const mode = opts.stdout ?? "text";
|
|
379
403
|
const stdout = mode === "stream" ? () => Buffer.alloc(0) : collect(childOut, opts.onStdout);
|
|
380
404
|
const stderr = collect(childErr, opts.onStderr);
|
|
405
|
+
const roleOut = [];
|
|
406
|
+
const roleIn = [];
|
|
407
|
+
roles.forEach(([name, role], i) => {
|
|
408
|
+
const stream = child.stdio[i + 3];
|
|
409
|
+
if (role.input !== void 0) {
|
|
410
|
+
roleIn.push({ stream, input: role.input });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let partial = "";
|
|
414
|
+
const lines = role.onLine && ((chunk) => {
|
|
415
|
+
const parts = (partial + chunk).split("\n");
|
|
416
|
+
partial = parts.pop() ?? "";
|
|
417
|
+
for (const line of parts) role.onLine?.(line);
|
|
418
|
+
});
|
|
419
|
+
stream.once("end", () => {
|
|
420
|
+
if (partial) role.onLine?.(partial);
|
|
421
|
+
partial = "";
|
|
422
|
+
});
|
|
423
|
+
roleOut.push({ name, stream, text: collect(stream, lines || void 0) });
|
|
424
|
+
});
|
|
381
425
|
const result = new Promise((resolve5, reject) => {
|
|
382
426
|
let timedOut = false;
|
|
383
427
|
let failure;
|
|
@@ -406,6 +450,10 @@ function start(file, argv, opts = {}) {
|
|
|
406
450
|
}
|
|
407
451
|
}
|
|
408
452
|
child.stdin?.destroy();
|
|
453
|
+
for (const { stream, input } of roleIn) {
|
|
454
|
+
if (input instanceof import_node_stream.Readable) input.unpipe(stream);
|
|
455
|
+
stream.destroy();
|
|
456
|
+
}
|
|
409
457
|
};
|
|
410
458
|
const finish = async (exitCode, signal) => {
|
|
411
459
|
if (settled) return;
|
|
@@ -431,6 +479,7 @@ function start(file, argv, opts = {}) {
|
|
|
431
479
|
...mode === "buffer" ? { stdoutBuffer: out } : {},
|
|
432
480
|
stderr: stderr().toString("utf8"),
|
|
433
481
|
...truncated ? { truncated } : {},
|
|
482
|
+
...roleOut.length ? { roles: Object.fromEntries(roleOut.map((r) => [r.name, r.text().toString("utf8")])) } : {},
|
|
434
483
|
durationMs: Date.now() - started
|
|
435
484
|
});
|
|
436
485
|
};
|
|
@@ -447,7 +496,7 @@ function start(file, argv, opts = {}) {
|
|
|
447
496
|
}
|
|
448
497
|
deadline = setTimeout(() => {
|
|
449
498
|
treeStopped = terminateTree(child, "SIGKILL");
|
|
450
|
-
for (const stream of [childOut, childErr]) {
|
|
499
|
+
for (const stream of [childOut, childErr, ...roleOut.map((r) => r.stream)]) {
|
|
451
500
|
if (!stream.destroyed) {
|
|
452
501
|
stream.once("end", () => stream.destroy());
|
|
453
502
|
stream.push(null);
|
|
@@ -488,6 +537,13 @@ function start(file, argv, opts = {}) {
|
|
|
488
537
|
else source.pipe(child.stdin);
|
|
489
538
|
} else child.stdin.end(opts.stdin);
|
|
490
539
|
}
|
|
540
|
+
for (const { stream, input } of roleIn) {
|
|
541
|
+
stream.on("error", () => void 0).resume();
|
|
542
|
+
if (input instanceof import_node_stream.Readable) {
|
|
543
|
+
input.on("error", inputError);
|
|
544
|
+
input.pipe(stream);
|
|
545
|
+
} else stream.end(input);
|
|
546
|
+
}
|
|
491
547
|
if (opts.signal?.aborted) abort();
|
|
492
548
|
});
|
|
493
549
|
return { child, stdout: childOut, result };
|
|
@@ -499,6 +555,12 @@ function shellQuote(args) {
|
|
|
499
555
|
return args.map((a) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
|
500
556
|
}
|
|
501
557
|
|
|
558
|
+
// packages/tools/src/tools.ts
|
|
559
|
+
function declaredRoles(tool, requested) {
|
|
560
|
+
const declared = new Map((tool.schema.roles ?? []).map((r) => [r.name, r.direction]));
|
|
561
|
+
return Object.fromEntries(Object.entries(requested).filter(([name, role]) => declared.get(name) === (role.input === void 0 ? "out" : "in")));
|
|
562
|
+
}
|
|
563
|
+
|
|
502
564
|
// packages/jobs/src/functions.ts
|
|
503
565
|
var import_node_fs4 = require("node:fs");
|
|
504
566
|
var import_promises = require("node:stream/promises");
|
|
@@ -546,6 +608,14 @@ async function buildFunctionCommand(fn, opts, schema) {
|
|
|
546
608
|
for (const key of unknown) LOGGER.warn("[%s] ignoring config key %s; no matching CLI option in schema", name, key);
|
|
547
609
|
return [fn.script, ...argv];
|
|
548
610
|
}
|
|
611
|
+
function parseRoleJson(text) {
|
|
612
|
+
if (!text?.trim()) return void 0;
|
|
613
|
+
try {
|
|
614
|
+
return JSON.parse(text);
|
|
615
|
+
} catch {
|
|
616
|
+
return void 0;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
549
619
|
function streamPath(template, opts, base) {
|
|
550
620
|
if (!template) return void 0;
|
|
551
621
|
const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
|
|
@@ -559,6 +629,19 @@ async function runScriptFunction(fn, opts) {
|
|
|
559
629
|
const workDir = opts.context?.paths?.work_dir;
|
|
560
630
|
const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
|
|
561
631
|
const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
|
|
632
|
+
let progress;
|
|
633
|
+
const roles = {
|
|
634
|
+
progress: {
|
|
635
|
+
onLine: (line) => {
|
|
636
|
+
const update = parseRoleJson(line);
|
|
637
|
+
if (!update || typeof update !== "object" || Array.isArray(update)) return;
|
|
638
|
+
progress = update;
|
|
639
|
+
opts.onProgress?.(progress);
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
meta: {},
|
|
643
|
+
log: { onLine: opts.onLog }
|
|
644
|
+
};
|
|
562
645
|
const started = start(file, argv, {
|
|
563
646
|
cwd: opts.cwd,
|
|
564
647
|
env: opts.env,
|
|
@@ -566,7 +649,8 @@ async function runScriptFunction(fn, opts) {
|
|
|
566
649
|
signal: opts.signal,
|
|
567
650
|
onStderr: opts.onStderr,
|
|
568
651
|
stdin: stdinPath ? (0, import_node_fs4.createReadStream)(stdinPath) : void 0,
|
|
569
|
-
stdout: stdoutPath ? "stream" : "text"
|
|
652
|
+
stdout: stdoutPath ? "stream" : "text",
|
|
653
|
+
roles: declaredRoles({ schema }, roles)
|
|
570
654
|
});
|
|
571
655
|
let written;
|
|
572
656
|
if (stdoutPath) {
|
|
@@ -574,7 +658,8 @@ async function runScriptFunction(fn, opts) {
|
|
|
574
658
|
written = (0, import_promises.finished)(started.stdout.pipe((0, import_node_fs4.createWriteStream)(stdoutPath)));
|
|
575
659
|
}
|
|
576
660
|
const [result] = await Promise.all([started.result, written]);
|
|
577
|
-
|
|
661
|
+
const meta = parseRoleJson(result.roles?.meta);
|
|
662
|
+
return { ...result, command, stderrTail: tail(result.stderr), ...meta !== void 0 ? { meta } : {}, ...progress ? { progress } : {} };
|
|
578
663
|
}
|
|
579
664
|
function buildResultRecord(p) {
|
|
580
665
|
return {
|
|
@@ -720,10 +805,15 @@ function transitionJobRecord(record, change) {
|
|
|
720
805
|
result_key: change.resultKey || null
|
|
721
806
|
};
|
|
722
807
|
}
|
|
808
|
+
function updateJobProgress(record, progress, now) {
|
|
809
|
+
const stage = typeof progress.stage === "string" ? progress.stage : record.stage;
|
|
810
|
+
return { ...record, progress, stage, updated_at: now ?? timestampIso() };
|
|
811
|
+
}
|
|
723
812
|
|
|
724
813
|
// packages/jobs/src/queue.ts
|
|
725
814
|
var import_node_os = require("node:os");
|
|
726
815
|
var JobQueue = class {
|
|
816
|
+
/** Create an instance with the supplied configuration; omitted fields use the documented defaults. */
|
|
727
817
|
constructor(opts = {}) {
|
|
728
818
|
this.opts = opts;
|
|
729
819
|
}
|
|
@@ -750,7 +840,15 @@ var JobQueue = class {
|
|
|
750
840
|
try {
|
|
751
841
|
abort.signal.throwIfAborted();
|
|
752
842
|
update({ status: "processing" });
|
|
753
|
-
const result = await task({
|
|
843
|
+
const result = await task({
|
|
844
|
+
signal: abort.signal,
|
|
845
|
+
stage: (stage) => update({ status: "processing", stage }),
|
|
846
|
+
progress: (p) => {
|
|
847
|
+
if (job.record.status !== "processing") return;
|
|
848
|
+
job.record = updateJobProgress(job.record, p);
|
|
849
|
+
this.opts.onChange?.(job.record);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
754
852
|
job.result = result;
|
|
755
853
|
abort.signal.throwIfAborted();
|
|
756
854
|
update({ status: "done", completedAt: timestampIso() });
|
|
@@ -768,9 +866,11 @@ var JobQueue = class {
|
|
|
768
866
|
this.opts.onChange?.(job.record);
|
|
769
867
|
return job;
|
|
770
868
|
}
|
|
869
|
+
/** Look up a retained job by ID; returns undefined when unknown or dropped. */
|
|
771
870
|
get(id) {
|
|
772
871
|
return this.jobs.get(id);
|
|
773
872
|
}
|
|
873
|
+
/** Return the current records for all retained jobs, including pending and finished jobs. */
|
|
774
874
|
list() {
|
|
775
875
|
return [...this.jobs.values()].map((j) => j.record);
|
|
776
876
|
}
|
|
@@ -816,6 +916,7 @@ var JobQueue = class {
|
|
|
816
916
|
newJobId,
|
|
817
917
|
newJobRecord,
|
|
818
918
|
objectValue,
|
|
919
|
+
parseRoleJson,
|
|
819
920
|
pathParts,
|
|
820
921
|
readJson,
|
|
821
922
|
renderConfigValue,
|
|
@@ -831,5 +932,6 @@ var JobQueue = class {
|
|
|
831
932
|
timestampIso,
|
|
832
933
|
transitionJobRecord,
|
|
833
934
|
truthy,
|
|
935
|
+
updateJobProgress,
|
|
834
936
|
writeJson
|
|
835
937
|
});
|
package/dist/artifacts.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResolvedFunction } from './functions.js';
|
|
2
2
|
import { type TemplateContext } from './template.js';
|
|
3
|
+
/** Compile an artifact glob into an anchored regular expression. */
|
|
3
4
|
export declare function globToRegex(pattern: string): RegExp;
|
|
4
5
|
/** Every file below `dir`, following symlinks without looping. */
|
|
5
6
|
export declare function listFilesRecursive(dir: string, visited?: Set<string>): string[];
|
|
@@ -13,7 +14,9 @@ export declare function findFilesByArtifactPattern(pattern: string, context: Tem
|
|
|
13
14
|
export declare function artifactIncludePatterns(fn: ResolvedFunction): string[];
|
|
14
15
|
/** How many artifacts a function must produce: `min`, or 1 for `required: true`. */
|
|
15
16
|
export declare function artifactRequirement(fn: ResolvedFunction): number;
|
|
17
|
+
/** An artifact copied to its destination, recording the original source path. */
|
|
16
18
|
export interface CopiedArtifact {
|
|
19
|
+
/** Original source path of the copied artifact. */
|
|
17
20
|
source: string;
|
|
18
21
|
/** `<keyPrefix><artifacts dir name>/<file name>`. */
|
|
19
22
|
key: string;
|
|
@@ -31,3 +34,4 @@ export declare function copyConfiguredArtifacts(fn: ResolvedFunction, context: T
|
|
|
31
34
|
}, opts?: {
|
|
32
35
|
keyPrefix?: string;
|
|
33
36
|
}): CopiedArtifact[];
|
|
37
|
+
//# sourceMappingURL=artifacts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifacts.d.ts","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAK3E,oEAAoE;AACpE,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAiBnD;AAWD,kEAAkE;AAClE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,cAAoB,GAAG,MAAM,EAAE,CAarF;AAED,oFAAoF;AACpF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,MAAM,EAAE,CAQhI;AAED,sFAAsF;AACtF,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE,CAatE;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,CAMhE;AAED,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,gBAAgB,EACpB,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EACjF,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,cAAc,EAAE,CAwBlB"}
|
package/dist/artifacts.js
CHANGED
|
@@ -5,6 +5,7 @@ import { renderTemplateString } from './template.js';
|
|
|
5
5
|
import { copyFileEnsuringDir, isDirectoryPath, isFilePath, statFollowingSymlinks, truthy } from './util.js';
|
|
6
6
|
// A small glob subset as a regex: `*`, `?`, `**` and `**/` (which also matches no
|
|
7
7
|
// directory), with `/` separators.
|
|
8
|
+
/** Compile an artifact glob into an anchored regular expression. */
|
|
8
9
|
export function globToRegex(pattern) {
|
|
9
10
|
const source = String(pattern).replace(/\\/g, '/');
|
|
10
11
|
let out = '^';
|
package/dist/functions.d.ts
CHANGED
|
@@ -7,13 +7,17 @@ import { type Json } from './util.js';
|
|
|
7
7
|
* controlled by media-dropoff".
|
|
8
8
|
*/
|
|
9
9
|
export declare function configureLogging(opts: {
|
|
10
|
-
module?: string;
|
|
10
|
+
module?: string; /** Write a timestamped warning to stderr; supports printf-style arguments. */
|
|
11
11
|
warn?: (...args: unknown[]) => void;
|
|
12
12
|
verbose?: (...args: unknown[]) => void;
|
|
13
13
|
}): void;
|
|
14
|
+
/** A configured binding to an executable, optional input/output files, artifacts, and result templates. */
|
|
14
15
|
export interface FunctionDefinition {
|
|
16
|
+
/** Config key holding function options; defaults to the function name. */
|
|
15
17
|
key?: string;
|
|
18
|
+
/** Executable path; configured paths are resolved against the script root. */
|
|
16
19
|
script?: string;
|
|
20
|
+
/** Explicit positional templates; an empty array explicitly supplies no positionals. */
|
|
17
21
|
positionals?: unknown[];
|
|
18
22
|
/** Positionals before or after options (default: first). */
|
|
19
23
|
positionals_order?: 'first' | 'last';
|
|
@@ -21,11 +25,15 @@ export interface FunctionDefinition {
|
|
|
21
25
|
stdin?: string;
|
|
22
26
|
/** A template for the file the tool's stdout is written to; it also counts as an artifact. */
|
|
23
27
|
stdout?: string;
|
|
28
|
+
/** Artifact configuration consumed by the job engine after execution. */
|
|
24
29
|
artifacts?: unknown;
|
|
30
|
+
/** Task result or configured result templates, according to the containing interface. */
|
|
25
31
|
result?: unknown;
|
|
26
32
|
[key: string]: unknown;
|
|
27
33
|
}
|
|
34
|
+
/** A configured function with resolved executable and merged defaults and overrides. */
|
|
28
35
|
export interface ResolvedFunction {
|
|
36
|
+
/** Name of the configured function to invoke. */
|
|
29
37
|
functionName: string;
|
|
30
38
|
/** The config key holding its options (default: the function name). */
|
|
31
39
|
key: string;
|
|
@@ -33,6 +41,7 @@ export interface ResolvedFunction {
|
|
|
33
41
|
script: string;
|
|
34
42
|
/** `deepMerge(defaults[key], config[key])`. */
|
|
35
43
|
config: Json;
|
|
44
|
+
/** Original function binding, including positional, stream, artifact, and result templates. */
|
|
36
45
|
definition: FunctionDefinition;
|
|
37
46
|
}
|
|
38
47
|
/**
|
|
@@ -40,9 +49,11 @@ export interface ResolvedFunction {
|
|
|
40
49
|
* has no such function. A function without a `script` is an error.
|
|
41
50
|
*/
|
|
42
51
|
export declare function resolveFunctionConfig(scriptRoot: string, routeConfig: Json, functionName: string): ResolvedFunction | null;
|
|
52
|
+
/** Path base, template context, and controlled arguments used when constructing a job command. */
|
|
43
53
|
export interface CommandOptions {
|
|
44
54
|
/** Directory relative path options resolve against. */
|
|
45
55
|
configRoot: string;
|
|
56
|
+
/** Values available to dotted-path templates; null disables context-based rendering. */
|
|
46
57
|
context?: TemplateContext | null;
|
|
47
58
|
/** Options the trigger sets itself; config keys for them are ignored with a warning. Default: ['help']. */
|
|
48
59
|
controlled?: string[];
|
|
@@ -54,10 +65,17 @@ export interface CommandOptions {
|
|
|
54
65
|
}
|
|
55
66
|
/** The full command line for a function: [script, ...argv]. */
|
|
56
67
|
export declare function buildFunctionCommand(fn: ResolvedFunction, opts: CommandOptions, schema?: Schema): Promise<string[]>;
|
|
68
|
+
/** The completed tool execution with a color-stripped stderr tail. */
|
|
57
69
|
export interface FunctionRun extends RunResult {
|
|
58
70
|
/** The last 40 lines of stderr without color codes. */
|
|
59
71
|
stderrTail: string[];
|
|
72
|
+
/** The tool's `meta` role (spec section 14), parsed, when it declares one and wrote JSON. */
|
|
73
|
+
meta?: unknown;
|
|
74
|
+
/** The last update on its `progress` role. */
|
|
75
|
+
progress?: Record<string, unknown>;
|
|
60
76
|
}
|
|
77
|
+
/** A role's collected text as JSON, or undefined when it is empty or isn't JSON. */
|
|
78
|
+
export declare function parseRoleJson(text: string | undefined): unknown;
|
|
61
79
|
/**
|
|
62
80
|
* Run a function's tool. Its stdin is the function's `stdin` file, if any;
|
|
63
81
|
* stdout goes to its `stdout` file, else is collected (a tool may print its
|
|
@@ -65,10 +83,12 @@ export interface FunctionRun extends RunResult {
|
|
|
65
83
|
*/
|
|
66
84
|
export declare function runScriptFunction(fn: ResolvedFunction, opts: CommandOptions & {
|
|
67
85
|
timeoutMs?: number;
|
|
68
|
-
env?: NodeJS.ProcessEnv;
|
|
69
|
-
cwd?: string;
|
|
70
|
-
signal?: AbortSignal;
|
|
71
|
-
onStderr?: (chunk: string) => void;
|
|
86
|
+
/** Environment supplied to the process or read for option defaults. */ env?: NodeJS.ProcessEnv;
|
|
87
|
+
/** Working directory used to resolve command-line paths and execute tools. */ cwd?: string;
|
|
88
|
+
/** AbortSignal used to cancel execution and terminate the tool process tree. */ signal?: AbortSignal;
|
|
89
|
+
/** Called for each decoded stderr chunk. */ onStderr?: (chunk: string) => void;
|
|
90
|
+
/** Called with each object the tool writes on its `progress` role (spec section 14). */ onProgress?: (update: Record<string, unknown>) => void;
|
|
91
|
+
/** Called with each line the tool writes on its `log` role. */ onLog?: (line: string) => void;
|
|
72
92
|
}): Promise<FunctionRun>;
|
|
73
93
|
/** The result record every function shares, plus its rendered `result` map. */
|
|
74
94
|
export declare function buildResultRecord(p: {
|
|
@@ -76,6 +96,8 @@ export declare function buildResultRecord(p: {
|
|
|
76
96
|
fields?: Json;
|
|
77
97
|
fn: ResolvedFunction;
|
|
78
98
|
artifactKeys: string[];
|
|
99
|
+
/** Values available to dotted-path templates; null disables context-based rendering. */
|
|
79
100
|
context: TemplateContext;
|
|
80
101
|
now: string;
|
|
81
102
|
}): Json;
|
|
103
|
+
//# sourceMappingURL=functions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"AASA,OAAO,EAAsG,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAI/J,OAAO,EAA4D,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAC/G,OAAO,EAAwD,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAS5F;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,8EAA8E;IAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;CAAE,GAAG,IAAI,CAE5N;AAED,2GAA2G;AAC3G,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,WAAW,CAAC,EAAE,OAAO,EAAE,CAAC;IACxB,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACrC,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8FAA8F;IAC9F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,yFAAyF;IACzF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,MAAM,EAAE,IAAI,CAAC;IACb,+FAA+F;IAC/F,UAAU,EAAE,kBAAkB,CAAC;CAChC;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAQ1H;AAED,kGAAkG;AAClG,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACjC,2GAA2G;IAC3G,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAYD,+DAA+D;AAC/D,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBzH;AAED,sEAAsE;AACtE,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,uDAAuD;IACvD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,6FAA6F;IAC7F,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAO/D;AASD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,gBAAgB,EACpB,IAAI,EAAE,cAAc,GAAG;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChG,8EAA8E,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAC5F,gFAAgF,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IACtG,4CAA4C,CAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChF,wFAAwF,CAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAChJ,+DAA+D,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChG,GACA,OAAO,CAAC,WAAW,CAAC,CAqCtB;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,CAAC,EAAE;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,IAAI,CAAC;IACd,EAAE,EAAE,gBAAgB,CAAC;IACrB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,eAAe,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,IAAI,CAYP"}
|
package/dist/functions.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Tool options are mapped onto the tool's command line through its
|
|
8
8
|
// --help-json-schema (spec section 13), after `${dot.path}` / `{{dot.path}}`
|
|
9
9
|
// templates are rendered against a context the trigger builds.
|
|
10
|
-
import { inputKeys, loadSchema, redactArgv, start, shellQuote, tail, toArgv } from 'clyops-tools';
|
|
10
|
+
import { declaredRoles, inputKeys, loadSchema, redactArgv, start, shellQuote, tail, toArgv } from 'clyops-tools';
|
|
11
11
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
12
12
|
import { finished } from 'node:stream/promises';
|
|
13
13
|
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
@@ -75,6 +75,17 @@ export async function buildFunctionCommand(fn, opts, schema) {
|
|
|
75
75
|
LOGGER.warn('[%s] ignoring config key %s; no matching CLI option in schema', name, key);
|
|
76
76
|
return [fn.script, ...argv];
|
|
77
77
|
}
|
|
78
|
+
/** A role's collected text as JSON, or undefined when it is empty or isn't JSON. */
|
|
79
|
+
export function parseRoleJson(text) {
|
|
80
|
+
if (!text?.trim())
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
78
89
|
/** A function's `stdin` or `stdout` template rendered, a relative one against `base`. */
|
|
79
90
|
function streamPath(template, opts, base) {
|
|
80
91
|
if (!template)
|
|
@@ -96,10 +107,26 @@ export async function runScriptFunction(fn, opts) {
|
|
|
96
107
|
const workDir = opts.context?.paths?.work_dir;
|
|
97
108
|
const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
|
|
98
109
|
const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
|
|
110
|
+
// Wire the well-known roles the tool declares: progress onto the record, meta into the run.
|
|
111
|
+
let progress;
|
|
112
|
+
const roles = {
|
|
113
|
+
progress: {
|
|
114
|
+
onLine: (line) => {
|
|
115
|
+
const update = parseRoleJson(line);
|
|
116
|
+
if (!update || typeof update !== 'object' || Array.isArray(update))
|
|
117
|
+
return;
|
|
118
|
+
progress = update;
|
|
119
|
+
opts.onProgress?.(progress);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
meta: {},
|
|
123
|
+
log: { onLine: opts.onLog },
|
|
124
|
+
};
|
|
99
125
|
const started = start(file, argv, {
|
|
100
126
|
cwd: opts.cwd, env: opts.env, timeoutMs: opts.timeoutMs, signal: opts.signal, onStderr: opts.onStderr,
|
|
101
127
|
stdin: stdinPath ? createReadStream(stdinPath) : undefined,
|
|
102
128
|
stdout: stdoutPath ? 'stream' : 'text',
|
|
129
|
+
roles: declaredRoles({ schema }, roles),
|
|
103
130
|
});
|
|
104
131
|
let written;
|
|
105
132
|
if (stdoutPath) {
|
|
@@ -107,7 +134,8 @@ export async function runScriptFunction(fn, opts) {
|
|
|
107
134
|
written = finished(started.stdout.pipe(createWriteStream(stdoutPath)));
|
|
108
135
|
}
|
|
109
136
|
const [result] = await Promise.all([started.result, written]);
|
|
110
|
-
|
|
137
|
+
const meta = parseRoleJson(result.roles?.meta);
|
|
138
|
+
return { ...result, command, stderrTail: tail(result.stderr), ...(meta !== undefined ? { meta } : {}), ...(progress ? { progress } : {}) };
|
|
111
139
|
}
|
|
112
140
|
/** The result record every function shares, plus its rendered `result` map. */
|
|
113
141
|
export function buildResultRecord(p) {
|
package/dist/index.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/queue.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { type JobRecord } from './records.js';
|
|
2
|
+
/** A queued task, its current record, completion promise, optional result, and cancellation method. */
|
|
2
3
|
export interface Job<R> {
|
|
4
|
+
/** Current serializable job state, updated as the task progresses. */
|
|
3
5
|
record: JobRecord;
|
|
6
|
+
/** Task result or configured result templates, according to the containing interface. */
|
|
4
7
|
result?: R;
|
|
5
8
|
/** Resolves with the result when the job is done (rejects when it fails). */
|
|
6
9
|
done: Promise<R>;
|
|
7
10
|
/** Abort a pending or running job. */
|
|
8
11
|
cancel(): void;
|
|
9
12
|
}
|
|
13
|
+
/** Concurrency, retention, ID prefix, and lifecycle callbacks for the in-memory queue. */
|
|
10
14
|
export interface QueueOptions {
|
|
11
15
|
/** Jobs run at the same time (default: the number of CPUs). */
|
|
12
16
|
concurrency?: number;
|
|
@@ -19,23 +23,29 @@ export interface QueueOptions {
|
|
|
19
23
|
/** Called when a finished job is dropped (beyond `keep`), e.g. to remove its files. */
|
|
20
24
|
onDrop?: (record: JobRecord) => void;
|
|
21
25
|
}
|
|
26
|
+
/** An in-memory queue with bounded concurrency and retention. Await each job.done to observe its result. */
|
|
22
27
|
export declare class JobQueue<R = unknown> {
|
|
23
28
|
private opts;
|
|
24
29
|
private jobs;
|
|
25
30
|
private waiting;
|
|
26
31
|
private running;
|
|
27
32
|
private finished;
|
|
33
|
+
/** Create an instance with the supplied configuration; omitted fields use the documented defaults. */
|
|
28
34
|
constructor(opts?: QueueOptions);
|
|
29
35
|
/**
|
|
30
36
|
* Queue `task` as a job of `functionName`. The task gets an AbortSignal and
|
|
31
37
|
* a `stage` setter for progress; its return value is the job's result.
|
|
32
38
|
*/
|
|
33
39
|
add(functionName: string, task: (ctx: {
|
|
34
|
-
signal: AbortSignal;
|
|
35
|
-
stage: (stage: string) => void;
|
|
40
|
+
/** Termination signal, or null for an ordinary exit. RunOptions uses an AbortSignal for cancellation. */ signal: AbortSignal;
|
|
41
|
+
/** Current progress stage, or null when unspecified. */ stage: (stage: string) => void;
|
|
42
|
+
/** Record a progress update, e.g. runScriptFunction's onProgress (no history entry). */ progress: (update: Record<string, unknown>) => void;
|
|
36
43
|
}) => Promise<R>, fields?: Record<string, unknown>): Job<R>;
|
|
44
|
+
/** Look up a retained job by ID; returns undefined when unknown or dropped. */
|
|
37
45
|
get(id: string): Job<R> | undefined;
|
|
46
|
+
/** Return the current records for all retained jobs, including pending and finished jobs. */
|
|
38
47
|
list(): JobRecord[];
|
|
39
48
|
private slot;
|
|
40
49
|
private release;
|
|
41
50
|
}
|
|
51
|
+
//# sourceMappingURL=queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9G,uGAAuG;AACvG,MAAM,WAAW,GAAG,CAAC,CAAC;IACpB,sEAAsE;IACtE,MAAM,EAAE,SAAS,CAAC;IAClB,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,6EAA6E;IAC7E,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACjB,sCAAsC;IACtC,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,0FAA0F;AAC1F,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC;IACvC,uFAAuF;IACvF,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAED,4GAA4G;AAC5G,qBAAa,QAAQ,CAAC,CAAC,GAAG,OAAO;IAOnB,OAAO,CAAC,IAAI;IANxB,OAAO,CAAC,IAAI,CAA6B;IACzC,OAAO,CAAC,OAAO,CAAsB;IACrC,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAgB;IAEhC,sGAAsG;gBAClF,IAAI,GAAE,YAAiB;IAE3C;;;OAGG;IACH,GAAG,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE;QACpC,yGAAyG,CAAC,MAAM,EAAE,WAAW,CAAC;QAC9H,wDAAwD,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACxF,wFAAwF,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;KAC9I,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,GAAG,CAAC,CAAC,CAAC;IAyC/D,+EAA+E;IAC/E,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,SAAS;IAInC,6FAA6F;IAC7F,IAAI,IAAI,SAAS,EAAE;IAInB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,OAAO;CAYhB"}
|
package/dist/queue.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// An in-memory job queue: run tasks with a concurrency limit and keep their
|
|
2
2
|
// records and results, for a server that hands out job ids.
|
|
3
3
|
import { availableParallelism } from 'node:os';
|
|
4
|
-
import { newJobId, newJobRecord, transitionJobRecord } from './records.js';
|
|
4
|
+
import { newJobId, newJobRecord, transitionJobRecord, updateJobProgress } from './records.js';
|
|
5
5
|
import { timestampIso } from './util.js';
|
|
6
|
+
/** An in-memory queue with bounded concurrency and retention. Await each job.done to observe its result. */
|
|
6
7
|
export class JobQueue {
|
|
7
8
|
opts;
|
|
8
9
|
jobs = new Map();
|
|
9
10
|
waiting = [];
|
|
10
11
|
running = 0;
|
|
11
12
|
finished = [];
|
|
13
|
+
/** Create an instance with the supplied configuration; omitted fields use the documented defaults. */
|
|
12
14
|
constructor(opts = {}) {
|
|
13
15
|
this.opts = opts;
|
|
14
16
|
}
|
|
@@ -30,7 +32,16 @@ export class JobQueue {
|
|
|
30
32
|
try {
|
|
31
33
|
abort.signal.throwIfAborted();
|
|
32
34
|
update({ status: 'processing' });
|
|
33
|
-
const result = await task({
|
|
35
|
+
const result = await task({
|
|
36
|
+
signal: abort.signal,
|
|
37
|
+
stage: (stage) => update({ status: 'processing', stage }),
|
|
38
|
+
progress: (p) => {
|
|
39
|
+
if (job.record.status !== 'processing')
|
|
40
|
+
return;
|
|
41
|
+
job.record = updateJobProgress(job.record, p);
|
|
42
|
+
this.opts.onChange?.(job.record);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
34
45
|
job.result = result;
|
|
35
46
|
// A cancelled task that still returns (a killed tool) is cancelled all the same.
|
|
36
47
|
abort.signal.throwIfAborted();
|
|
@@ -50,9 +61,11 @@ export class JobQueue {
|
|
|
50
61
|
this.opts.onChange?.(job.record);
|
|
51
62
|
return job;
|
|
52
63
|
}
|
|
64
|
+
/** Look up a retained job by ID; returns undefined when unknown or dropped. */
|
|
53
65
|
get(id) {
|
|
54
66
|
return this.jobs.get(id);
|
|
55
67
|
}
|
|
68
|
+
/** Return the current records for all retained jobs, including pending and finished jobs. */
|
|
56
69
|
list() {
|
|
57
70
|
return [...this.jobs.values()].map((j) => j.record);
|
|
58
71
|
}
|
package/dist/records.d.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
+
/** Job lifecycle: pending, processing, done, or error. */
|
|
1
2
|
export type JobStatus = 'pending' | 'processing' | 'done' | 'error';
|
|
3
|
+
/** A serializable job state with lifecycle timestamps, error information, and history. */
|
|
2
4
|
export interface JobRecord {
|
|
5
|
+
/** Unique identifier for the job. */
|
|
3
6
|
job_id: string;
|
|
7
|
+
/** Configured function associated with the job. */
|
|
4
8
|
function: string;
|
|
9
|
+
/** Lifecycle state or HTTP input-error status, according to the containing type. */
|
|
5
10
|
status: JobStatus;
|
|
11
|
+
/** Current progress stage, or null when unspecified. */
|
|
6
12
|
stage: string | null;
|
|
13
|
+
/** ISO timestamp when the record was created. */
|
|
7
14
|
started_at: string;
|
|
15
|
+
/** ISO timestamp of the last transition. */
|
|
8
16
|
updated_at: string;
|
|
17
|
+
/** ISO timestamp when execution finished, or null while unfinished. */
|
|
9
18
|
completed_at: string | null;
|
|
19
|
+
/** Failure message, or null while no error has been recorded. */
|
|
10
20
|
error: string | null;
|
|
21
|
+
/** Storage key of the result, or null when absent. */
|
|
11
22
|
result_key: string | null;
|
|
23
|
+
/** The latest update from the tool's `progress` role (spec section 14), when it has one. */
|
|
24
|
+
progress?: Record<string, unknown> | null;
|
|
25
|
+
/** Earlier job states, newest first. */
|
|
12
26
|
history: {
|
|
13
|
-
status: JobStatus;
|
|
14
|
-
stage: string | null;
|
|
27
|
+
status: JobStatus; /** Current progress stage, or null when unspecified. */
|
|
28
|
+
stage: string | null; /** ISO timestamp of the last transition. */
|
|
15
29
|
updated_at: string;
|
|
16
30
|
}[];
|
|
17
31
|
[field: string]: unknown;
|
|
@@ -20,18 +34,24 @@ export interface JobRecord {
|
|
|
20
34
|
export declare function newJobId(seed: string, prefix?: string): string;
|
|
21
35
|
/** A new record in the `pending` state; `fields` are flattened onto it. */
|
|
22
36
|
export declare function newJobRecord(p: {
|
|
23
|
-
jobId: string;
|
|
37
|
+
jobId: string; /** Name of the configured function to invoke. */
|
|
24
38
|
functionName: string;
|
|
25
39
|
fields?: Record<string, unknown>;
|
|
26
40
|
now?: string;
|
|
27
41
|
}): JobRecord;
|
|
28
42
|
/** Move a record on, pushing its current state onto the front of `history`. Returns a new record. */
|
|
29
43
|
export declare function transitionJobRecord(record: JobRecord, change: {
|
|
30
|
-
status: JobStatus;
|
|
31
|
-
stage?: string | null;
|
|
44
|
+
status: JobStatus; /** Current progress stage, or null when unspecified. */
|
|
45
|
+
stage?: string | null; /** Write a timestamped error to stderr; supports printf-style arguments. */
|
|
32
46
|
error?: string | null;
|
|
33
47
|
resultKey?: string | null;
|
|
34
48
|
completedAt?: string | null;
|
|
35
49
|
now?: string;
|
|
36
50
|
extra?: Record<string, unknown>;
|
|
37
51
|
}): JobRecord;
|
|
52
|
+
/**
|
|
53
|
+
* Record a progress update (spec section 14) without a history entry, so frequent updates don't
|
|
54
|
+
* grow it: `progress` is the update, and its `stage`, when it has one, becomes the record's.
|
|
55
|
+
*/
|
|
56
|
+
export declare function updateJobProgress(record: JobRecord, progress: Record<string, unknown>, now?: string): JobRecord;
|
|
57
|
+
//# sourceMappingURL=records.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../src/records.ts"],"names":[],"mappings":"AAOA,0DAA0D;AAC1D,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,YAAY,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,0FAA0F;AAC1F,MAAM,WAAW,SAAS;IACxB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,MAAM,EAAE,SAAS,CAAC;IAClB,wDAAwD;IACxD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,iDAAiD;IACjD,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,iEAAiE;IACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,sDAAsD;IACtD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC1C,wCAAwC;IACxC,OAAO,EAAE;QAAuF,MAAM,EAAE,SAAS,CAAC,CAAC,wDAAwD;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,4CAA4C;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,gGAAgG;AAChG,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAQ,GAAG,MAAM,CAG7D;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,CAAC,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC,CAAC,iDAAiD;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAepL;AAED,qGAAqG;AACrG,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE;IAAuF,MAAM,EAAE,SAAS,CAAC,CAAC,wDAAwD;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,4EAA4E;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC5Y,SAAS,CAYX;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAG/G"}
|
package/dist/records.js
CHANGED
|
@@ -40,3 +40,11 @@ export function transitionJobRecord(record, change) {
|
|
|
40
40
|
result_key: change.resultKey || null,
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Record a progress update (spec section 14) without a history entry, so frequent updates don't
|
|
45
|
+
* grow it: `progress` is the update, and its `stage`, when it has one, becomes the record's.
|
|
46
|
+
*/
|
|
47
|
+
export function updateJobProgress(record, progress, now) {
|
|
48
|
+
const stage = typeof progress.stage === 'string' ? progress.stage : record.stage;
|
|
49
|
+
return { ...record, progress, stage, updated_at: now ?? timestampIso() };
|
|
50
|
+
}
|
package/dist/template.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
/** Values available to dotted-path template expressions. */
|
|
1
2
|
export type TemplateContext = {
|
|
2
3
|
[key: string]: unknown;
|
|
3
4
|
};
|
|
4
5
|
/** A dot-path value from the context; unknown paths are errors, objects become JSON. */
|
|
5
6
|
export declare function lookupTemplateValue(context: TemplateContext, expression: string): string;
|
|
7
|
+
/** Replace dotted-path ${expression} and {{expression}} templates using the context. */
|
|
6
8
|
export declare function renderTemplateString(value: string, context: TemplateContext): string;
|
|
7
9
|
/** Render strings and the strings in arrays; other scalars keep their type. */
|
|
8
10
|
export declare function renderConfigValue(value: unknown, context?: TemplateContext | null): unknown;
|
|
@@ -12,3 +14,4 @@ export declare function renderResultValue(value: unknown, context: TemplateConte
|
|
|
12
14
|
export declare function renderResultMap(resultMap: unknown, context: TemplateContext): {
|
|
13
15
|
[key: string]: unknown;
|
|
14
16
|
};
|
|
17
|
+
//# sourceMappingURL=template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/template.ts"],"names":[],"mappings":"AACA,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAEzD,wFAAwF;AACxF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAUxF;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,CAIpF;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAAG,OAAO,CAK3F;AAED,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAOnF;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAGxG"}
|
package/dist/template.js
CHANGED
|
@@ -11,6 +11,7 @@ export function lookupTemplateValue(context, expression) {
|
|
|
11
11
|
return '';
|
|
12
12
|
return typeof current === 'object' ? JSON.stringify(current) : String(current);
|
|
13
13
|
}
|
|
14
|
+
/** Replace dotted-path ${expression} and {{expression}} templates using the context. */
|
|
14
15
|
export function renderTemplateString(value, context) {
|
|
15
16
|
return String(value).replace(/\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace));
|
|
16
17
|
}
|
package/dist/util.d.ts
CHANGED
|
@@ -5,14 +5,19 @@ export declare function timestampIso(): string;
|
|
|
5
5
|
export declare function isEnabled(value: unknown): boolean;
|
|
6
6
|
/** Human-friendly booleans used in config files. */
|
|
7
7
|
export declare function truthy(value: unknown): boolean;
|
|
8
|
+
/** Create the directory and missing ancestors; filesystem failures throw. */
|
|
8
9
|
export declare function ensureDir(dir: string): void;
|
|
10
|
+
/** Read and parse a UTF-8 JSON file; malformed JSON and filesystem failures throw. */
|
|
9
11
|
export declare function readJson<T = unknown>(file: string): T;
|
|
10
12
|
/** Stable pretty-printed JSON with a trailing newline. */
|
|
11
13
|
export declare function writeJson(file: string, value: unknown): void;
|
|
12
14
|
/** stat() following symlinks; null when missing or inaccessible. */
|
|
13
15
|
export declare function statFollowingSymlinks(file: string): Stats | null;
|
|
16
|
+
/** Whether the path exists and refers to a directory, following symlinks. */
|
|
14
17
|
export declare function isDirectoryPath(file: string): boolean;
|
|
18
|
+
/** Whether the path exists and refers to a regular file, following symlinks. */
|
|
15
19
|
export declare function isFilePath(file: string): boolean;
|
|
20
|
+
/** Create destination parents and copy the file; filesystem failures throw. */
|
|
16
21
|
export declare function copyFileEnsuringDir(source: string, destination: string): void;
|
|
17
22
|
/** Path components for templates: `{path, dir, name, basename, ext}`. */
|
|
18
23
|
export declare function pathParts(file: string): {
|
|
@@ -22,6 +27,7 @@ export declare function pathParts(file: string): {
|
|
|
22
27
|
basename: string;
|
|
23
28
|
ext: string;
|
|
24
29
|
};
|
|
30
|
+
/** A JSON object used for job configuration. */
|
|
25
31
|
export type Json = {
|
|
26
32
|
[key: string]: unknown;
|
|
27
33
|
};
|
|
@@ -36,3 +42,4 @@ export declare function deepMerge(base: unknown, override: unknown): unknown;
|
|
|
36
42
|
export declare function resolveConfigPath(root: string, value: string): string;
|
|
37
43
|
/** Same rules as resolveConfigPath, for script paths against the script root. */
|
|
38
44
|
export declare const resolveScriptPath: typeof resolveConfigPath;
|
|
45
|
+
//# sourceMappingURL=util.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AACA,OAAO,EAAmD,KAAK,KAAK,EAAiB,MAAM,SAAS,CAAC;AAGrG,8DAA8D;AAC9D,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,oDAAoD;AACpD,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE9C;AAED,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE3C;AAED,sFAAsF;AACtF,wBAAgB,QAAQ,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAErD;AAED,0DAA0D;AAC1D,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAE5D;AAED,oEAAoE;AACpE,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,IAAI,CAMhE;AAED,6EAA6E;AAC7E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,+EAA+E;AAC/E,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAG7E;AAED,yEAAyE;AACzE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM;;;;;;EAIrC;AAED,gDAAgD;AAChD,MAAM,MAAM,IAAI,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAE9C,4DAA4D;AAC5D,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAEhD;AAED,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAMnE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIrE;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,0BAAoB,CAAC"}
|
package/dist/util.js
CHANGED
|
@@ -13,9 +13,11 @@ export function isEnabled(value) {
|
|
|
13
13
|
export function truthy(value) {
|
|
14
14
|
return /^(true|1|yes|on)$/i.test(String(value));
|
|
15
15
|
}
|
|
16
|
+
/** Create the directory and missing ancestors; filesystem failures throw. */
|
|
16
17
|
export function ensureDir(dir) {
|
|
17
18
|
mkdirSync(dir, { recursive: true });
|
|
18
19
|
}
|
|
20
|
+
/** Read and parse a UTF-8 JSON file; malformed JSON and filesystem failures throw. */
|
|
19
21
|
export function readJson(file) {
|
|
20
22
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
21
23
|
}
|
|
@@ -32,12 +34,15 @@ export function statFollowingSymlinks(file) {
|
|
|
32
34
|
return null;
|
|
33
35
|
}
|
|
34
36
|
}
|
|
37
|
+
/** Whether the path exists and refers to a directory, following symlinks. */
|
|
35
38
|
export function isDirectoryPath(file) {
|
|
36
39
|
return Boolean(statFollowingSymlinks(file)?.isDirectory());
|
|
37
40
|
}
|
|
41
|
+
/** Whether the path exists and refers to a regular file, following symlinks. */
|
|
38
42
|
export function isFilePath(file) {
|
|
39
43
|
return Boolean(statFollowingSymlinks(file)?.isFile());
|
|
40
44
|
}
|
|
45
|
+
/** Create destination parents and copy the file; filesystem failures throw. */
|
|
41
46
|
export function copyFileEnsuringDir(source, destination) {
|
|
42
47
|
ensureDir(dirname(destination));
|
|
43
48
|
copyFileSync(source, destination);
|
|
@@ -46,7 +51,7 @@ export function copyFileEnsuringDir(source, destination) {
|
|
|
46
51
|
export function pathParts(file) {
|
|
47
52
|
const name = basename(file);
|
|
48
53
|
const ext = extname(name);
|
|
49
|
-
return { path: file, dir: dirname(file), name, basename: ext ? name.slice(0, -ext.length) : name, ext: ext.replace(/^\./, '') };
|
|
54
|
+
return { /** Resolved filesystem path or HTTP endpoint path, according to the containing type. */ path: file, /** Directory represented by this group or specified in .clyops settings. */ dir: dirname(file), /** Program, command, option, or argument name as declared in its containing type. */ name, /** Filename without its extension. */ basename: ext ? name.slice(0, -ext.length) : name, /** Filename extension including the leading dot. */ ext: ext.replace(/^\./, '') };
|
|
50
55
|
}
|
|
51
56
|
/** A plain object, or `{}` for scalars, arrays and null. */
|
|
52
57
|
export function objectValue(value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Run clyops tools as jobs: config-bound functions, templated invocation, artifacts, job records and a queue.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,8 +38,9 @@
|
|
|
38
38
|
"typescript": "^5.6.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"clyops-tools": "0.
|
|
41
|
+
"clyops-tools": "0.5.0"
|
|
42
42
|
},
|
|
43
|
+
"homepage": "https://clyops.io/node-tools.html",
|
|
43
44
|
"scripts": {
|
|
44
45
|
"build": "tsc -p tsconfig.json",
|
|
45
46
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
package/src/artifacts.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { copyFileEnsuringDir, isDirectoryPath, isFilePath, statFollowingSymlinks
|
|
|
7
7
|
|
|
8
8
|
// A small glob subset as a regex: `*`, `?`, `**` and `**/` (which also matches no
|
|
9
9
|
// directory), with `/` separators.
|
|
10
|
+
/** Compile an artifact glob into an anchored regular expression. */
|
|
10
11
|
export function globToRegex(pattern: string): RegExp {
|
|
11
12
|
const source = String(pattern).replace(/\\/g, '/');
|
|
12
13
|
let out = '^';
|
|
@@ -87,7 +88,9 @@ export function artifactRequirement(fn: ResolvedFunction): number {
|
|
|
87
88
|
return artifacts.required === true || truthy(artifacts.required) ? 1 : 0;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/** An artifact copied to its destination, recording the original source path. */
|
|
90
92
|
export interface CopiedArtifact {
|
|
93
|
+
/** Original source path of the copied artifact. */
|
|
91
94
|
source: string;
|
|
92
95
|
/** `<keyPrefix><artifacts dir name>/<file name>`. */
|
|
93
96
|
key: string;
|
package/src/functions.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Tool options are mapped onto the tool's command line through its
|
|
8
8
|
// --help-json-schema (spec section 13), after `${dot.path}` / `{{dot.path}}`
|
|
9
9
|
// templates are rendered against a context the trigger builds.
|
|
10
|
-
import { inputKeys, loadSchema, redactArgv, start, shellQuote, tail, toArgv, type RunResult, type Schema } from 'clyops-tools';
|
|
10
|
+
import { declaredRoles, inputKeys, loadSchema, redactArgv, start, shellQuote, tail, toArgv, type RoleOption, type RunResult, type Schema } from 'clyops-tools';
|
|
11
11
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
12
12
|
import { finished } from 'node:stream/promises';
|
|
13
13
|
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
@@ -26,13 +26,17 @@ const LOGGER = {
|
|
|
26
26
|
* arguments; `module` names the trigger in messages such as "--help is
|
|
27
27
|
* controlled by media-dropoff".
|
|
28
28
|
*/
|
|
29
|
-
export function configureLogging(opts: { module?: string; warn?: (...args: unknown[]) => void; verbose?: (...args: unknown[]) => void }): void {
|
|
29
|
+
export function configureLogging(opts: { module?: string; /** Write a timestamped warning to stderr; supports printf-style arguments. */ warn?: (...args: unknown[]) => void; verbose?: (...args: unknown[]) => void }): void {
|
|
30
30
|
Object.assign(LOGGER, Object.fromEntries(Object.entries(opts).filter(([, v]) => v)));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** A configured binding to an executable, optional input/output files, artifacts, and result templates. */
|
|
33
34
|
export interface FunctionDefinition {
|
|
35
|
+
/** Config key holding function options; defaults to the function name. */
|
|
34
36
|
key?: string;
|
|
37
|
+
/** Executable path; configured paths are resolved against the script root. */
|
|
35
38
|
script?: string;
|
|
39
|
+
/** Explicit positional templates; an empty array explicitly supplies no positionals. */
|
|
36
40
|
positionals?: unknown[];
|
|
37
41
|
/** Positionals before or after options (default: first). */
|
|
38
42
|
positionals_order?: 'first' | 'last';
|
|
@@ -40,12 +44,16 @@ export interface FunctionDefinition {
|
|
|
40
44
|
stdin?: string;
|
|
41
45
|
/** A template for the file the tool's stdout is written to; it also counts as an artifact. */
|
|
42
46
|
stdout?: string;
|
|
47
|
+
/** Artifact configuration consumed by the job engine after execution. */
|
|
43
48
|
artifacts?: unknown;
|
|
49
|
+
/** Task result or configured result templates, according to the containing interface. */
|
|
44
50
|
result?: unknown;
|
|
45
51
|
[key: string]: unknown;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
/** A configured function with resolved executable and merged defaults and overrides. */
|
|
48
55
|
export interface ResolvedFunction {
|
|
56
|
+
/** Name of the configured function to invoke. */
|
|
49
57
|
functionName: string;
|
|
50
58
|
/** The config key holding its options (default: the function name). */
|
|
51
59
|
key: string;
|
|
@@ -53,6 +61,7 @@ export interface ResolvedFunction {
|
|
|
53
61
|
script: string;
|
|
54
62
|
/** `deepMerge(defaults[key], config[key])`. */
|
|
55
63
|
config: Json;
|
|
64
|
+
/** Original function binding, including positional, stream, artifact, and result templates. */
|
|
56
65
|
definition: FunctionDefinition;
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -70,9 +79,11 @@ export function resolveFunctionConfig(scriptRoot: string, routeConfig: Json, fun
|
|
|
70
79
|
return { functionName, key, script: resolveScriptPath(scriptRoot, script), config, definition };
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
/** Path base, template context, and controlled arguments used when constructing a job command. */
|
|
73
83
|
export interface CommandOptions {
|
|
74
84
|
/** Directory relative path options resolve against. */
|
|
75
85
|
configRoot: string;
|
|
86
|
+
/** Values available to dotted-path templates; null disables context-based rendering. */
|
|
76
87
|
context?: TemplateContext | null;
|
|
77
88
|
/** Options the trigger sets itself; config keys for them are ignored with a warning. Default: ['help']. */
|
|
78
89
|
controlled?: string[];
|
|
@@ -115,9 +126,24 @@ export async function buildFunctionCommand(fn: ResolvedFunction, opts: CommandOp
|
|
|
115
126
|
return [fn.script, ...argv];
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
/** The completed tool execution with a color-stripped stderr tail. */
|
|
118
130
|
export interface FunctionRun extends RunResult {
|
|
119
131
|
/** The last 40 lines of stderr without color codes. */
|
|
120
132
|
stderrTail: string[];
|
|
133
|
+
/** The tool's `meta` role (spec section 14), parsed, when it declares one and wrote JSON. */
|
|
134
|
+
meta?: unknown;
|
|
135
|
+
/** The last update on its `progress` role. */
|
|
136
|
+
progress?: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A role's collected text as JSON, or undefined when it is empty or isn't JSON. */
|
|
140
|
+
export function parseRoleJson(text: string | undefined): unknown {
|
|
141
|
+
if (!text?.trim()) return undefined;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
/** A function's `stdin` or `stdout` template rendered, a relative one against `base`. */
|
|
@@ -134,7 +160,15 @@ function streamPath(template: string | undefined, opts: CommandOptions, base: st
|
|
|
134
160
|
*/
|
|
135
161
|
export async function runScriptFunction(
|
|
136
162
|
fn: ResolvedFunction,
|
|
137
|
-
opts: CommandOptions & {
|
|
163
|
+
opts: CommandOptions & {
|
|
164
|
+
timeoutMs?: number;
|
|
165
|
+
/** Environment supplied to the process or read for option defaults. */ env?: NodeJS.ProcessEnv;
|
|
166
|
+
/** Working directory used to resolve command-line paths and execute tools. */ cwd?: string;
|
|
167
|
+
/** AbortSignal used to cancel execution and terminate the tool process tree. */ signal?: AbortSignal;
|
|
168
|
+
/** Called for each decoded stderr chunk. */ onStderr?: (chunk: string) => void;
|
|
169
|
+
/** Called with each object the tool writes on its `progress` role (spec section 14). */ onProgress?: (update: Record<string, unknown>) => void;
|
|
170
|
+
/** Called with each line the tool writes on its `log` role. */ onLog?: (line: string) => void;
|
|
171
|
+
},
|
|
138
172
|
): Promise<FunctionRun> {
|
|
139
173
|
const schema = await loadSchema(fn.script);
|
|
140
174
|
const [file, ...argv] = await buildFunctionCommand(fn, opts, schema);
|
|
@@ -144,10 +178,25 @@ export async function runScriptFunction(
|
|
|
144
178
|
const workDir = (opts.context?.paths as { work_dir?: string } | undefined)?.work_dir;
|
|
145
179
|
const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
|
|
146
180
|
const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
|
|
181
|
+
// Wire the well-known roles the tool declares: progress onto the record, meta into the run.
|
|
182
|
+
let progress: Record<string, unknown> | undefined;
|
|
183
|
+
const roles: Record<string, RoleOption> = {
|
|
184
|
+
progress: {
|
|
185
|
+
onLine: (line) => {
|
|
186
|
+
const update = parseRoleJson(line);
|
|
187
|
+
if (!update || typeof update !== 'object' || Array.isArray(update)) return;
|
|
188
|
+
progress = update as Record<string, unknown>;
|
|
189
|
+
opts.onProgress?.(progress);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
meta: {},
|
|
193
|
+
log: { onLine: opts.onLog },
|
|
194
|
+
};
|
|
147
195
|
const started = start(file, argv, {
|
|
148
196
|
cwd: opts.cwd, env: opts.env, timeoutMs: opts.timeoutMs, signal: opts.signal, onStderr: opts.onStderr,
|
|
149
197
|
stdin: stdinPath ? createReadStream(stdinPath) : undefined,
|
|
150
198
|
stdout: stdoutPath ? 'stream' : 'text',
|
|
199
|
+
roles: declaredRoles({ schema }, roles),
|
|
151
200
|
});
|
|
152
201
|
let written: Promise<void> | undefined;
|
|
153
202
|
if (stdoutPath) {
|
|
@@ -155,7 +204,8 @@ export async function runScriptFunction(
|
|
|
155
204
|
written = finished(started.stdout.pipe(createWriteStream(stdoutPath)));
|
|
156
205
|
}
|
|
157
206
|
const [result] = await Promise.all([started.result, written]);
|
|
158
|
-
|
|
207
|
+
const meta = parseRoleJson(result.roles?.meta);
|
|
208
|
+
return { ...result, command, stderrTail: tail(result.stderr), ...(meta !== undefined ? { meta } : {}), ...(progress ? { progress } : {}) };
|
|
159
209
|
}
|
|
160
210
|
|
|
161
211
|
/** The result record every function shares, plus its rendered `result` map. */
|
|
@@ -164,6 +214,7 @@ export function buildResultRecord(p: {
|
|
|
164
214
|
fields?: Json;
|
|
165
215
|
fn: ResolvedFunction;
|
|
166
216
|
artifactKeys: string[];
|
|
217
|
+
/** Values available to dotted-path templates; null disables context-based rendering. */
|
|
167
218
|
context: TemplateContext;
|
|
168
219
|
now: string;
|
|
169
220
|
}): Json {
|
package/src/queue.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// An in-memory job queue: run tasks with a concurrency limit and keep their
|
|
2
2
|
// records and results, for a server that hands out job ids.
|
|
3
3
|
import { availableParallelism } from 'node:os';
|
|
4
|
-
import { newJobId, newJobRecord, transitionJobRecord, type JobRecord } from './records.js';
|
|
4
|
+
import { newJobId, newJobRecord, transitionJobRecord, updateJobProgress, type JobRecord } from './records.js';
|
|
5
5
|
import { timestampIso } from './util.js';
|
|
6
6
|
|
|
7
|
+
/** A queued task, its current record, completion promise, optional result, and cancellation method. */
|
|
7
8
|
export interface Job<R> {
|
|
9
|
+
/** Current serializable job state, updated as the task progresses. */
|
|
8
10
|
record: JobRecord;
|
|
11
|
+
/** Task result or configured result templates, according to the containing interface. */
|
|
9
12
|
result?: R;
|
|
10
13
|
/** Resolves with the result when the job is done (rejects when it fails). */
|
|
11
14
|
done: Promise<R>;
|
|
@@ -13,6 +16,7 @@ export interface Job<R> {
|
|
|
13
16
|
cancel(): void;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/** Concurrency, retention, ID prefix, and lifecycle callbacks for the in-memory queue. */
|
|
16
20
|
export interface QueueOptions {
|
|
17
21
|
/** Jobs run at the same time (default: the number of CPUs). */
|
|
18
22
|
concurrency?: number;
|
|
@@ -26,19 +30,25 @@ export interface QueueOptions {
|
|
|
26
30
|
onDrop?: (record: JobRecord) => void;
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/** An in-memory queue with bounded concurrency and retention. Await each job.done to observe its result. */
|
|
29
34
|
export class JobQueue<R = unknown> {
|
|
30
35
|
private jobs = new Map<string, Job<R>>();
|
|
31
36
|
private waiting: (() => void)[] = [];
|
|
32
37
|
private running = 0;
|
|
33
38
|
private finished: string[] = [];
|
|
34
39
|
|
|
40
|
+
/** Create an instance with the supplied configuration; omitted fields use the documented defaults. */
|
|
35
41
|
constructor(private opts: QueueOptions = {}) {}
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* Queue `task` as a job of `functionName`. The task gets an AbortSignal and
|
|
39
45
|
* a `stage` setter for progress; its return value is the job's result.
|
|
40
46
|
*/
|
|
41
|
-
add(functionName: string, task: (ctx: {
|
|
47
|
+
add(functionName: string, task: (ctx: {
|
|
48
|
+
/** Termination signal, or null for an ordinary exit. RunOptions uses an AbortSignal for cancellation. */ signal: AbortSignal;
|
|
49
|
+
/** Current progress stage, or null when unspecified. */ stage: (stage: string) => void;
|
|
50
|
+
/** Record a progress update, e.g. runScriptFunction's onProgress (no history entry). */ progress: (update: Record<string, unknown>) => void;
|
|
51
|
+
}) => Promise<R>, fields: Record<string, unknown> = {}): Job<R> {
|
|
42
52
|
const id = newJobId(functionName, this.opts.prefix ?? 'job');
|
|
43
53
|
const abort = new AbortController();
|
|
44
54
|
const job = { record: newJobRecord({ jobId: id, functionName, fields }) } as Job<R>;
|
|
@@ -52,7 +62,15 @@ export class JobQueue<R = unknown> {
|
|
|
52
62
|
try {
|
|
53
63
|
abort.signal.throwIfAborted();
|
|
54
64
|
update({ status: 'processing' });
|
|
55
|
-
const result = await task({
|
|
65
|
+
const result = await task({
|
|
66
|
+
signal: abort.signal,
|
|
67
|
+
stage: (stage) => update({ status: 'processing', stage }),
|
|
68
|
+
progress: (p) => {
|
|
69
|
+
if (job.record.status !== 'processing') return;
|
|
70
|
+
job.record = updateJobProgress(job.record, p);
|
|
71
|
+
this.opts.onChange?.(job.record);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
56
74
|
job.result = result;
|
|
57
75
|
// A cancelled task that still returns (a killed tool) is cancelled all the same.
|
|
58
76
|
abort.signal.throwIfAborted();
|
|
@@ -71,10 +89,12 @@ export class JobQueue<R = unknown> {
|
|
|
71
89
|
return job;
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
/** Look up a retained job by ID; returns undefined when unknown or dropped. */
|
|
74
93
|
get(id: string): Job<R> | undefined {
|
|
75
94
|
return this.jobs.get(id);
|
|
76
95
|
}
|
|
77
96
|
|
|
97
|
+
/** Return the current records for all retained jobs, including pending and finished jobs. */
|
|
78
98
|
list(): JobRecord[] {
|
|
79
99
|
return [...this.jobs.values()].map((j) => j.record);
|
|
80
100
|
}
|
package/src/records.ts
CHANGED
|
@@ -5,19 +5,33 @@
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { timestampIso } from './util.js';
|
|
7
7
|
|
|
8
|
+
/** Job lifecycle: pending, processing, done, or error. */
|
|
8
9
|
export type JobStatus = 'pending' | 'processing' | 'done' | 'error';
|
|
9
10
|
|
|
11
|
+
/** A serializable job state with lifecycle timestamps, error information, and history. */
|
|
10
12
|
export interface JobRecord {
|
|
13
|
+
/** Unique identifier for the job. */
|
|
11
14
|
job_id: string;
|
|
15
|
+
/** Configured function associated with the job. */
|
|
12
16
|
function: string;
|
|
17
|
+
/** Lifecycle state or HTTP input-error status, according to the containing type. */
|
|
13
18
|
status: JobStatus;
|
|
19
|
+
/** Current progress stage, or null when unspecified. */
|
|
14
20
|
stage: string | null;
|
|
21
|
+
/** ISO timestamp when the record was created. */
|
|
15
22
|
started_at: string;
|
|
23
|
+
/** ISO timestamp of the last transition. */
|
|
16
24
|
updated_at: string;
|
|
25
|
+
/** ISO timestamp when execution finished, or null while unfinished. */
|
|
17
26
|
completed_at: string | null;
|
|
27
|
+
/** Failure message, or null while no error has been recorded. */
|
|
18
28
|
error: string | null;
|
|
29
|
+
/** Storage key of the result, or null when absent. */
|
|
19
30
|
result_key: string | null;
|
|
20
|
-
|
|
31
|
+
/** The latest update from the tool's `progress` role (spec section 14), when it has one. */
|
|
32
|
+
progress?: Record<string, unknown> | null;
|
|
33
|
+
/** Earlier job states, newest first. */
|
|
34
|
+
history: { /** Lifecycle state or HTTP input-error status, according to the containing type. */ status: JobStatus; /** Current progress stage, or null when unspecified. */ stage: string | null; /** ISO timestamp of the last transition. */ updated_at: string }[];
|
|
21
35
|
[field: string]: unknown;
|
|
22
36
|
}
|
|
23
37
|
|
|
@@ -28,7 +42,7 @@ export function newJobId(seed: string, prefix = 'job'): string {
|
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
/** A new record in the `pending` state; `fields` are flattened onto it. */
|
|
31
|
-
export function newJobRecord(p: { jobId: string; functionName: string; fields?: Record<string, unknown>; now?: string }): JobRecord {
|
|
45
|
+
export function newJobRecord(p: { jobId: string; /** Name of the configured function to invoke. */ functionName: string; fields?: Record<string, unknown>; now?: string }): JobRecord {
|
|
32
46
|
const now = p.now ?? timestampIso();
|
|
33
47
|
return {
|
|
34
48
|
job_id: p.jobId,
|
|
@@ -48,7 +62,7 @@ export function newJobRecord(p: { jobId: string; functionName: string; fields?:
|
|
|
48
62
|
/** Move a record on, pushing its current state onto the front of `history`. Returns a new record. */
|
|
49
63
|
export function transitionJobRecord(
|
|
50
64
|
record: JobRecord,
|
|
51
|
-
change: { status: JobStatus; stage?: string | null; error?: string | null; resultKey?: string | null; completedAt?: string | null; now?: string; extra?: Record<string, unknown> },
|
|
65
|
+
change: { /** Lifecycle state or HTTP input-error status, according to the containing type. */ status: JobStatus; /** Current progress stage, or null when unspecified. */ stage?: string | null; /** Write a timestamped error to stderr; supports printf-style arguments. */ error?: string | null; resultKey?: string | null; completedAt?: string | null; now?: string; extra?: Record<string, unknown> },
|
|
52
66
|
): JobRecord {
|
|
53
67
|
return {
|
|
54
68
|
...record,
|
|
@@ -62,3 +76,12 @@ export function transitionJobRecord(
|
|
|
62
76
|
result_key: change.resultKey || null,
|
|
63
77
|
};
|
|
64
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record a progress update (spec section 14) without a history entry, so frequent updates don't
|
|
82
|
+
* grow it: `progress` is the update, and its `stage`, when it has one, becomes the record's.
|
|
83
|
+
*/
|
|
84
|
+
export function updateJobProgress(record: JobRecord, progress: Record<string, unknown>, now?: string): JobRecord {
|
|
85
|
+
const stage = typeof progress.stage === 'string' ? progress.stage : record.stage;
|
|
86
|
+
return { ...record, progress, stage, updated_at: now ?? timestampIso() };
|
|
87
|
+
}
|
package/src/template.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// `${dot.path}` and `{{dot.path}}` templates in config values.
|
|
2
|
+
/** Values available to dotted-path template expressions. */
|
|
2
3
|
export type TemplateContext = { [key: string]: unknown };
|
|
3
4
|
|
|
4
5
|
/** A dot-path value from the context; unknown paths are errors, objects become JSON. */
|
|
@@ -14,6 +15,7 @@ export function lookupTemplateValue(context: TemplateContext, expression: string
|
|
|
14
15
|
return typeof current === 'object' ? JSON.stringify(current) : String(current);
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** Replace dotted-path ${expression} and {{expression}} templates using the context. */
|
|
17
19
|
export function renderTemplateString(value: string, context: TemplateContext): string {
|
|
18
20
|
return String(value).replace(/\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, dollar, brace) =>
|
|
19
21
|
lookupTemplateValue(context, dollar || brace),
|
package/src/util.ts
CHANGED
|
@@ -17,10 +17,12 @@ export function truthy(value: unknown): boolean {
|
|
|
17
17
|
return /^(true|1|yes|on)$/i.test(String(value));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Create the directory and missing ancestors; filesystem failures throw. */
|
|
20
21
|
export function ensureDir(dir: string): void {
|
|
21
22
|
mkdirSync(dir, { recursive: true });
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Read and parse a UTF-8 JSON file; malformed JSON and filesystem failures throw. */
|
|
24
26
|
export function readJson<T = unknown>(file: string): T {
|
|
25
27
|
return JSON.parse(readFileSync(file, 'utf8')) as T;
|
|
26
28
|
}
|
|
@@ -39,14 +41,17 @@ export function statFollowingSymlinks(file: string): Stats | null {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/** Whether the path exists and refers to a directory, following symlinks. */
|
|
42
45
|
export function isDirectoryPath(file: string): boolean {
|
|
43
46
|
return Boolean(statFollowingSymlinks(file)?.isDirectory());
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** Whether the path exists and refers to a regular file, following symlinks. */
|
|
46
50
|
export function isFilePath(file: string): boolean {
|
|
47
51
|
return Boolean(statFollowingSymlinks(file)?.isFile());
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/** Create destination parents and copy the file; filesystem failures throw. */
|
|
50
55
|
export function copyFileEnsuringDir(source: string, destination: string): void {
|
|
51
56
|
ensureDir(dirname(destination));
|
|
52
57
|
copyFileSync(source, destination);
|
|
@@ -56,9 +61,10 @@ export function copyFileEnsuringDir(source: string, destination: string): void {
|
|
|
56
61
|
export function pathParts(file: string) {
|
|
57
62
|
const name = basename(file);
|
|
58
63
|
const ext = extname(name);
|
|
59
|
-
return { path: file, dir: dirname(file), name, basename: ext ? name.slice(0, -ext.length) : name, ext: ext.replace(/^\./, '') };
|
|
64
|
+
return { /** Resolved filesystem path or HTTP endpoint path, according to the containing type. */ path: file, /** Directory represented by this group or specified in .clyops settings. */ dir: dirname(file), /** Program, command, option, or argument name as declared in its containing type. */ name, /** Filename without its extension. */ basename: ext ? name.slice(0, -ext.length) : name, /** Filename extension including the leading dot. */ ext: ext.replace(/^\./, '') };
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
/** A JSON object used for job configuration. */
|
|
62
68
|
export type Json = { [key: string]: unknown };
|
|
63
69
|
|
|
64
70
|
/** A plain object, or `{}` for scalars, arrays and null. */
|