@xemahq/dsl 0.8.0 → 0.8.2

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.
Files changed (64) hide show
  1. package/dist/payload-codec/lib/blob-store.js +0 -0
  2. package/dist/payload-codec/lib/blob-store.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/deliverable-spec/index.ts +0 -19
  5. package/src/deliverable-spec/lib/schema.ts +0 -270
  6. package/src/deliverable-spec/lib/types.ts +0 -26
  7. package/src/payload-codec/index.ts +0 -44
  8. package/src/payload-codec/lib/blob-store.ts +0 -0
  9. package/src/payload-codec/lib/codec-context.ts +0 -38
  10. package/src/payload-codec/lib/codec.ts +0 -605
  11. package/src/payload-codec/lib/enums.ts +0 -58
  12. package/src/payload-codec/lib/errors.ts +0 -54
  13. package/src/payload-codec/lib/http-blob-store.ts +0 -267
  14. package/src/payload-codec/lib/lru-cache.ts +0 -81
  15. package/src/payload-codec/lib/payload.ts +0 -26
  16. package/src/payload-codec/temporal/index.ts +0 -36
  17. package/src/workflow/index.ts +0 -108
  18. package/src/workflow/lib/action-input-validator.ts +0 -160
  19. package/src/workflow/lib/compiler/action-shape.ts +0 -71
  20. package/src/workflow/lib/compiler/canonical-json.ts +0 -66
  21. package/src/workflow/lib/compiler/compile.ts +0 -1742
  22. package/src/workflow/lib/compiler/concurrency.ts +0 -223
  23. package/src/workflow/lib/compiler/dag.ts +0 -108
  24. package/src/workflow/lib/compiler/gate-defaults.ts +0 -153
  25. package/src/workflow/lib/compiler/index.ts +0 -11
  26. package/src/workflow/lib/compiler/inputs.ts +0 -254
  27. package/src/workflow/lib/compiler/installation-resource-validator.ts +0 -114
  28. package/src/workflow/lib/compiler/manifest-source.ts +0 -71
  29. package/src/workflow/lib/compiler/matrix.ts +0 -135
  30. package/src/workflow/lib/compiler/mount-plan.ts +0 -190
  31. package/src/workflow/lib/compiler/payload-reach-in.ts +0 -497
  32. package/src/workflow/lib/compiler/permissions.ts +0 -64
  33. package/src/workflow/lib/compiler/retry-timeout.ts +0 -105
  34. package/src/workflow/lib/compiler/review-step.ts +0 -548
  35. package/src/workflow/lib/compiler/types.ts +0 -172
  36. package/src/workflow/lib/compiler/variable-requirements.ts +0 -208
  37. package/src/workflow/lib/deliverable-spec-introspection-error.ts +0 -63
  38. package/src/workflow/lib/deliverable-spec-keys.ts +0 -147
  39. package/src/workflow/lib/deliverable-spec-source-scan.ts +0 -280
  40. package/src/workflow/lib/dispatch-inputs/index.ts +0 -160
  41. package/src/workflow/lib/dispatch-inputs/to-json-schema.ts +0 -60
  42. package/src/workflow/lib/duration.ts +0 -43
  43. package/src/workflow/lib/errors.ts +0 -37
  44. package/src/workflow/lib/expression/ast.ts +0 -108
  45. package/src/workflow/lib/expression/context.ts +0 -148
  46. package/src/workflow/lib/expression/evaluator.ts +0 -492
  47. package/src/workflow/lib/expression/index.ts +0 -28
  48. package/src/workflow/lib/expression/interpolation.ts +0 -84
  49. package/src/workflow/lib/expression/parser.ts +0 -264
  50. package/src/workflow/lib/expression/template.ts +0 -117
  51. package/src/workflow/lib/expression/tokenizer.ts +0 -200
  52. package/src/workflow/lib/expression/tokens.ts +0 -30
  53. package/src/workflow/lib/expression/walk-artifact-refs.ts +0 -232
  54. package/src/workflow/lib/installation-resource-kind.ts +0 -107
  55. package/src/workflow/lib/schemas-loader.ts +0 -64
  56. package/src/workflow/lib/serializer.ts +0 -30
  57. package/src/workflow/lib/types.ts +0 -417
  58. package/src/workflow/lib/validate.ts +0 -199
  59. package/src/workspace-manifest/index.ts +0 -27
  60. package/src/workspace-manifest/lib/compile.ts +0 -619
  61. package/src/workspace-manifest/lib/interpolate.ts +0 -166
  62. package/src/workspace-manifest/lib/resolve-extends.ts +0 -260
  63. package/src/workspace-manifest/lib/schema.ts +0 -692
  64. package/src/workspace-manifest/lib/types.ts +0 -446
