@olivervea/pipelines 2.0.0-beta.32 → 2.0.0-beta.33

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
@@ -97,7 +97,7 @@ Rules:
97
97
  - A **plain-schema input** has no model to match on, so the step needs a mapper.
98
98
  - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
99
99
  - **`cells(M)`** reads every cell of a matrix step whose output is `M` (see "Matrix steps").
100
- - **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one.
100
+ - **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one. `link: { input, path?, label? }` is the link shown on the step while it waits: `input` is one of the same step's regular (non-external) inputs (typed to their names), `path` a dot path inside its value (`url` → `input.review.url`; none = the value itself is the URL), `label` defaults to `title`. A value that is not an absolute http(s) URL sets no link; the step waits all the same.
101
101
 
102
102
  ### Config
103
103
 
@@ -366,7 +366,7 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
366
366
  { "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
367
367
  ```
368
368
 
369
- Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
369
+ Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits. The attempt keeps the links its external inputs' `link` gave it while it waited first, then adds the envelope's, leaving out a URL already there.
370
370
 
371
371
  For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
372
372
 
@@ -422,7 +422,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
422
422
  - a nested object of mappings.
423
423
 
424
424
  An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
425
- - **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`. Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
425
+ - **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from, link?}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`, and `link` `{input, path?, label?}` (omitted when not set, as are its `path` and `label`). Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
426
426
  - **`inputTypes`** declares each input key: `{model, optional}`, `{model, optional: false, cells: true}` for a `cells` input (mapped with a whole-step ref to a matrix step whose output is that model), or `{schema: {output, input?}}` for a plain schema.
427
427
  - **Step `matrix`** (matrix steps only, never a failure handler) is `{axes: [{name, values}…], exclude?: [{axis: value…}…], failure: {kind: "keepRunning" | "cascadeFailure"}, maxParallel?}`. `axes` is a list because canonical JSON sorts keys; `exclude` is omitted when empty; `failure.kind` is always written. All of it is part of what the step declares.
428
428
  - **`codeHash`** is the build's identity: `sha256(canonical {bundle, harness})`. `bundle` hashes the entry point bundled with everything it imports, with `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts and dependencies, so any change to code in use changes it, and with it the manifest, which makes a new pipeline version. A step version covers what the step declares: image, `inputTypes`/model versions, `output` model version, `secrets`, `configEntries` (and those entries' schemas), `input` mappings and `config`.
@@ -445,6 +445,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
445
445
  - availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
446
446
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
447
447
  - `when` on an unknown or non-literal field, or with an invalid value;
448
+ - an external input's `link` naming anything but one of the step's regular inputs, an empty path segment, a label that is empty or over 100 characters;
448
449
  - a poll header referencing an undeclared secret, duplicate triggers;
449
450
  - conflicting secret descriptions;
450
451
  - config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
@@ -584,6 +584,32 @@
584
584
  "minItems": 1,
585
585
  "type": "array"
586
586
  },
