clyops-jobs 0.4.0 → 0.6.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 +12 -2
- package/clyops-jobs.cjs +89 -6
- package/dist/functions.d.ts +13 -5
- package/dist/functions.d.ts.map +1 -1
- package/dist/functions.js +30 -2
- package/dist/queue.d.ts +3 -2
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +11 -2
- package/dist/records.d.ts +7 -0
- package/dist/records.d.ts.map +1 -1
- package/dist/records.js +8 -0
- package/package.json +2 -2
- package/src/functions.ts +41 -3
- package/src/queue.ts +15 -3
- package/src/records.ts +11 -0
package/README.md
CHANGED
|
@@ -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')`,
|
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);
|
|
@@ -342,6 +344,7 @@ function redactArgv(schema, argv) {
|
|
|
342
344
|
var import_node_child_process2 = require("node:child_process");
|
|
343
345
|
var import_node_stream = require("node:stream");
|
|
344
346
|
var import_node_string_decoder = require("node:string_decoder");
|
|
347
|
+
var ROLE_NAME = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
|
345
348
|
var ABORT_GRACE_MS = 200;
|
|
346
349
|
var STOP_WAIT_MS = 1e3;
|
|
347
350
|
function terminateTree(child, signal) {
|
|
@@ -364,10 +367,15 @@ function terminateTree(child, signal) {
|
|
|
364
367
|
}
|
|
365
368
|
function start(file, argv, opts = {}) {
|
|
366
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(",");
|
|
367
375
|
const child = (0, import_node_child_process2.spawn)(file, argv, {
|
|
368
376
|
cwd: opts.cwd,
|
|
369
|
-
env
|
|
370
|
-
stdio: [opts.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"],
|
|
377
|
+
env,
|
|
378
|
+
stdio: [opts.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe", ...roles.map(() => "pipe")],
|
|
371
379
|
// POSIX detached children lead a new session/group, still with owned pipes.
|
|
372
380
|
detached: process.platform !== "win32",
|
|
373
381
|
windowsHide: true
|
|
@@ -394,6 +402,26 @@ function start(file, argv, opts = {}) {
|
|
|
394
402
|
const mode = opts.stdout ?? "text";
|
|
395
403
|
const stdout = mode === "stream" ? () => Buffer.alloc(0) : collect(childOut, opts.onStdout);
|
|
396
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
|
+
});
|
|
397
425
|
const result = new Promise((resolve5, reject) => {
|
|
398
426
|
let timedOut = false;
|
|
399
427
|
let failure;
|
|
@@ -422,6 +450,10 @@ function start(file, argv, opts = {}) {
|
|
|
422
450
|
}
|
|
423
451
|
}
|
|
424
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
|
+
}
|
|
425
457
|
};
|
|
426
458
|
const finish = async (exitCode, signal) => {
|
|
427
459
|
if (settled) return;
|
|
@@ -447,6 +479,7 @@ function start(file, argv, opts = {}) {
|
|
|
447
479
|
...mode === "buffer" ? { stdoutBuffer: out } : {},
|
|
448
480
|
stderr: stderr().toString("utf8"),
|
|
449
481
|
...truncated ? { truncated } : {},
|
|
482
|
+
...roleOut.length ? { roles: Object.fromEntries(roleOut.map((r) => [r.name, r.text().toString("utf8")])) } : {},
|
|
450
483
|
durationMs: Date.now() - started
|
|
451
484
|
});
|
|
452
485
|
};
|
|
@@ -463,7 +496,7 @@ function start(file, argv, opts = {}) {
|
|
|
463
496
|
}
|
|
464
497
|
deadline = setTimeout(() => {
|
|
465
498
|
treeStopped = terminateTree(child, "SIGKILL");
|
|
466
|
-
for (const stream of [childOut, childErr]) {
|
|
499
|
+
for (const stream of [childOut, childErr, ...roleOut.map((r) => r.stream)]) {
|
|
467
500
|
if (!stream.destroyed) {
|
|
468
501
|
stream.once("end", () => stream.destroy());
|
|
469
502
|
stream.push(null);
|
|
@@ -504,6 +537,13 @@ function start(file, argv, opts = {}) {
|
|
|
504
537
|
else source.pipe(child.stdin);
|
|
505
538
|
} else child.stdin.end(opts.stdin);
|
|
506
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
|
+
}
|
|
507
547
|
if (opts.signal?.aborted) abort();
|
|
508
548
|
});
|
|
509
549
|
return { child, stdout: childOut, result };
|
|
@@ -515,6 +555,12 @@ function shellQuote(args) {
|
|
|
515
555
|
return args.map((a) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
|
516
556
|
}
|
|
517
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
|
+
|
|
518
564
|
// packages/jobs/src/functions.ts
|
|
519
565
|
var import_node_fs4 = require("node:fs");
|
|
520
566
|
var import_promises = require("node:stream/promises");
|
|
@@ -562,6 +608,14 @@ async function buildFunctionCommand(fn, opts, schema) {
|
|
|
562
608
|
for (const key of unknown) LOGGER.warn("[%s] ignoring config key %s; no matching CLI option in schema", name, key);
|
|
563
609
|
return [fn.script, ...argv];
|
|
564
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
|
+
}
|
|
565
619
|
function streamPath(template, opts, base) {
|
|
566
620
|
if (!template) return void 0;
|
|
567
621
|
const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
|
|
@@ -575,6 +629,19 @@ async function runScriptFunction(fn, opts) {
|
|
|
575
629
|
const workDir = opts.context?.paths?.work_dir;
|
|
576
630
|
const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
|
|
577
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
|
+
};
|
|
578
645
|
const started = start(file, argv, {
|
|
579
646
|
cwd: opts.cwd,
|
|
580
647
|
env: opts.env,
|
|
@@ -582,7 +649,8 @@ async function runScriptFunction(fn, opts) {
|
|
|
582
649
|
signal: opts.signal,
|
|
583
650
|
onStderr: opts.onStderr,
|
|
584
651
|
stdin: stdinPath ? (0, import_node_fs4.createReadStream)(stdinPath) : void 0,
|
|
585
|
-
stdout: stdoutPath ? "stream" : "text"
|
|
652
|
+
stdout: stdoutPath ? "stream" : "text",
|
|
653
|
+
roles: declaredRoles({ schema }, roles)
|
|
586
654
|
});
|
|
587
655
|
let written;
|
|
588
656
|
if (stdoutPath) {
|
|
@@ -590,7 +658,8 @@ async function runScriptFunction(fn, opts) {
|
|
|
590
658
|
written = (0, import_promises.finished)(started.stdout.pipe((0, import_node_fs4.createWriteStream)(stdoutPath)));
|
|
591
659
|
}
|
|
592
660
|
const [result] = await Promise.all([started.result, written]);
|
|
593
|
-
|
|
661
|
+
const meta = parseRoleJson(result.roles?.meta);
|
|
662
|
+
return { ...result, command, stderrTail: tail(result.stderr), ...meta !== void 0 ? { meta } : {}, ...progress ? { progress } : {} };
|
|
594
663
|
}
|
|
595
664
|
function buildResultRecord(p) {
|
|
596
665
|
return {
|
|
@@ -736,6 +805,10 @@ function transitionJobRecord(record, change) {
|
|
|
736
805
|
result_key: change.resultKey || null
|
|
737
806
|
};
|
|
738
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
|
+
}
|
|
739
812
|
|
|
740
813
|
// packages/jobs/src/queue.ts
|
|
741
814
|
var import_node_os = require("node:os");
|
|
@@ -767,7 +840,15 @@ var JobQueue = class {
|
|
|
767
840
|
try {
|
|
768
841
|
abort.signal.throwIfAborted();
|
|
769
842
|
update({ status: "processing" });
|
|
770
|
-
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
|
+
});
|
|
771
852
|
job.result = result;
|
|
772
853
|
abort.signal.throwIfAborted();
|
|
773
854
|
update({ status: "done", completedAt: timestampIso() });
|
|
@@ -835,6 +916,7 @@ var JobQueue = class {
|
|
|
835
916
|
newJobId,
|
|
836
917
|
newJobRecord,
|
|
837
918
|
objectValue,
|
|
919
|
+
parseRoleJson,
|
|
838
920
|
pathParts,
|
|
839
921
|
readJson,
|
|
840
922
|
renderConfigValue,
|
|
@@ -850,5 +932,6 @@ var JobQueue = class {
|
|
|
850
932
|
timestampIso,
|
|
851
933
|
transitionJobRecord,
|
|
852
934
|
truthy,
|
|
935
|
+
updateJobProgress,
|
|
853
936
|
writeJson
|
|
854
937
|
});
|
package/dist/functions.d.ts
CHANGED
|
@@ -69,18 +69,26 @@ export declare function buildFunctionCommand(fn: ResolvedFunction, opts: Command
|
|
|
69
69
|
export interface FunctionRun extends RunResult {
|
|
70
70
|
/** The last 40 lines of stderr without color codes. */
|
|
71
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>;
|
|
72
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;
|
|
73
79
|
/**
|
|
74
80
|
* Run a function's tool. Its stdin is the function's `stdin` file, if any;
|
|
75
81
|
* stdout goes to its `stdout` file, else is collected (a tool may print its
|
|
76
82
|
* result), and stderr is collected. Secret options are `***` in `command`.
|
|
77
83
|
*/
|
|
78
84
|
export declare function runScriptFunction(fn: ResolvedFunction, opts: CommandOptions & {
|
|
79
|
-
timeoutMs?: number;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
onStderr?: (chunk: string) => void;
|
|
85
|
+
timeoutMs?: number;
|
|
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;
|
|
84
92
|
}): Promise<FunctionRun>;
|
|
85
93
|
/** The result record every function shares, plus its rendered `result` map. */
|
|
86
94
|
export declare function buildResultRecord(p: {
|
package/dist/functions.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"AASA,OAAO,
|
|
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/queue.d.ts
CHANGED
|
@@ -37,8 +37,9 @@ export declare class JobQueue<R = unknown> {
|
|
|
37
37
|
* a `stage` setter for progress; its return value is the job's result.
|
|
38
38
|
*/
|
|
39
39
|
add(functionName: string, task: (ctx: {
|
|
40
|
-
|
|
41
|
-
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;
|
|
42
43
|
}) => Promise<R>, fields?: Record<string, unknown>): Job<R>;
|
|
43
44
|
/** Look up a retained job by ID; returns undefined when unknown or dropped. */
|
|
44
45
|
get(id: string): Job<R> | undefined;
|
package/dist/queue.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAGA,OAAO,
|
|
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,7 +1,7 @@
|
|
|
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
6
|
/** An in-memory queue with bounded concurrency and retention. Await each job.done to observe its result. */
|
|
7
7
|
export class JobQueue {
|
|
@@ -32,7 +32,16 @@ export class JobQueue {
|
|
|
32
32
|
try {
|
|
33
33
|
abort.signal.throwIfAborted();
|
|
34
34
|
update({ status: 'processing' });
|
|
35
|
-
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
|
+
});
|
|
36
45
|
job.result = result;
|
|
37
46
|
// A cancelled task that still returns (a killed tool) is cancelled all the same.
|
|
38
47
|
abort.signal.throwIfAborted();
|
package/dist/records.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface JobRecord {
|
|
|
20
20
|
error: string | null;
|
|
21
21
|
/** Storage key of the result, or null when absent. */
|
|
22
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;
|
|
23
25
|
/** Earlier job states, newest first. */
|
|
24
26
|
history: {
|
|
25
27
|
status: JobStatus; /** Current progress stage, or null when unspecified. */
|
|
@@ -47,4 +49,9 @@ export declare function transitionJobRecord(record: JobRecord, change: {
|
|
|
47
49
|
now?: string;
|
|
48
50
|
extra?: Record<string, unknown>;
|
|
49
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;
|
|
50
57
|
//# sourceMappingURL=records.d.ts.map
|
package/dist/records.d.ts.map
CHANGED
|
@@ -1 +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,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"}
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,7 +38,7 @@
|
|
|
38
38
|
"typescript": "^5.6.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"clyops-tools": "0.
|
|
41
|
+
"clyops-tools": "0.6.0"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://clyops.io/node-tools.html",
|
|
44
44
|
"scripts": {
|
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';
|
|
@@ -130,6 +130,20 @@ export async function buildFunctionCommand(fn: ResolvedFunction, opts: CommandOp
|
|
|
130
130
|
export interface FunctionRun extends RunResult {
|
|
131
131
|
/** The last 40 lines of stderr without color codes. */
|
|
132
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
|
+
}
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
/** A function's `stdin` or `stdout` template rendered, a relative one against `base`. */
|
|
@@ -146,7 +160,15 @@ function streamPath(template: string | undefined, opts: CommandOptions, base: st
|
|
|
146
160
|
*/
|
|
147
161
|
export async function runScriptFunction(
|
|
148
162
|
fn: ResolvedFunction,
|
|
149
|
-
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
|
+
},
|
|
150
172
|
): Promise<FunctionRun> {
|
|
151
173
|
const schema = await loadSchema(fn.script);
|
|
152
174
|
const [file, ...argv] = await buildFunctionCommand(fn, opts, schema);
|
|
@@ -156,10 +178,25 @@ export async function runScriptFunction(
|
|
|
156
178
|
const workDir = (opts.context?.paths as { work_dir?: string } | undefined)?.work_dir;
|
|
157
179
|
const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
|
|
158
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
|
+
};
|
|
159
195
|
const started = start(file, argv, {
|
|
160
196
|
cwd: opts.cwd, env: opts.env, timeoutMs: opts.timeoutMs, signal: opts.signal, onStderr: opts.onStderr,
|
|
161
197
|
stdin: stdinPath ? createReadStream(stdinPath) : undefined,
|
|
162
198
|
stdout: stdoutPath ? 'stream' : 'text',
|
|
199
|
+
roles: declaredRoles({ schema }, roles),
|
|
163
200
|
});
|
|
164
201
|
let written: Promise<void> | undefined;
|
|
165
202
|
if (stdoutPath) {
|
|
@@ -167,7 +204,8 @@ export async function runScriptFunction(
|
|
|
167
204
|
written = finished(started.stdout.pipe(createWriteStream(stdoutPath)));
|
|
168
205
|
}
|
|
169
206
|
const [result] = await Promise.all([started.result, written]);
|
|
170
|
-
|
|
207
|
+
const meta = parseRoleJson(result.roles?.meta);
|
|
208
|
+
return { ...result, command, stderrTail: tail(result.stderr), ...(meta !== undefined ? { meta } : {}), ...(progress ? { progress } : {}) };
|
|
171
209
|
}
|
|
172
210
|
|
|
173
211
|
/** The result record every function shares, plus its rendered `result` map. */
|
package/src/queue.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
/** A queued task, its current record, completion promise, optional result, and cancellation method. */
|
|
@@ -44,7 +44,11 @@ export class JobQueue<R = unknown> {
|
|
|
44
44
|
* Queue `task` as a job of `functionName`. The task gets an AbortSignal and
|
|
45
45
|
* a `stage` setter for progress; its return value is the job's result.
|
|
46
46
|
*/
|
|
47
|
-
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> {
|
|
48
52
|
const id = newJobId(functionName, this.opts.prefix ?? 'job');
|
|
49
53
|
const abort = new AbortController();
|
|
50
54
|
const job = { record: newJobRecord({ jobId: id, functionName, fields }) } as Job<R>;
|
|
@@ -58,7 +62,15 @@ export class JobQueue<R = unknown> {
|
|
|
58
62
|
try {
|
|
59
63
|
abort.signal.throwIfAborted();
|
|
60
64
|
update({ status: 'processing' });
|
|
61
|
-
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
|
+
});
|
|
62
74
|
job.result = result;
|
|
63
75
|
// A cancelled task that still returns (a killed tool) is cancelled all the same.
|
|
64
76
|
abort.signal.throwIfAborted();
|
package/src/records.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface JobRecord {
|
|
|
28
28
|
error: string | null;
|
|
29
29
|
/** Storage key of the result, or null when absent. */
|
|
30
30
|
result_key: string | null;
|
|
31
|
+
/** The latest update from the tool's `progress` role (spec section 14), when it has one. */
|
|
32
|
+
progress?: Record<string, unknown> | null;
|
|
31
33
|
/** Earlier job states, newest first. */
|
|
32
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 }[];
|
|
33
35
|
[field: string]: unknown;
|
|
@@ -74,3 +76,12 @@ export function transitionJobRecord(
|
|
|
74
76
|
result_key: change.resultKey || null,
|
|
75
77
|
};
|
|
76
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
|
+
}
|