clyops-jobs 0.4.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
@@ -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: opts.env ?? process.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
- 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 } : {} };
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({ 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
+ });
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
  });
@@ -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; /** Environment supplied to the process or read for option defaults. */
80
- env?: NodeJS.ProcessEnv; /** Working directory used to resolve command-line paths and execute tools. */
81
- cwd?: string; /** AbortSignal used to cancel execution and terminate the tool process tree. */
82
- signal?: AbortSignal; /** Called for each decoded stderr chunk. */
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: {
@@ -1 +1 @@
1
- {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"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"}
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/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
- signal: AbortSignal; /** Current progress stage, or null when unspecified. */
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;
@@ -1 +1 @@
1
- {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAGA,OAAO,EAA+C,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAG3F,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;QAA4G,MAAM,EAAE,WAAW,CAAC,CAAC,wDAAwD;QAAC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,GAAG,CAAC,CAAC,CAAC;IAiC9T,+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"}
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({ 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
+ });
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
@@ -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.4.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,7 +38,7 @@
38
38
  "typescript": "^5.6.0"
39
39
  },
40
40
  "dependencies": {
41
- "clyops-tools": "0.4.0"
41
+ "clyops-tools": "0.5.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 & { timeoutMs?: number; /** Environment supplied to the process or read for option defaults. */ env?: NodeJS.ProcessEnv; /** Working directory used to resolve command-line paths and execute tools. */ cwd?: string; /** AbortSignal used to cancel execution and terminate the tool process tree. */ signal?: AbortSignal; /** Called for each decoded stderr chunk. */ 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
+ },
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
- 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 } : {}) };
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: { /** Termination signal, or null for an ordinary exit. RunOptions uses an AbortSignal for cancellation. */ signal: AbortSignal; /** Current progress stage, or null when unspecified. */ 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> {
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({ 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
+ });
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
+ }