587
+ "link": {
588
+ "additionalProperties": false,
589
+ "description": "The link shown while the step waits: the absolute http(s) URL at 'path' inside the value of the step's input 'input'. Omitted when none.",
590
+ "properties": {
591
+ "input": {
592
+ "description": "One of the same step's inputs (not an external one).",
593
+ "minLength": 1,
594
+ "type": "string"
595
+ },
596
+ "label": {
597
+ "description": "Default: the input's title.",
598
+ "maxLength": 100,
599
+ "minLength": 1,
600
+ "type": "string"
601
+ },
602
+ "path": {
603
+ "description": "Dot path inside that input's value; omitted = the value itself is the URL.",
604
+ "minLength": 1,
605
+ "type": "string"
606
+ }
607
+ },
608
+ "required": [
609
+ "input"
610
+ ],
611
+ "type": "object"
612
+ },
587
613
  "model": {
588
614
  "description": "The value is validated against this model's input view.",
589
615
  "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.32",
3
+ "version": "2.0.0-beta.33",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,32 +47,49 @@ export function poll(options: PollOptions): ExternalSource {
47
47
  * A step input whose value comes from outside the run. The step waits until it is answered (the value is
48
48
  * validated against `model`), then runs with the value in its input like any other.
49
49
  */
50
- export interface External<M extends AnyModel = AnyModel> {
50
+ export interface External<M extends AnyModel = AnyModel, L extends string = string> {
51
51
  readonly kind: 'external';
52
52
  readonly model: M;
53
53
  readonly title: string;
54
54
  readonly description: string;
55
55
  readonly from: readonly ExternalSource[];
56
+ readonly link?: ExternalLink<L>;
56
57
  }
57
58
 
58
- export interface ExternalOptions {
59
+ /**
60
+ * The link shown on the step while it waits: the URL is the value of one of the step's other
61
+ * (non-external) inputs, or a field inside it.
62
+ */
63
+ export interface ExternalLink<L extends string = string> {
64
+ /** One of the same step's non-external inputs. */
65
+ readonly input: L;
66
+ /** Dot path inside that input's value (`url`, `review.url`); omitted = the value itself is the URL. */
67
+ readonly path?: string;
68
+ /** Default: the input's title. */
69
+ readonly label?: string;
70
+ }
71
+
72
+ export interface ExternalOptions<L extends string = string> {
59
73
  /** What is asked, shown to whoever answers. */
60
74
  title: string;
61
75
  description?: string;
62
76
  /** Who may answer (default: a person). */
63
77
  from?: readonly ExternalSource[];
78
+ /** The link shown while the step waits, e.g. `{ input: "review", path: "url" }`. */
79
+ link?: ExternalLink<L>;
64
80
  }
65
81
 
66
- export function external<M extends AnyModel>(
82
+ export function external<M extends AnyModel, const L extends string = never>(
67
83
  model: M,
68
- options: ExternalOptions,
69
- ): External<M> {
84
+ options: ExternalOptions<L>,
85
+ ): External<M, L> {
70
86
  return Object.freeze({
71
87
  kind: 'external' as const,
72
88
  model,
73
89
  title: options.title,
74
90
  description: options.description ?? '',
75
91
  from: Object.freeze([...(options.from ?? [person()])]),
92
+ ...(options.link !== undefined ? { link: Object.freeze({ ...options.link }) } : {}),
76
93
  });
77
94
  }
78
95
 
package/src/core/steps.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { z } from 'zod';
2
2
  import { STEP, type StepBrand } from './brands.ts';
3
3
  import type { AnyModel, Infer, Optional } from './models.ts';
4
- import type { External } from './external.ts';
4
+ import type { External, ExternalLink } from './external.ts';
5
5
  import type { AnyConfigEntry } from './config.ts';
6
6
  import { configRefsIn } from './config-refs.ts';
7
7
  import type { Secret } from './secrets.ts';
@@ -35,6 +35,23 @@ export type InferEntry<E> =
35
35
  : E extends z.ZodType
36
36
  ? z.output<E>
37
37
  : never;
38
+ /** The keys of a step's inputs that are not external. */
39
+ export type RegularInputKey<I> = {
40
+ [K in keyof I]: I[K] extends External ? never : K;
41
+ }[keyof I] &
42
+ string;
43
+
44
+ /** An external input's `link.input` must name one of the same step's non-external inputs. */
45
+ export type ExternalLinksCheck<I> = {
46
+ [K in keyof I]: I[K] extends External<AnyModel, infer L>
47
+ ? [L] extends [never]
48
+ ? unknown
49
+ : [L] extends [RegularInputKey<I>]
50
+ ? unknown
51
+ : { readonly link?: ExternalLink<RegularInputKey<I>> }
52
+ : unknown;
53
+ };
54
+
38
55
  /** What a handler receives: the output view of every input entry. */
39
56
  export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
40
57
 
@@ -106,7 +123,7 @@ export interface StepDef<
106
123
  description: string;
107
124
  /** Any image: the harness brings its own runtime. */
108
125
  image: string;
109
- input?: I;
126
+ input?: I & ExternalLinksCheck<I>;
110
127
  output?: O;
111
128
  secrets?: Sec;
112
129
  /** Config entries delivered as `ctx.config` (typed to exactly these). */
@@ -211,6 +211,30 @@ export const StepDescriptor = z
211
211
  )
212
212
  .min(1)
213
213
  .describe('Who or what may answer.'),
214
+ link: z
215
+ .strictObject({
216
+ input: z
217
+ .string()
218
+ .min(1)
219
+ .describe("One of the same step's inputs (not an external one)."),
220
+ path: z
221
+ .string()
222
+ .min(1)
223
+ .optional()
224
+ .describe(
225
+ "Dot path inside that input's value; omitted = the value itself is the URL.",
226
+ ),
227
+ label: z
228
+ .string()
229
+ .min(1)
230
+ .max(100)
231
+ .optional()
232
+ .describe("Default: the input's title."),
233
+ })
234
+ .optional()
235
+ .describe(
236
+ "The link shown while the step waits: the absolute http(s) URL at 'path' inside the value of the step's input 'input'. Omitted when none.",
237
+ ),
214
238
  }),
215
239
  )
216
240
  .optional()
@@ -124,6 +124,7 @@ function describeExternalInputs(
124
124
  ): ExternalInputs {
125
125
  const problems = ctx.problems;
126
126
  const externalInputs: ExternalInputs = {};
127
+ const regular = new Set(allEntries.filter(([, e]) => !isExternal(e)).map(([k]) => k));
127
128
  for (const [key, e] of allEntries) {
128
129
  if (!isExternal(e)) continue;
129
130
  if (failureHandler)
@@ -153,16 +154,48 @@ function describeExternalInputs(
153
154
  if (f.valuePath.trim() === '')
154
155
  problems.push(`${at} external input '${key}': poll valuePath must not be empty`);
155
156
  }
157
+ const link = e.link;
158
+ if (link !== undefined) {
159
+ if (!regular.has(link.input))
160
+ problems.push(
161
+ `${at} external input '${key}': link input '${link.input}' must name one of the step's regular (non-external) inputs`,
162
+ );
163
+ if (link.path !== undefined && !isDotPath(link.path))
164
+ problems.push(
165
+ `${at} external input '${key}': link path must be a dot path of non-empty field names`,
166
+ );
167
+ if (
168
+ link.label !== undefined &&
169
+ (typeof link.label !== 'string' ||
170
+ link.label.length < 1 ||
171
+ link.label.length > 100)
172
+ )
173
+ problems.push(
174
+ `${at} external input '${key}': link label must be 1 to 100 characters`,
175
+ );
176
+ }
156
177
  externalInputs[key] = {
157
178
  model: ctx.useModel(e.model, `${at} external input '${key}'`),
158
179
  title: e.title,
159
180
  description: e.description,
160
181
  from: e.from.map((f) => ({ ...f })),
182
+ ...(link !== undefined
183
+ ? {
184
+ link: {
185
+ input: link.input,
186
+ ...(link.path !== undefined ? { path: link.path } : {}),
187
+ ...(link.label !== undefined ? { label: link.label } : {}),
188
+ },
189
+ }
190
+ : {}),
161
191
  };
162
192
  }
163
193
  return externalInputs;
164
194
  }
165
195
 
196
+ const isDotPath = (path: unknown): boolean =>
197
+ typeof path === 'string' && path.split('.').every((segment) => segment !== '');
198
+
166
199
  function describeInputTypes(
167
200
  ctx: DescribeContext,
168
201
  at: string,
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export {
53
53
  person,
54
54
  poll,
55
55
  type External,
56
+ type ExternalLink,
56
57
  type ExternalOptions,
57
58
  type ExternalSource,
58
59
  type PollOptions,