clyops-jobs 0.9.0 → 0.11.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
@@ -27,6 +27,19 @@ and no `node_modules`. It includes clyops-tools. `require('clyops-jobs')` loads
27
27
  }
28
28
  ```
29
29
 
30
+ **Editor support.** The config has a JSON Schema, so editors complete and check it. Name it in
31
+ the file, from the site or from the installed package:
32
+
33
+ ```json
34
+ { "$schema": "https://clyops.io/schemas/clyops-jobs.json", "functions": {} }
35
+ ```
36
+
37
+ It's `config.schema.json` in the package (`clyops-jobs/config.schema.json`, e.g.
38
+ `"./node_modules/clyops-jobs/config.schema.json"`). It catches typos in function and step
39
+ fields (`x-` fields are free for your own notes), `script` and `steps` together, and bad step
40
+ names; that a branch names a later step is checked when the function is resolved. Options
41
+ blocks are any object, since their keys come from each tool's schema.
42
+
30
43
  - `functions.NAME.script` is the tool, relative to a script root.
31
44
  - `key` names the options block (default: the function name). Options are
32
45
  `deepMerge(defaults[key], config[key])`, keyed like the tool's options (`out_dir`, `out-dir`)
@@ -35,7 +48,12 @@ and no `node_modules`. It includes clyops-tools. `require('clyops-jobs')` loads
35
48
  - `positionals` (optional) lists the tool's positional arguments explicitly; otherwise they're
36
49
  options keyed by argument name.
37
50
  - String values may use `${dot.path}` or `{{dot.path}}` templates against a context you build
38
- (`job`, `paths`, whatever the trigger knows).
51
+ (`job`, `paths`, whatever the trigger knows). An unknown path is an error, so a typo fails
52
+ loudly, except under `params` (the caller's fields): an option or positional that names a
53
+ missing or null `params` field is left unset, so the tool's default applies. That holds for
54
+ `${params.x}` alone and inside a longer string (`v-${params.x}`), so a value is never half
55
+ filled. Elsewhere (`result`, `stdin`, `stdout`, artifact patterns) a missing field renders as
56
+ `''`. `strict_params: true` on a function makes a missing `params` field an error too.
39
57
  - `artifacts`: glob patterns (`*`, `?`, `**`) relative to `paths.work_dir`, copied into
40
58
  `paths.artifacts_dir`, with an optional `required: true` or `min: N`.
41
59
  - `stdin`: a template naming a file fed to the tool's stdin (`"${media.path}"`; relative to the
@@ -43,6 +61,73 @@ and no `node_modules`. It includes clyops-tools. `require('clyops-jobs')` loads
43
61
  (`"${paths.work_dir}/out.mp3"`; relative to `paths.work_dir`), which also counts as an artifact.
44
62
  - `result`: a template map added to the result record.
45
63
 
64
+ ### Steps
65
+
66
+ A function can run several tools in order instead of one `script`, so a definition chains
67
+ tools that already exist (fetch some JSON, download what it names, process the file):
68
+
69
+ ```json
70
+ {
71
+ "functions": {
72
+ "fingerprint": {
73
+ "steps": [
74
+ { "name": "fetch", "script": "tools/http-get-json" },
75
+ { "name": "download", "script": "tools/download", "stdout": "${job.id}/media" },
76
+ { "name": "fp", "script": "tools/media-to-fp", "stdin": "${steps.download.stdout}" }
77
+ ],
78
+ "artifacts": { "include": ["${job.id}/*.fp"], "required": true },
79
+ "result": { "size": "${steps.fetch.result.size}" }
80
+ }
81
+ },
82
+ "fetch": { "url": "https://media.example.com/recording/${params.uuid}", "token": "${secrets.token}" },
83
+ "download": { "url": "${steps.fetch.result.signedUrl}" }
84
+ }
85
+ ```
86
+
87
+ - Each step is a binding like a function: `name` (letters, digits, `_`, `-`), `script`, and
88
+ optionally `key` (its options block, default the step name), `positionals`,
89
+ `positionals_order`, `stdin`, `stdout` and `strict_params` (default the function's).
90
+ `artifacts` and `result` belong to the function, as do the caller-input rules: a caller
91
+ reaches a step only through `params` fields its options name.
92
+ - Every step shares the context, `paths.work_dir` included, so files pass by path. A step's
93
+ outputs are `steps.NAME` in the templates after it: `exit_code`; `stdout`, the path of its
94
+ `stdout` file or else its collected output without trailing newlines; `result`, its
95
+ `result` role or else its collected stdout parsed as JSON; `meta`; `progress`. A
96
+ `${steps...}` path that isn't there, such as a later step or a field the JSON lacks, is an
97
+ error naming the step.
98
+ - `previous` is the step that ran just before, on whichever path led here, with the same
99
+ fields plus its `name`: a handler several branches share reads `${previous.name}`,
100
+ `${previous.exit_code}`, `${previous.result.field}` or, after an `error` branch,
101
+ `${previous.error}`. The first step has none, so naming it there is an error.
102
+ - After a step that succeeds the chain goes on to the next step, or ends after the last one.
103
+ A step that exits nonzero, times out or is stopped ends the chain; the run is that step's,
104
+ with `failedStep` naming it. A step that can't start (a template, stdin or schema error)
105
+ rejects the run. `continue_on_exit: [2]` on a step makes those codes a success, and the
106
+ chain then exits with the first of them (a partial result).
107
+ - Branches change where a step leads: `succeed`, `fail` (a nonzero exit or timeout) and
108
+ `error` (it couldn't start) each name a later step, and `succeed: "$end"` ends the chain.
109
+ Branches only go forward, so a chain always ends. A step reached only through a branch,
110
+ such as a fallback or a cleanup, still sits in the list, so the step before it usually
111
+ needs a `succeed`. The chain's exit code is its last step's: a fallback that succeeds
112
+ makes the job succeed, and a cleanup that should leave it failed exits nonzero. A step
113
+ that took its `error` branch is `steps.NAME.error` (the message), with `exit_code: null`.
114
+ A cancelled chain, or one out of time, takes no branch.
115
+
116
+ ```json
117
+ "steps": [
118
+ { "name": "fetch", "script": "tools/http-get-json", "fail": "mirror", "succeed": "process" },
119
+ { "name": "mirror", "script": "tools/http-get-json" },
120
+ { "name": "process", "script": "tools/media-to-fp" }
121
+ ]
122
+ ```
123
+ - `timeoutMs` limits the whole chain and a step's `timeout_ms` that step; `signal` stops the
124
+ running step. `onProgress` gets each step's updates with `step` and a default `stage`.
125
+ - The run is the last step's, plus `steps` (the outputs by name), `previous` (the last step)
126
+ and `stepRuns`. Render artifacts and the result with them through `runContext(context,
127
+ run)`, so a result map can use `${previous...}` whichever branch the chain ended on.
128
+ - `functionParamNames(fn)` lists the `params` fields a function's templates use, steps
129
+ included, for describing its caller inputs.
130
+
46
131
  ```js