@@ -1,190 +0,0 @@
1
- import {
2
- AWP_V1_SPEC,
3
- findSlot,
4
- isKnownSlotKey,
5
- SlotMode,
6
- type WorkspaceSpec,
7
- } from '@xemahq/kernel-contracts/agent-workspace';
8
- import { WorkflowErrorCode } from '@xemahq/kernel-contracts/workflow';
9
- import type { MountPlan } from '@xemahq/kernel-contracts/workflow';
10
- import { WorkflowDslError } from '../errors';
11
- import type { ActionManifest } from '../types';
12
- import { isAgentShapedAction } from './action-shape';
13
-
14
- /**
15
- * Compile a job's `with:` block into a compile-time {@link MountPlan} —
16
- * a declarative list of mount keys grouped by access, validated against
17
- * (a) the workspace spec's closed slot set and (b) the action manifest's
18
- * `allowedMounts`.
19
- *
20
- * Read/write classification is per-mount with a slot-default fallback:
21
- * - `with.mounts.<key>: true` uses the slot's `defaultMode` from the
22
- * {@link WorkspaceSpec}.
23
- * - `with.mounts.<key>: { mode: 'read-only' | 'read-write' }`
24
- * overrides the slot default. The value is the closed `SlotMode`
25
- * enum; any other string is rejected fail-fast.
26
- *
27
- * The legacy `with.mountKeys: []` / `with.writableMountKeys: []` array
28
- * form is gone — the action manifest's input schema rejects it before
29
- * this compiler runs, but we also explicitly reject those keys here so
30
- * a misconfigured manifest can't sneak the legacy shape through.
31
- *
32
- * Concrete {@link MountSource} resolution is NOT the compiler's job: it
33
- * requires access to the engine's org-scoped resource registry, which is
34
- * only available at dispatch time.
35
- *
36
- * Rules:
37
- * - `with.mounts` absent → empty plan (no implicit mounts).
38
- * - Unknown slot keys (per the spec) fail fast.
39
- * - If the action manifest declares `allowedMounts`, keys outside it
40
- * also fail fast.
41
- * - Legacy `mountKeys` / `writableMountKeys` keys on `with:` → fail fast
42
- * with a migration hint.
43
- */
44
-
45
- const SPEC: WorkspaceSpec = AWP_V1_SPEC;
46
-
47
- const LEGACY_MOUNT_KEYS = ['mountKeys', 'writableMountKeys'] as const;
48
-
49
- export function compileMountPlan(
50
- jobKey: string,
51
- withBlock: Readonly<Record<string, unknown>> | undefined,
52
- actionManifest: ActionManifest | null,
53
- ): MountPlan {
54
- rejectLegacyShape(jobKey, withBlock);
55
-
56
- enforceManifestSourceDiscriminator(jobKey, withBlock, actionManifest);
57
-
58
- const mounts = extractMountsBlock(withBlock);
59
- if (!mounts) {
60
- return { readOnly: [], readWrite: [] };
61
- }
62
-
63
- const allowed =
64
- actionManifest?.spec.allowedMounts !== undefined
65
- ? new Set(actionManifest.spec.allowedMounts)
66
- : null;
67
-
68
- const readOnly: { key: string }[] = [];
69
- const readWrite: { key: string }[] = [];
70
-
71
- const sortedKeys = Object.keys(mounts).sort();
72
- for (const key of sortedKeys) {
73
- const mode = parseMountValue(jobKey, key, mounts[key]);
74
- if (!isKnownSlotKey(SPEC, key)) {
75
- throw new WorkflowDslError(
76
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
77
- `Job '${jobKey}' requests mount '${key}' which is not a known slot in WorkspaceSpec '${SPEC.id}'.`,
78
- { jobKey, mountKey: key, knownSlots: SPEC.slots.map((s) => s.key) },
79
- );
80
- }
81
- if (allowed !== null && !allowed.has(key)) {
82
- throw new WorkflowDslError(
83
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
84
- `Job '${jobKey}' requests mount '${key}' but action manifest does not list it in allowedMounts.`,
85
- { jobKey, mountKey: key, allowedMounts: [...allowed] },
86
- );
87
- }
88
- const slot = findSlot(SPEC, key);
89
- const slotDefault = slot?.defaultMode ?? SlotMode.ReadOnly;
90
- const effective = mode ?? slotDefault;
91
- if (slot && !slot.allowedModes.includes(effective)) {
92
- throw new WorkflowDslError(
93
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
94
- `Job '${jobKey}' requests mount '${key}' with mode '${effective}', which is not in slot allowedModes [${slot.allowedModes.join(', ')}].`,
95
- { jobKey, mountKey: key, requestedMode: effective, allowedModes: [...slot.allowedModes] },
96
- );
97
- }
98
- if (effective === SlotMode.ReadWrite) {
99
- readWrite.push({ key });
100
- } else {
101
- readOnly.push({ key });
102
- }
103
- }
104
-
105
- return { readOnly, readWrite };
106
- }
107
-
108
- function parseMountValue(
109
- jobKey: string,
110
- mountKey: string,
111
- value: unknown,
112
- ): SlotMode | null {
113
- if (value === true) return null;
114
- if (value && typeof value === 'object' && !Array.isArray(value)) {
115
- const obj = value as Record<string, unknown>;
116
- const mode = obj['mode'];
117
- if (mode === SlotMode.ReadOnly || mode === SlotMode.ReadWrite) {
118
- return mode;
119
- }
120
- throw new WorkflowDslError(
121
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
122
- `Job '${jobKey}' mount '${mountKey}' has invalid 'mode': must be '${SlotMode.ReadOnly}' or '${SlotMode.ReadWrite}'.`,
123
- { jobKey, mountKey, mode: typeof mode === 'string' ? mode : typeof mode },
124
- );
125
- }
126
- throw new WorkflowDslError(
127
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
128
- `Job '${jobKey}' mount '${mountKey}' value must be \`true\` or \`{ mode: 'read-only' | 'read-write' }\`.`,
129
- { jobKey, mountKey, valueKind: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value },
130
- );
131
- }
132
-
133
- function extractMountsBlock(
134
- withBlock: Readonly<Record<string, unknown>> | undefined,
135
- ): Readonly<Record<string, unknown>> | null {
136
- if (!withBlock) return null;
137
- const raw = withBlock['mounts'];
138
- if (raw === undefined) return null;
139
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
140
- throw new WorkflowDslError(
141
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
142
- `\`with.mounts\` must be an object mapping slot keys to \`true\` or \`{ mode }\`.`,
143
- { mountsKind: Array.isArray(raw) ? 'array' : raw === null ? 'null' : typeof raw },
144
- );
145
- }
146
- return raw as Readonly<Record<string, unknown>>;
147
- }
148
-
149
- function rejectLegacyShape(
150
- jobKey: string,
151
- withBlock: Readonly<Record<string, unknown>> | undefined,
152
- ): void {
153
- if (!withBlock) return;
154
- for (const legacyKey of LEGACY_MOUNT_KEYS) {
155
- if (legacyKey in withBlock) {
156
- throw new WorkflowDslError(
157
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
158
- `Job '${jobKey}' uses legacy '${legacyKey}' on \`with:\`. Migrate to \`with.mounts\` (object form): \`mounts: { references: true, deliverables: { mode: 'read-write' } }\`.`,
159
- { jobKey, legacyKey },
160
- );
161
- }
162
- }
163
- }
164
-
165
- /**
166
- * Manifest-source requirement: every agent-shaped action must name its
167
- * agent via `with.agentRef`. `agentRef` is the SOLE source — there is no
168
- * inline `mounts` short-form anymore. Detected generically so
169
- * biome-shipped agent actions inherit the same compile-time check
170
- * without a hardcoded action-id allowlist.
171
- */
172
- function enforceManifestSourceDiscriminator(
173
- jobKey: string,
174
- withBlock: Readonly<Record<string, unknown>> | undefined,
175
- actionManifest: ActionManifest | null,
176
- ): void {
177
- if (!isAgentShapedAction(actionManifest)) return;
178
- const hasRef =
179
- withBlock !== undefined &&
180
- 'agentRef' in withBlock &&
181
- typeof withBlock['agentRef'] === 'string' &&
182
- (withBlock['agentRef'] as string).length > 0;
183
- if (!hasRef) {
184
- throw new WorkflowDslError(
185
- WorkflowErrorCode.DSL_SEMANTIC_INVALID,
186
- `Job '${jobKey}' is an agent-shaped action but does not set \`with.agentRef\`. \`agentRef\` (a \`<slug>\` or \`<slug>@<version>\` reference) is the only way a workflow names its agent.`,
187
- { jobKey, actionId: actionManifest.metadata.id },
188
- );
189
- }
190
- }
@@ -1,497 +0,0 @@
1
- // ═══════════════════════════════════════════════════════════════════════════
2
- // ── Payload reach-in validation ──
3
- //
4
- // Workflow YAML supports two flavours of `needs.<X>.outputs.<name>.<field>`:
5
- //
6
- // 1. Envelope-field access — `<field>` is one of
7
- // {artifactId, versionId, version, hash, type, title}. Free; the
8
- // runtime reads it off the ArtifactRef without fetching bytes.
9
- // Already works structurally via `readProperty` on the ref object.
10
- //
11
- // 2. Payload reach-in — `<field>` is a property on the bound
12
- // deliverable spec's JSON Schema. Requires the runtime to
13
- // pre-fetch the artifact's parsed payload into the per-step
14
- // `payloadCache`. The compiler validates the path against the
15
- // bound spec at compile time so authors get a clean DSL error
16
- // instead of a runtime "Unknown property" surfacing as
17
- // `DSL_EXPRESSION_INVALID`.
18
- //
19
- // This module owns family #2's compile-time checks. The actual runtime
20
- // pre-fetch is the worker's responsibility — see the plan section on
21
- // "Compile-time resolution (chosen approach)" — and is gated by the
22
- // engine wiring spec metadata into `ResolvedDeliverableSpec`. Until
23
- // that wiring lands for every spec, this validator gracefully skips
24
- // when it cannot prove typing (the runtime then fails fast via
25
- // `DSL_PAYLOAD_PARSE_ERROR` if the author insisted on a payload field
26
- // without a typed binding).
27
- // ═══════════════════════════════════════════════════════════════════════════
28
-
29
- import {
30
- isArtifactRefEnvelopeField,
31
- WorkflowErrorCode,
32
- type PayloadReachInPin,
33
- } from '@xemahq/kernel-contracts/workflow';
34
- import { WorkflowDslError } from '../errors';
35
- import {
36
- compileExpression,
37
- ExpressionNodeKind,
38
- stripInterpolation,
39
- type ExpressionNode,
40
- extractInterpolations,
41
- } from '../expression';
42
- import type { WorkflowDocument } from '../types';
43
- import type { ResolvedDeliverableSpec, ResolvedRef } from './types';
44
-
45
- /**
46
- * Lookup helper: given an upstream job (by key) and an output name,
47
- * return the deliverable-spec ref the upstream activity manifest pins
48
- * for that output, or `null` when the manifest declares no per-output
49
- * binding for it. The compiler prefers this over `with.deliverableSpecRef`
50
- * — per-output bindings are activity-declared contracts; the job-level
51
- * `with.deliverableSpecRef` is the workflow-author chosen runtime spec
52
- * (dynamic, e.g. for the agent activity). They can coexist on the same
53
- * job; the per-output binding wins for outputs it covers.
54
- */
55
- function lookupOutputBindingSpecRef(
56
- upstream: WorkflowDocument['jobs'][string],
57
- outputName: string,
58
- resolvedRefs: Readonly<Record<string, ResolvedRef>>,
59
- ): string | null {
60
- const ref = resolvedRefs[upstream.uses];
61
- const bindings = ref?.actionManifest?.spec.outputBindings;
62
- if (!bindings) return null;
63
- const binding = bindings[outputName];
64
- if (!binding) return null;
65
- const specRef = binding.deliverableSpecRef;
66
- if (typeof specRef !== 'string' || specRef.length === 0) return null;
67
- return specRef;
68
- }
69
-
70
- /**
71
- * Walk every authored expression and field-check
72
- * `needs.<X>.outputs.<name>.<field>` reach-ins.
73
- *
74
- * Decision table for each `<name>.<field>`:
75
- * • `<field>` is an ArtifactRef envelope field → OK (no payload fetch).
76
- * • Upstream activity manifest declares `outputBindings.<name>.deliverableSpecRef`
77
- * (per-output binding) → use that spec; otherwise fall back
78
- * to the upstream job's `with.deliverableSpecRef`. Then:
79
- * - resolved spec has `topLevelKeys` and `<field>` is one → OK.
80
- * - resolved spec has `topLevelKeys` and `<field>` is NOT one →
81
- * `DSL_FIELD_NOT_IN_SCHEMA`.
82
- * • Neither per-output binding NOR `with.deliverableSpecRef` is set,
83
- * upstream has no `outputs:` projection, and no expression-shaped
84
- * ref → `DSL_FIELD_NOT_TYPED`.
85
- * • Otherwise (custom `outputs:` projection, expression-shaped ref,
86
- * unknown spec, non-introspectable kind) → skip; the runtime
87
- * evaluator + `payloadCache` will fail fast via
88
- * `DSL_PAYLOAD_PARSE_ERROR` if the path is ultimately invalid.
89
- *
90
- * The validator runs only when `resolvedSpecs` is non-empty — same
91
- * opt-out convention as the rest of the compiler (preview-raw passes
92
- * an empty map).
93
- */
94
- export function validatePayloadReachIns(
95
- doc: WorkflowDocument,
96
- resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
97
- resolvedRefs: Readonly<Record<string, ResolvedRef>>,
98
- ): void {
99
- if (Object.keys(resolvedSpecs).length === 0) return;
100
-
101
- for (const [jobKey, job] of Object.entries(doc.jobs)) {
102
- const expressions = collectJobExpressions(jobKey, job);
103
- for (const { source, fieldPath } of expressions) {
104
- const ast = compileExpression(source);
105
- walkPayloadReachIns(ast, (access) => {
106
- validateReachIn(
107
- doc,
108
- jobKey,
109
- fieldPath,
110
- access,
111
- resolvedSpecs,
112
- resolvedRefs,
113
- );
114
- });
115
- }
116
- }
117
-
118
- const callOutputs = doc.on.workflow_call?.outputs;
119
- if (callOutputs) {
120
- for (const [outputName, expr] of Object.entries(callOutputs)) {
121
- const ast = compileExpression(stripInterpolation(expr));
122
- walkPayloadReachIns(ast, (access) => {
123
- validateReachIn(
124
- doc,
125
- '<workflow_call.outputs>',
126
- `on.workflow_call.outputs.${outputName}`,
127
- access,
128
- resolvedSpecs,
129
- resolvedRefs,
130
- );
131
- });
132
- }
133
- }
134
- }
135
-
136
- /**
137
- * Walk every reach-in for a single job and emit one pin per
138
- * `(upstreamJobKey, specRef)` pair. Pins drive the runtime spec-version
139
- * assertion — the worker pre-fetches
140
- * the artifact and refuses to proceed when its `schemaVersion` doesn't
141
- * match the pin's `specVersion`.
142
- *
143
- * Returns `[]` when the job has no resolvable reach-ins (no upstream
144
- * spec, expression-shaped ref, or projected upstream `outputs:`).
145
- */
146
- export function collectPayloadReachInsForJob(
147
- doc: WorkflowDocument,
148
- jobKey: string,
149
- job: WorkflowDocument['jobs'][string],
150
- resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
151
- resolvedRefs: Readonly<Record<string, ResolvedRef>>,
152
- ): readonly PayloadReachInPin[] {
153
- if (Object.keys(resolvedSpecs).length === 0) return [];
154
-
155
- const pins: PayloadReachInPin[] = [];
156
- const seen = new Set<string>();
157
-
158
- const expressions = collectJobExpressions(jobKey, job);
159
- for (const { source, fieldPath } of expressions) {
160
- let ast;
161
- try {
162
- ast = compileExpression(source);
163
- } catch {
164
- continue;
165
- }
166
- walkPayloadReachIns(ast, (access) => {
167
- const pin = resolveReachInPin(
168
- doc,
169
- fieldPath,
170
- access,
171
- resolvedSpecs,
172
- resolvedRefs,
173
- );
174
- if (pin === null) return;
175
- const dedupeKey = `${pin.upstreamJobKey}|${pin.outputName}|${pin.specRef}`;
176
- if (seen.has(dedupeKey)) return;
177
- seen.add(dedupeKey);
178
- pins.push(pin);
179
- });
180
- }
181
-
182
- return pins;
183
- }
184
-
185
- /**
186
- * Resolve one reach-in to a pin entry, returning `null` when the pin
187
- * cannot be statically materialized (e.g. expression-shaped specRef,
188
- * upstream has a custom `outputs:` projection, spec not in the resolved
189
- * map). The validator emits the user-facing error path; this collector
190
- * is silent on un-resolvable reach-ins because the validator already
191
- * surfaced them.
192
- */
193
- function resolveReachInPin(
194
- doc: WorkflowDocument,
195
- fieldPath: string,
196
- access: PayloadReachIn,
197
- resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
198
- resolvedRefs: Readonly<Record<string, ResolvedRef>>,
199
- ): PayloadReachInPin | null {
200
- const upstream = doc.jobs[access.upstreamJobKey];
201
- if (!upstream) return null;
202
- if (upstream.outputs && Object.keys(upstream.outputs).length > 0) {
203
- return null;
204
- }
205
-
206
- // Prefer per-output binding from the upstream activity manifest.
207
- // Falls back to the job-level `with.deliverableSpecRef`
208
- // when no per-output binding is declared OR when the reach-in's
209
- // outputName is one of the indexed sentinels.
210
- let specRef: string | null = null;
211
- let outputName: string | null = null;
212
- if (
213
- access.outputName !== '<indexed>' &&
214
- access.outputName !== '<byKey>'
215
- ) {
216
- const perOutputRef = lookupOutputBindingSpecRef(
217
- upstream,
218
- access.outputName,
219
- resolvedRefs,
220
- );
221
- if (perOutputRef !== null) {
222
- specRef = perOutputRef;
223
- outputName = access.outputName;
224
- }
225
- }
226
- if (specRef === null) {
227
- const withMap = upstream.with as Record<string, unknown> | undefined;
228
- const fromWith = withMap?.['deliverableSpecRef'];
229
- if (typeof fromWith !== 'string' || fromWith.length === 0) return null;
230
- if (fromWith.includes('${{')) return null;
231
- specRef = fromWith;
232
- }
233
-
234
- const spec = resolvedSpecs[specRef];
235
- if (!spec) return null;
236
- const at = specRef.lastIndexOf('@');
237
- const specVersion = at >= 0 ? specRef.slice(at + 1) : '';
238
- if (specVersion.length === 0) return null;
239
- return {
240
- fieldPath,
241
- upstreamJobKey: access.upstreamJobKey,
242
- outputName,
243
- specRef,
244
- specVersion,
245
- };
246
- }
247
-
248
- interface CollectedExpression {
249
- readonly source: string;
250
- readonly fieldPath: string;
251
- }
252
-
253
- function collectJobExpressions(
254
- jobKey: string,
255
- job: WorkflowDocument['jobs'][string],
256
- ): readonly CollectedExpression[] {
257
- const out: CollectedExpression[] = [];
258
- if (job.if !== undefined) {
259
- out.push({ source: stripInterpolation(job.if), fieldPath: `${jobKey}.if` });
260
- }
261
- if (job.with) {
262
- for (const ext of extractInterpolations(job.with, [jobKey, 'with'])) {
263
- out.push({ source: ext.source, fieldPath: ext.path.join('.') });
264
- }
265
- }
266
- if (job.outputs) {
267
- for (const [name, expr] of Object.entries(job.outputs)) {
268
- out.push({
269
- source: stripInterpolation(expr),
270
- fieldPath: `${jobKey}.outputs.${name}`,
271
- });
272
- }
273
- }
274
- if (job.strategy && 'dynamic' in job.strategy) {
275
- out.push({
276
- source: stripInterpolation(job.strategy.dynamic.from),
277
- fieldPath: `${jobKey}.strategy.dynamic.from`,
278
- });
279
- }
280
- return out;
281
- }
282
-
283
- interface PayloadReachIn {
284
- readonly upstreamJobKey: string;
285
- readonly outputName: string;
286
- readonly field: string;
287
- }
288
-
289
- export function walkPayloadReachIns(
290
- node: ExpressionNode,
291
- visit: (access: PayloadReachIn) => void,
292
- ): void {
293
- const match = matchPayloadReachIn(node);
294
- if (match !== null) {
295
- visit(match);
296
- // Continue recursion so nested expressions inside index nodes are
297
- // also walked — e.g. byKey[needs.Y.outputs.foo.bar] would carry
298
- // its own reach-in.
299
- }
300
- switch (node.kind) {
301
- case ExpressionNodeKind.MEMBER:
302
- walkPayloadReachIns(node.target, visit);
303
- return;
304
- case ExpressionNodeKind.INDEX:
305
- walkPayloadReachIns(node.target, visit);
306
- walkPayloadReachIns(node.index, visit);
307
- return;
308
- case ExpressionNodeKind.CALL:
309
- // Skip `fromJSON(...)` and `toJSON(...)` arguments — those are
310
- // the documented escape hatch; the author opted out of typed
311
- // reach-in validation by wrapping the ref.
312
- for (const arg of node.args) walkPayloadReachIns(arg, visit);
313
- return;
314
- case ExpressionNodeKind.UNARY_NOT:
315
- walkPayloadReachIns(node.operand, visit);
316
- return;
317
- case ExpressionNodeKind.BINARY_EQ:
318
- case ExpressionNodeKind.BINARY_NEQ:
319
- case ExpressionNodeKind.BINARY_AND:
320
- case ExpressionNodeKind.BINARY_OR:
321
- walkPayloadReachIns(node.left, visit);
322
- walkPayloadReachIns(node.right, visit);
323
- return;
324
- case ExpressionNodeKind.LITERAL:
325
- case ExpressionNodeKind.IDENTIFIER:
326
- return;
327
- }
328
- }
329
-
330
- /**
331
- * Match the outermost `needs.<X>.outputs.<name>.<field>` shape. Returns
332
- * null if the chain doesn't match, OR if `<field>` is an envelope
333
- * field (the runtime reads those off the ArtifactRef without a fetch).
334
- *
335
- * Also matches:
336
- * needs.<X>.outputs[N].<field>
337
- * needs.<X>.outputs.byKey['k'].<field>
338
- *
339
- * For these, `<name>` is set to a sentinel so the validator still
340
- * resolves the upstream job's spec but doesn't attempt per-output
341
- * name resolution (today's data model declares the spec at the
342
- * job level, not per-output — see the plan's "B." section).
343
- */
344
- function matchPayloadReachIn(node: ExpressionNode): PayloadReachIn | null {
345
- if (node.kind !== ExpressionNodeKind.MEMBER) return null;
346
- const field = node.property;
347
- if (isArtifactRefEnvelopeField(field)) return null;
348
-
349
- // Walk one level up: `<base>.<field>` where `<base>` must resolve to
350
- // `needs.<X>.outputs.<name>` (or an indexed/keyed variant).
351
- const base = node.target;
352
-
353
- // Form A: needs.<X>.outputs.<name>.<field>
354
- if (base.kind === ExpressionNodeKind.MEMBER) {
355
- const outputName = base.property;
356
- if (outputName === 'byKey') return null; // handled below
357
- const outputsNode = base.target;
358
- const upstreamKey = matchNeedsOutputsRoot(outputsNode);
359
- if (upstreamKey !== null) {
360
- return { upstreamJobKey: upstreamKey, outputName, field };
361
- }
362
- }
363
-
364
- // Form B: needs.<X>.outputs[N].<field> OR needs.<X>.outputs.byKey['k'].<field>
365
- if (base.kind === ExpressionNodeKind.INDEX) {
366
- const indexed = base.target;
367
- // outputs[N]
368
- const upstreamFromOutputs = matchNeedsOutputsRoot(indexed);
369
- if (upstreamFromOutputs !== null) {
370
- return {
371
- upstreamJobKey: upstreamFromOutputs,
372
- outputName: '<indexed>',
373
- field,
374
- };
375
- }
376
- // outputs.byKey['k']
377
- if (
378
- indexed.kind === ExpressionNodeKind.MEMBER &&
379
- indexed.property === 'byKey'
380
- ) {
381
- const upstreamFromByKey = matchNeedsOutputsRoot(indexed.target);
382
- if (upstreamFromByKey !== null) {
383
- return {
384
- upstreamJobKey: upstreamFromByKey,
385
- outputName: '<byKey>',
386
- field,
387
- };
388
- }
389
- }
390
- }
391
-
392
- return null;
393
- }
394
-
395
- /**
396
- * Match the chain `needs.<X>.outputs`. Returns the upstream job key, or
397
- * null if the node isn't that shape.
398
- */
399
- function matchNeedsOutputsRoot(node: ExpressionNode): string | null {
400
- if (node.kind !== ExpressionNodeKind.MEMBER) return null;
401
- if (node.property !== 'outputs') return null;
402
- const needsRoot = node.target;
403
- if (needsRoot.kind !== ExpressionNodeKind.MEMBER) return null;
404
- const upstreamKey = needsRoot.property;
405
- const ident = needsRoot.target;
406
- if (ident.kind !== ExpressionNodeKind.IDENTIFIER) return null;
407
- if (ident.name !== 'needs') return null;
408
- return upstreamKey;
409
- }
410
-
411
- function validateReachIn(
412
- doc: WorkflowDocument,
413
- consumerJobKey: string,
414
- fieldPath: string,
415
- access: PayloadReachIn,
416
- resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
417
- resolvedRefs: Readonly<Record<string, ResolvedRef>>,
418
- ): void {
419
- const upstream = doc.jobs[access.upstreamJobKey];
420
- if (!upstream) {
421
- // Topological-sort step already raises a clearer error for unknown
422
- // upstreams; don't double-report here.
423
- return;
424
- }
425
-
426
- // If the upstream declares an `outputs:` projection, it re-shapes the
427
- // exposed namespace. We can't statically introspect arbitrary author
428
- // projections, so skip — the projection itself is expression-validated
429
- // upstream.
430
- if (upstream.outputs && Object.keys(upstream.outputs).length > 0) {
431
- return;
432
- }
433
-
434
- // Per-output binding from the upstream activity manifest
435
- // wins over the job-level `with.deliverableSpecRef` when present.
436
- const perOutputBinding =
437
- access.outputName === '<indexed>' || access.outputName === '<byKey>'
438
- ? null
439
- : lookupOutputBindingSpecRef(upstream, access.outputName, resolvedRefs);
440
-
441
- const withMap = upstream.with as Record<string, unknown> | undefined;
442
- const specRef = perOutputBinding ?? withMap?.['deliverableSpecRef'];
443
-
444
- // Expression-shaped spec → unknowable at compile time. Runtime
445
- // surfaces `DSL_PAYLOAD_PARSE_ERROR` if reach-in is ultimately wrong.
446
- if (typeof specRef === 'string' && specRef.includes('${{')) {
447
- return;
448
- }
449
-
450
- if (typeof specRef !== 'string' || specRef.length === 0) {
451
- throw new WorkflowDslError(
452
- WorkflowErrorCode.DSL_FIELD_NOT_TYPED,
453
- `${fieldPath}: 'needs.${access.upstreamJobKey}.outputs.${access.outputName}.${access.field}' reaches into the payload of an output that has no schema-bearing deliverable spec. Either bind a typed 'with.deliverableSpecRef' on '${access.upstreamJobKey}' or use the 'fromJSON(...)' escape hatch.`,
454
- {
455
- consumerJobKey,
456
- upstreamJobKey: access.upstreamJobKey,
457
- outputName: access.outputName,
458
- field: access.field,
459
- fieldPath,
460
- },
461
- );
462
- }
463
-
464
- const spec = resolvedSpecs[specRef];
465
- if (!spec) {
466
- // Engine couldn't resolve the spec (e.g. registry unreachable) —
467
- // the existing literal-reference validator already emits
468
- // `DSL_UNKNOWN_DELIVERABLE_SPEC` for this case; don't double-report.
469
- return;
470
- }
471
- if (!spec.topLevelKeys || spec.topLevelKeys.length === 0) {
472
- // Spec kind doesn't support static field introspection
473
- // (DOCUMENT_TEMPLATE, STRUCTURED_JSON, CUSTOM, …). Defer to runtime.
474
- return;
475
- }
476
- if (spec.topLevelKeys.includes(access.field)) {
477
- // Valid reach-in. The runtime is responsible for pre-fetching this
478
- // artifact's payload into the per-step `payloadCache` before the
479
- // expression evaluates — see the plan's runtime-resolver scaffold.
480
- return;
481
- }
482
-
483
- const knownPreview = spec.topLevelKeys.slice(0, 12).join(', ');
484
- throw new WorkflowDslError(
485
- WorkflowErrorCode.DSL_FIELD_NOT_IN_SCHEMA,
486
- `${fieldPath}: 'needs.${access.upstreamJobKey}.outputs.${access.outputName}.${access.field}' is not a top-level field on deliverable spec '${spec.ref}' (bound by '${access.upstreamJobKey}.with.deliverableSpecRef'). Known fields: [${knownPreview}].`,
487
- {
488
- consumerJobKey,
489
- upstreamJobKey: access.upstreamJobKey,
490
- outputName: access.outputName,
491
- field: access.field,
492
- fieldPath,
493
- specRef: spec.ref,
494
- knownFields: [...spec.topLevelKeys],
495
- },
496
- );
497
- }