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 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](../../spec/SPEC.md#13-json-input-toargv)).
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 { path: file, dir: (0, import_node_path.dirname)(file), name, basename: ext ? name.slice(0, -ext.length) : name, ext: ext.replace(/^\./, "") };
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
- var InputError = class extends Error {
199
- status = 400;
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: opts.env ?? process.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
- return { ...result, command, stderrTail: tail(result.stderr) };
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({ signal: abort.signal, stage: (stage) => update({ status: "processing", stage }) });
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
  });
@@ -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 = '^';
@@ -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
- return { ...result, command, stderrTail: tail(result.stderr) };
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
@@ -5,3 +5,4 @@ export * from './artifacts.js';
5
5
  export * from './records.js';
6
6
  export * from './queue.js';
7
7
  export { shellQuote } from 'clyops-tools';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -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({ signal: abort.signal, stage: (stage) => update({ status: 'processing', stage }) });
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
+ }
@@ -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.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.3.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 & { timeoutMs?: number; env?: NodeJS.ProcessEnv; cwd?: string; signal?: AbortSignal; onStderr?: (chunk: string) => void },
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
- return { ...result, command, stderrTail: tail(result.stderr) };
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: { signal: AbortSignal; stage: (stage: string) => void }) => Promise<R>, fields: Record<string, unknown> = {}): Job<R> {
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({ signal: abort.signal, stage: (stage) => update({ status: 'processing', stage }) });
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
- history: { status: JobStatus; stage: string | null; updated_at: string }[];
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. */