47
132
  import { resolveFunctionConfig, runScriptFunction, copyConfiguredArtifacts, buildResultRecord,
48
133
  newJobId, newJobRecord, transitionJobRecord, timestampIso } from 'clyops-jobs';
@@ -68,6 +153,7 @@ record = transitionJobRecord(record, { status: 'done', completedAt: timestampIso
68
153
  When the tool declares the well-known [roles](https://github.com/wankdanker/clyops/blob/main/spec/SPEC.md#14-roles-on-file-descriptors),
69
154
  the run wires them up: each `progress` object goes to `onProgress` (and the last one is the run's
70
155
  `progress`), `log` lines to `onLog`, and the `meta` document is parsed into the run's `meta`.
156
+ The run's `result` is the `result` role parsed as JSON, or else the collected stdout when it is JSON.
71
157
  `updateJobProgress(record, update)` puts an update on a record as `progress` (and its `stage`, if
72
158
  any) without a history entry; the queue's tasks get it as `progress(update)`:
73
159
 
package/clyops-jobs.cjs CHANGED
@@ -32,10 +32,13 @@ __export(index_exports, {
32
32
  deepMerge: () => deepMerge,
33
33
  ensureDir: () => ensureDir,
34
34
  findFilesByArtifactPattern: () => findFilesByArtifactPattern,
35
+ functionParamNames: () => functionParamNames,
36
+ functionTemplateOptions: () => functionTemplateOptions,
35
37
  globToRegex: () => globToRegex,
36
38
  isDirectoryPath: () => isDirectoryPath,
37
39
  isEnabled: () => isEnabled,
38
40
  isFilePath: () => isFilePath,
41
+ isUnsetTemplate: () => isUnsetTemplate,
39
42
  listFilesRecursive: () => listFilesRecursive,
40
43
  lookupTemplateValue: () => lookupTemplateValue,
41
44
  newJobId: () => newJobId,
@@ -51,9 +54,11 @@ __export(index_exports, {
51
54
  resolveConfigPath: () => resolveConfigPath,
52
55
  resolveFunctionConfig: () => resolveFunctionConfig,
53
56
  resolveScriptPath: () => resolveScriptPath,
57
+ runContext: () => runContext,
54
58
  runScriptFunction: () => runScriptFunction,
55
59
  shellQuote: () => shellQuote,
56
60
  statFollowingSymlinks: () => statFollowingSymlinks,
61
+ templateParamNames: () => templateParamNames,
57
62
  timestampIso: () => timestampIso,
58
63
  transitionJobRecord: () => transitionJobRecord,
59
64
  truthy: () => truthy,
@@ -135,40 +140,69 @@ function resolveConfigPath(root, value) {
135
140
  var resolveScriptPath = resolveConfigPath;
136
141
 
137
142
  // packages/jobs/src/template.ts
138
- function lookupTemplateValue(context, expression) {
143
+ var OPTIONAL_ROOTS = ["params"];
144
+ var TEMPLATE = /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
145
+ var MISSING = /* @__PURE__ */ Symbol("missing");
146
+ function resolvePath(context, expression) {
139
147
  let current = context;
140
148
  for (const part of String(expression).split(".").filter(Boolean)) {
141
- if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, part)) {
142
- throw new Error(`unknown template value: ${expression}`);
143
- }
149
+ if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, part)) return MISSING;
144
150
  current = current[part];
145
151
  }
152
+ return current;
153
+ }
154
+ function optionalRoot(expression, opts) {
155
+ return !opts?.strict && OPTIONAL_ROOTS.includes(String(expression).split(".").filter(Boolean)[0] ?? "");
156
+ }
157
+ function lookupTemplateValue(context, expression, opts) {
158
+ const current = resolvePath(context, expression);
159
+ if (current === MISSING) {
160
+ if (optionalRoot(expression, opts)) return "";
161
+ throw new Error(`unknown template value: ${expression}`);
162
+ }
146
163
  if (current === void 0 || current === null) return "";
147
164
  return typeof current === "object" ? JSON.stringify(current) : String(current);
148
165
  }
149
- function renderTemplateString(value, context) {
150
- return String(value).replace(
151
- /\$\{\s*([A-Za-z0-9_.-]+)\s*\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g,
152
- (_, dollar, brace) => lookupTemplateValue(context, dollar || brace)
153
- );
166
+ function isUnsetTemplate(value, context, opts) {
167
+ if (typeof value !== "string") return false;
168
+ for (const [, dollar, brace] of value.matchAll(TEMPLATE)) {
169
+ const expression = dollar || brace;
170
+ if (!optionalRoot(expression, opts)) continue;
171
+ const current = resolvePath(context, expression);
172
+ if (current === MISSING || current === void 0 || current === null) return true;
173
+ }
174
+ return false;
175
+ }
176
+ function templateParamNames(value, names = /* @__PURE__ */ new Set()) {
177
+ if (typeof value === "string") {
178
+ for (const [, dollar, brace] of value.matchAll(TEMPLATE)) {
179
+ const [root, field] = String(dollar || brace).split(".").filter(Boolean);
180
+ if (OPTIONAL_ROOTS.includes(root ?? "") && field) names.add(field);
181
+ }
182
+ } else if (Array.isArray(value)) for (const item of value) templateParamNames(item, names);
183
+ else if (value && typeof value === "object") for (const item of Object.values(value)) templateParamNames(item, names);
184
+ return names;
185
+ }
186
+ function renderTemplateString(value, context, opts) {
187
+ return String(value).replace(TEMPLATE, (_, dollar, brace) => lookupTemplateValue(context, dollar || brace, opts));
154
188
  }
155
- function renderConfigValue(value, context) {
189
+ function renderConfigValue(value, context, opts) {
156
190
  if (!context) return value;
157
- if (typeof value === "string") return renderTemplateString(value, context);
158
- if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context));
191
+ if (typeof value === "string") return renderTemplateString(value, context, opts);
192
+ if (Array.isArray(value)) return value.map((item) => renderConfigValue(item, context, opts));
159
193
  return value;
160
194
  }
161
- function renderResultValue(value, context) {
162
- if (typeof value === "string") return renderTemplateString(value, context);
163
- if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context));
195
+ function renderResultValue(value, context, opts) {
196
+ if (typeof value === "string") return renderTemplateString(value, context, opts);
197
+ if (Array.isArray(value)) return value.map((item) => renderResultValue(item, context, opts));
164
198
  if (value && typeof value === "object") {
165
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context)]));
199
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderResultValue(v, context, opts)]));
166
200
  }
167
201
  return value;
168
202
  }
169
- function renderResultMap(resultMap, context) {
203
+ function renderResultMap(resultMap, context, opts) {
170
204
  if (!resultMap || typeof resultMap !== "object" || Array.isArray(resultMap)) return {};
171
- return renderResultValue(resultMap, context);
205
+ return renderResultValue(resultMap, context, opts);
172
206
  }
173
207
 
174
208
  // packages/tools/src/schema.ts
@@ -236,6 +270,9 @@ function isPathValued(validation, type) {
236
270
  function isBareProgram(validation, value) {
237
271
  return validation === "file:executable" && value !== "" && !value.includes("/") && !(process.platform === "win32" && value.includes("\\"));
238
272
  }
273
+ function oneJsonValue(validation, many) {
274
+ return (validation === "json" || validation.startsWith("json:")) && !many;
275
+ }
239
276
  var TRUE = /^(true|1|yes|on)$/i;
240
277
  var FALSE = /^(false|0|no|off)$/i;
241
278
  function toArgv(schema, input, opts = {}) {
@@ -283,7 +320,7 @@ function toArgv(schema, input, opts = {}) {
283
320
  out.env[option.variableName] = text(found.value, isPathValued(option.validation, option.type), `--${option.name}`, option.validation);
284
321
  continue;
285
322
  }
286
- for (const item of Array.isArray(found.value) ? found.value : [found.value]) {
323
+ for (const item of Array.isArray(found.value) && !oneJsonValue(option.validation, option.isArray) ? found.value : [found.value]) {
287
324
  out.argv.push(`--${option.name}`, text(item, isPathValued(option.validation, option.type), `--${option.name}`, option.validation));
288
325
  }
289
326
  }
@@ -309,7 +346,7 @@ function toArgv(schema, input, opts = {}) {
309
346
  positionals.push(text(gap.default, false));
310
347
  }
311
348
  skipped = [];
312
- const values = Array.isArray(found.value) ? found.value : [found.value];
349
+ const values = Array.isArray(found.value) && !oneJsonValue(argument.validation, argument.isVariadic) ? found.value : [found.value];
313
350
  positionals.push(...values.map((v) => text(v, isPathValued(argument.validation), argument.name, argument.validation)));
314
351
  }
315
352
  }
@@ -591,31 +628,81 @@ var LOGGER = {
591
628
  function configureLogging(opts) {
592
629
  Object.assign(LOGGER, Object.fromEntries(Object.entries(opts).filter(([, v]) => v)));
593
630
  }
631
+ var STEP_NAME = /^[A-Za-z0-9_-]+$/;
632
+ var END = "$end";
594
633
  function resolveFunctionConfig(scriptRoot, routeConfig, functionName) {
595
634
  const definition = objectValue(objectValue(routeConfig.functions)[functionName]);
596
635
  if (Object.keys(definition).length === 0) return null;
597
636
  const key = String(definition.key || functionName);
637
+ if (definition.steps === void 0) return resolveBinding(scriptRoot, routeConfig, functionName, key, definition, `function ${functionName}`);
638
+ if (definition.script) throw new Error(`function ${functionName} defines both script and steps`);
639
+ if (!Array.isArray(definition.steps) || definition.steps.length === 0) throw new Error(`function ${functionName} defines no steps`);
640
+ const steps = [];
641
+ for (const [i, item] of definition.steps.entries()) {
642
+ const step = { strict_params: definition.strict_params, ...objectValue(item) };
643
+ const name = String(step.name ?? "");
644
+ if (!STEP_NAME.test(name)) throw new Error(`function ${functionName} step ${i + 1} needs a name of letters, digits, _ or -`);
645
+ if (steps.some((s) => s.stepName === name)) throw new Error(`function ${functionName} has two steps named ${name}`);
646
+ const where = `function ${functionName} step ${name}`;
647
+ steps.push({ ...resolveBinding(scriptRoot, routeConfig, functionName, String(step.key || name), step, where), stepName: name, definition: step });
648
+ }
649
+ for (const [i, step] of steps.entries()) {
650
+ for (const outcome of ["succeed", "fail", "error"]) {
651
+ const target = step.definition[outcome];
652
+ if (target === void 0 || outcome === "succeed" && target === END) continue;
653
+ const at = steps.findIndex((s) => s.stepName === target);
654
+ if (at < 0) throw new Error(`function ${functionName} step ${step.stepName}: ${outcome} names no step ${String(target)}`);
655
+ if (at <= i) throw new Error(`function ${functionName} step ${step.stepName}: ${outcome} must name a later step, not ${String(target)}`);
656
+ }
657
+ }
658
+ const config = Object.fromEntries(steps.map((s) => [s.stepName, s.config]));
659
+ return { functionName, key, script: steps[steps.length - 1].script, config, definition, steps };
660
+ }
661
+ function resolveBinding(scriptRoot, routeConfig, functionName, key, definition, where) {
598
662
  const script = String(definition.script || "");
599
- if (!script) throw new Error(`function ${functionName} does not define a script`);
663
+ if (!script) throw new Error(`${where} does not define a script`);
600
664
  const config = deepMerge(objectValue(objectValue(routeConfig.defaults)[key]), objectValue(routeConfig[key]));
601
665
  return { functionName, key, script: resolveScriptPath(scriptRoot, script), config, definition };
602
666
  }
667
+ function functionParamNames(fn) {
668
+ const names = /* @__PURE__ */ new Set();
669
+ for (const part of fn.steps ?? [fn]) {
670
+ const { positionals, stdin, stdout } = part.definition;
671
+ templateParamNames([part.config, positionals, stdin, stdout], names);
672
+ }
673
+ templateParamNames([fn.definition.artifacts, fn.definition.result], names);
674
+ return [...names].sort();
675
+ }
676
+ function functionTemplateOptions(fn) {
677
+ return { strict: fn.definition.strict_params === true };
678
+ }
679
+ function withoutUnsetParams(config, context, opts) {
680
+ const out = {};
681
+ for (const [key, value] of Object.entries(config)) {
682
+ if (isUnsetTemplate(value, context, opts)) continue;
683
+ out[key] = Array.isArray(value) ? value.filter((item) => !isUnsetTemplate(item, context, opts)) : value;
684
+ }
685
+ return out;
686
+ }
603
687
  function explicitPositionals(fn, context) {
604
688
  if (!Array.isArray(fn.definition.positionals)) return void 0;
605
- return fn.definition.positionals.map((item) => String(renderConfigValue(item, context))).filter(Boolean);
689
+ const templates = functionTemplateOptions(fn);
690
+ return fn.definition.positionals.filter((item) => !context || !isUnsetTemplate(item, context, templates)).map((item) => String(renderConfigValue(item, context, templates))).filter(Boolean);
606
691
  }
607
692
  async function buildFunctionCommand(fn, opts, schema) {
693
+ if (fn.steps) throw new Error(`function ${fn.functionName} runs steps; build each step's command`);
608
694
  schema ??= await loadSchema(fn.script);
609
- const config = { ...fn.config };
695
+ const context = opts.context;
696
+ const templates = functionTemplateOptions(fn);
697
+ const config = context ? withoutUnsetParams(fn.config, context, templates) : { ...fn.config };
610
698
  const first = schema.arguments[0];
611
699
  if (opts.defaultPositional && first && !Array.isArray(fn.definition.positionals) && !inputKeys(first).some((k) => k in config)) {
612
700
  config[inputKeys(first)[0]] = opts.defaultPositional;
613
701
  }
614
- const context = opts.context;
615
702
  const { argv, unknown, controlled } = toArgv(schema, config, {
616
703
  base: opts.configRoot,
617
704
  controlled: opts.controlled ?? ["help"],
618
- render: context ? (s) => renderTemplateString(s, context) : void 0,
705
+ render: context ? (s) => renderTemplateString(s, context, templates) : void 0,
619
706
  positionals: explicitPositionals(fn, context),
620
707
  positionalsOrder: fn.definition.positionals_order
621
708
  });
@@ -632,19 +719,22 @@ function parseRoleJson(text) {
632
719
  return void 0;
633
720
  }
634
721
  }
635
- function streamPath(template, opts, base) {
722
+ function streamPath(template, opts, base, templates) {
636
723
  if (!template) return void 0;
637
- const rendered = opts.context ? renderTemplateString(template, opts.context) : template;
724
+ const rendered = opts.context ? renderTemplateString(template, opts.context, templates) : template;
638
725
  return (0, import_node_path3.isAbsolute)(rendered) ? rendered : (0, import_node_path3.resolve)(base, rendered);
639
726
  }
640
727
  async function runScriptFunction(fn, opts) {
728
+ return fn.steps ? runSteps(fn.steps, opts) : (await runTool(fn, opts)).run;
729
+ }
730
+ async function runTool(fn, opts) {
641
731
  const schema = await loadSchema(fn.script);
642
732
  const [file, ...argv] = await buildFunctionCommand(fn, opts, schema);
643
733
  const command = [file, ...redactArgv(schema, argv)];
644
734
  LOGGER.verbose("command: %s", shellQuote(command));
645
735
  const workDir = opts.context?.paths?.work_dir;
646
- const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot);
647
- const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot);
736
+ const stdinPath = streamPath(fn.definition.stdin, opts, opts.configRoot, functionTemplateOptions(fn));
737
+ const stdoutPath = streamPath(fn.definition.stdout, opts, workDir ?? opts.configRoot, functionTemplateOptions(fn));
648
738
  let progress;
649
739
  const roles = {
650
740
  progress: {
@@ -656,8 +746,10 @@ async function runScriptFunction(fn, opts) {
656
746
  }
657
747
  },
658
748
  meta: {},
749
+ result: {},
659
750
  log: { onLine: opts.onLog }
660
751
  };
752
+ const stdin = stdinPath ? (0, import_node_fs4.createReadStream)(stdinPath, { fd: (0, import_node_fs4.openSync)(stdinPath, "r") }) : void 0;
661
753
  const started = start(file, argv, {
662
754
  cwd: opts.cwd,
663
755
  env: opts.env,
@@ -665,7 +757,7 @@ async function runScriptFunction(fn, opts) {
665
757
  killGraceMs: opts.killGraceMs,
666
758
  signal: opts.signal,
667
759
  onStderr: opts.onStderr,
668
- stdin: stdinPath ? (0, import_node_fs4.createReadStream)(stdinPath) : void 0,
760
+ stdin,
669
761
  stdout: stdoutPath ? "stream" : "text",
670
762
  roles: declaredRoles({ schema }, roles)
671
763
  });
@@ -676,7 +768,70 @@ async function runScriptFunction(fn, opts) {
676
768
  }
677
769
  const [result] = await Promise.all([started.result, written]);
678
770
  const meta = parseRoleJson(result.roles?.meta);
679
- return { ...result, command, stderrTail: tail(result.stderr), ...meta !== void 0 ? { meta } : {}, ...progress ? { progress } : {} };
771
+ const output = parseRoleJson(result.roles?.result) ?? (stdoutPath ? void 0 : parseRoleJson(result.stdout));
772
+ const run = {
773
+ ...result,
774
+ command,
775
+ stderrTail: tail(result.stderr),
776
+ ...meta !== void 0 ? { meta } : {},
777
+ ...progress ? { progress } : {},
778
+ ...output !== void 0 ? { result: output } : {}
779
+ };
780
+ return { run, stdoutPath };
781
+ }
782
+ async function runSteps(steps, opts) {
783
+ const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : void 0;
784
+ const outputs = {};
785
+ const stepRuns = [];
786
+ const byName = new Map(steps.map((step, i) => [step.stepName, i]));
787
+ const after = (i, branch, fallthrough) => branch === END ? void 0 : branch ? byName.get(branch) : fallthrough && i + 1 < steps.length ? i + 1 : void 0;
788
+ let continued;
789
+ let last;
790
+ let previous;
791
+ for (let i = 0; i !== void 0; ) {
792
+ const step = steps[i];
793
+ const name = step.stepName;
794
+ const limits = [step.definition.timeout_ms, deadline && Math.max(1, deadline - Date.now())].filter((ms) => Number(ms) > 0);
795
+ const context = { ...opts.context, steps: { ...outputs }, ...previous ? { previous } : {} };
796
+ const onProgress = opts.onProgress && ((update) => opts.onProgress?.({ stage: name, ...update, step: name }));
797
+ let ran;
798
+ try {
799
+ ran = await runTool(step, { ...opts, context, onProgress, timeoutMs: limits.length ? Math.min(...limits) : void 0 });
800
+ } catch (error) {
801
+ const message = `step ${name}: ${error.message}`;
802
+ if (!step.definition.error) throw new Error(message, { cause: error });
803
+ outputs[name] = { exit_code: null, stdout: "", error: message };
804
+ previous = { name, ...outputs[name] };
805
+ i = byName.get(step.definition.error);
806
+ continue;
807
+ }
808
+ const { run, stdoutPath } = ran;
809
+ stepRuns.push({ ...run, step: name });
810
+ outputs[name] = {
811
+ exit_code: run.exitCode,
812
+ stdout: stdoutPath ?? run.stdout.replace(/\n+$/, ""),
813
+ ...run.result !== void 0 ? { result: run.result } : {},
814
+ ...run.meta !== void 0 ? { meta: run.meta } : {},
815
+ ...run.progress ? { progress: run.progress } : {}
816
+ };
817
+ previous = { name, ...outputs[name] };
818
+ last = { ...run, steps: outputs, previous, stepRuns, durationMs: stepRuns.reduce((ms, r) => ms + r.durationMs, 0) };
819
+ const ok = !run.timedOut && (run.exitCode === 0 || run.exitCode !== null && (step.definition.continue_on_exit ?? []).includes(run.exitCode));
820
+ if (ok) {
821
+ if (run.exitCode !== 0) continued ??= run.exitCode ?? void 0;
822
+ i = after(i, step.definition.succeed, true);
823
+ if (i === void 0) return { ...last, exitCode: run.exitCode || continued || 0 };
824
+ continue;
825
+ }
826
+ const stopped = opts.signal?.aborted || deadline !== void 0 && Date.now() >= deadline;
827
+ i = stopped ? void 0 : after(i, step.definition.fail, false);
828
+ if (i === void 0) return { ...last, failedStep: name };
829
+ }
830
+ if (!last) throw new Error("unreachable: a chain ends after a step that ran");
831
+ return last;
832
+ }
833
+ function runContext(context, run) {
834
+ return { ...context, ...run.steps ? { steps: run.steps } : {}, ...run.previous ? { previous: run.previous } : {} };
680
835
  }
681
836
  function buildResultRecord(p) {
682
837
  return {
@@ -688,7 +843,7 @@ function buildResultRecord(p) {
688
843
  artifact_keys: p.artifactKeys,
689
844
  completed_at: p.now,
690
845
  config: p.fn.config,
691
- ...renderResultMap(p.fn.definition.result, p.context)
846
+ ...renderResultMap(p.fn.definition.result, p.context, functionTemplateOptions(p.fn))
692
847
  };
693
848
  }
694
849
 
@@ -734,8 +889,8 @@ function listFilesRecursive(dir, visited = /* @__PURE__ */ new Set()) {
734
889
  }
735
890
  return files;
736
891
  }
737
- function findFilesByArtifactPattern(pattern, context) {
738
- const rendered = renderTemplateString(pattern, context);
892
+ function findFilesByArtifactPattern(pattern, context, opts) {
893
+ const rendered = renderTemplateString(pattern, context, opts);
739
894
  const absolute = (0, import_node_path4.isAbsolute)(rendered) ? rendered : (0, import_node_path4.resolve)(context.paths.work_dir, rendered);
740
895
  if (!/[*?]/.test(absolute)) return isFilePath(absolute) ? [absolute] : [];
741
896
  const regex = globToRegex(absolute);
@@ -774,7 +929,7 @@ function copyConfiguredArtifacts(fn, context, opts = {}) {
774
929
  const seen = /* @__PURE__ */ new Set();
775
930
  const copied = [];
776
931
  for (const pattern of patterns) {
777
- for (const source of findFilesByArtifactPattern(pattern, context)) {
932
+ for (const source of findFilesByArtifactPattern(pattern, context, { strict: fn.definition.strict_params === true })) {
778
933
  if (seen.has(source)) continue;
779
934
  seen.add(source);
780
935
  const destination = (0, import_node_path4.join)(dir, (0, import_node_path4.basename)(source));
@@ -924,10 +1079,13 @@ var JobQueue = class {
924
1079
  deepMerge,
925
1080
  ensureDir,
926
1081
  findFilesByArtifactPattern,
1082
+ functionParamNames,
1083
+ functionTemplateOptions,
927
1084
  globToRegex,
928
1085
  isDirectoryPath,
929
1086
  isEnabled,
930
1087
  isFilePath,
1088
+ isUnsetTemplate,
931
1089
  listFilesRecursive,
932
1090
  lookupTemplateValue,
933
1091
  newJobId,
@@ -943,9 +1101,11 @@ var JobQueue = class {
943
1101
  resolveConfigPath,
944
1102
  resolveFunctionConfig,
945
1103
  resolveScriptPath,
1104
+ runContext,
946
1105
  runScriptFunction,
947
1106
  shellQuote,
948
1107
  statFollowingSymlinks,
1108
+ templateParamNames,
949
1109
  timestampIso,
950
1110
  transitionJobRecord,
951
1111
  truthy,
@@ -0,0 +1,113 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://clyops.io/schemas/clyops-jobs.json",
4
+ "title": "clyops-jobs config",
5
+ "description": "Functions bound to clyops tools, their default options and their option blocks. Every other top-level key is an options block, named by a function's or step's `key`.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": { "type": "string", "description": "This schema's URL, for editors: https://clyops.io/schemas/clyops-jobs.json" },
9
+ "functions": {
10
+ "type": "object",
11
+ "description": "The functions a trigger can run, by name.",
12
+ "additionalProperties": { "$ref": "#/$defs/function" }
13
+ },
14
+ "defaults": {
15
+ "type": "object",
16
+ "description": "Default options by block key, merged under the block of the same key.",
17
+ "additionalProperties": { "$ref": "#/$defs/options" }
18
+ }
19
+ },
20
+ "additionalProperties": { "$ref": "#/$defs/options" },
21
+ "$defs": {
22
+ "template": {
23
+ "type": "string",
24
+ "description": "Text with `${dot.path}` or `{{dot.path}}` templates rendered against the trigger's context (`job`, `paths`, `params`, `steps`, ...)."
25
+ },
26
+ "stepName": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
27
+ "options": {
28
+ "type": "object",
29
+ "description": "Tool options keyed like the tool's options (`out_dir` or `out-dir`) or arguments, mapped onto its command line through --help-json-schema. String values are templates; an option naming a missing `params` field is left unset."
30
+ },
31
+ "binding": {
32
+ "type": "object",
33
+ "properties": {
34
+ "key": { "type": "string", "description": "The options block (a top-level key and a `defaults` key); default: the function's or step's name." },
35
+ "script": { "type": "string", "description": "The tool, relative to the script root." },
36
+ "positionals": {
37
+ "type": "array",
38
+ "description": "The tool's positional arguments as templates, in order; `[]` for none. Without it, arguments are options keyed by argument name.",
39
+ "items": { "type": ["string", "number", "boolean"] }
40
+ },
41
+ "positionals_order": { "enum": ["first", "last"], "description": "Positionals before (default) or after the options." },
42
+ "stdin": { "$ref": "#/$defs/template", "description": "A template naming the file fed to the tool's stdin, relative to the config root." },
43
+ "stdout": { "$ref": "#/$defs/template", "description": "A template naming the file the tool's stdout is written to, relative to `paths.work_dir`." },
44
+ "strict_params": { "type": "boolean", "description": "A missing `params` field is an error instead of leaving the option unset (default false)." }
45
+ },
46
+ "patternProperties": { "^x-": { "description": "Free for triggers and comments; clyops-jobs ignores it." } }
47
+ },
48
+ "function": {
49
+ "description": "A function runs one tool (`script`) or several in order (`steps`).",
50
+ "type": "object",
51
+ "allOf": [{ "$ref": "#/$defs/binding" }],
52
+ "properties": {
53
+ "key": true, "script": true, "positionals": true, "positionals_order": true, "stdin": true, "stdout": true, "strict_params": true,
54
+ "steps": {
55
+ "type": "array",
56
+ "minItems": 1,
57
+ "description": "Tools run in order with one context; each step's outputs are `steps.NAME` (`exit_code`, `stdout`, `result`, `meta`, `progress`, `error`) in the templates after it, and the step that ran just before is `previous` (the same fields plus `name`).",
58
+ "items": { "$ref": "#/$defs/step" }
59
+ },
60
+ "artifacts": { "$ref": "#/$defs/artifacts" },
61
+ "result": { "type": "object", "description": "Templates added to the result record; nested objects and arrays are rendered too." }
62
+ },
63
+ "patternProperties": { "^x-": true },
64
+ "additionalProperties": false,
65
+ "oneOf": [
66
+ { "required": ["script"], "not": { "required": ["steps"] } },
67
+ { "required": ["steps"], "not": { "required": ["script"] } }
68
+ ]
69
+ },
70
+ "step": {
71
+ "description": "One tool in a function's `steps`.",
72
+ "type": "object",
73
+ "allOf": [{ "$ref": "#/$defs/binding" }],
74
+ "required": ["name", "script"],
75
+ "properties": {
76
+ "key": true, "script": true, "positionals": true, "positionals_order": true, "stdin": true, "stdout": true, "strict_params": true,
77
+ "name": { "$ref": "#/$defs/stepName", "description": "Names the step in `${steps.NAME...}` templates, branches and errors." },
78
+ "timeout_ms": { "type": "integer", "exclusiveMinimum": 0, "description": "This step's time limit; the whole chain also stays within the run's timeoutMs." },
79
+ "continue_on_exit": {
80
+ "type": "array",
81
+ "items": { "type": "integer" },
82
+ "description": "Exit codes besides 0 that count as success; the chain then reports the first of them, e.g. [2] for a partial result."
83
+ },
84
+ "succeed": {
85
+ "anyOf": [{ "$ref": "#/$defs/stepName" }, { "const": "$end" }],
86
+ "description": "The later step to run after this one succeeds, instead of the next; `$end` ends the chain."
87
+ },
88
+ "fail": { "$ref": "#/$defs/stepName", "description": "The later step to run when this one exits nonzero or times out, instead of ending the chain." },
89
+ "error": { "$ref": "#/$defs/stepName", "description": "The later step to run when this one can't start (a template, stdin or schema error), instead of failing the run." }
90
+ },
91
+ "patternProperties": { "^x-": true },
92
+ "additionalProperties": false
93
+ },
94
+ "artifacts": {
95
+ "description": "Files copied from `paths.work_dir` into `paths.artifacts_dir` after the run: glob patterns (`*`, `?`, `**`), or an object with them and a requirement.",
96
+ "anyOf": [
97
+ { "$ref": "#/$defs/template" },
98
+ { "type": "array", "items": { "$ref": "#/$defs/template" } },
99
+ {
100
+ "type": "object",
101
+ "properties": {
102
+ "include": { "anyOf": [{ "$ref": "#/$defs/template" }, { "type": "array", "items": { "$ref": "#/$defs/template" } }], "description": "Glob patterns relative to `paths.work_dir`." },
103
+ "includes": { "$ref": "#/$defs/artifacts/anyOf/2/properties/include", "description": "Same as include." },
104
+ "patterns": { "$ref": "#/$defs/artifacts/anyOf/2/properties/include", "description": "Same as include." },
105
+ "required": { "type": ["boolean", "string"], "description": "At least one file must match." },
106
+ "min": { "type": "integer", "minimum": 0, "description": "At least this many files must match." }
107
+ },
108
+ "additionalProperties": false
109
+ }
110
+ ]
111
+ }
112
+ }
113
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedFunction } from './functions.js';
2
- import { type TemplateContext } from './template.js';
2
+ import { type TemplateContext, type TemplateOptions } from './template.js';
3
3
  /** Compile an artifact glob into an anchored regular expression. */
4
4
  export declare function globToRegex(pattern: string): RegExp;
5
5
  /** Every file below `dir`, following symlinks without looping. */
@@ -9,7 +9,7 @@ export declare function findFilesByArtifactPattern(pattern: string, context: Tem
9
9
  paths: {
10
10
  work_dir: string;
11
11
  };
12
- }): string[];
12
+ }, opts?: TemplateOptions): string[];
13
13
  /** A function's `artifacts` as a list of include patterns, plus its `stdout` file. */
14
14
  export declare function artifactIncludePatterns(fn: ResolvedFunction): string[];
15
15
  /** How many artifacts a function must produce: `min`, or 1 for `required: true`. */
@@ -1 +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"}
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,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAKjG,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,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAQxJ;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"}