@xemahq/dsl 0.8.1 → 0.8.3
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/dist/payload-codec/index.d.ts.map +1 -1
- package/dist/payload-codec/index.js.map +1 -1
- package/dist/payload-codec/lib/codec.d.ts.map +1 -1
- package/dist/payload-codec/lib/codec.js +2 -1
- package/dist/payload-codec/lib/codec.js.map +1 -1
- package/dist/payload-codec/lib/payload.d.ts.map +1 -1
- package/dist/schema/action.schema.json +205 -39
- package/dist/schema/workflow.schema.json +335 -88
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/lib/compiler/compile.js +1 -1
- package/dist/workflow/lib/compiler/compile.js.map +1 -1
- package/package.json +2 -18
- package/schema/action.schema.json +205 -39
- package/schema/workflow.schema.json +335 -88
- package/dist/payload-codec/temporal/index.d.ts +0 -5
- package/dist/payload-codec/temporal/index.d.ts.map +0 -1
- package/dist/payload-codec/temporal/index.js +0 -7
- package/dist/payload-codec/temporal/index.js.map +0 -1
- package/src/deliverable-spec/index.ts +0 -19
- package/src/deliverable-spec/lib/schema.ts +0 -270
- package/src/deliverable-spec/lib/types.ts +0 -26
- package/src/payload-codec/index.ts +0 -44
- package/src/payload-codec/lib/blob-store.ts +0 -176
- package/src/payload-codec/lib/codec-context.ts +0 -38
- package/src/payload-codec/lib/codec.ts +0 -605
- package/src/payload-codec/lib/enums.ts +0 -58
- package/src/payload-codec/lib/errors.ts +0 -54
- package/src/payload-codec/lib/http-blob-store.ts +0 -267
- package/src/payload-codec/lib/lru-cache.ts +0 -81
- package/src/payload-codec/lib/payload.ts +0 -26
- package/src/payload-codec/temporal/index.ts +0 -36
- package/src/workflow/index.ts +0 -108
- package/src/workflow/lib/action-input-validator.ts +0 -160
- package/src/workflow/lib/compiler/action-shape.ts +0 -71
- package/src/workflow/lib/compiler/canonical-json.ts +0 -66
- package/src/workflow/lib/compiler/compile.ts +0 -1742
- package/src/workflow/lib/compiler/concurrency.ts +0 -223
- package/src/workflow/lib/compiler/dag.ts +0 -108
- package/src/workflow/lib/compiler/gate-defaults.ts +0 -153
- package/src/workflow/lib/compiler/index.ts +0 -11
- package/src/workflow/lib/compiler/inputs.ts +0 -254
- package/src/workflow/lib/compiler/installation-resource-validator.ts +0 -114
- package/src/workflow/lib/compiler/manifest-source.ts +0 -71
- package/src/workflow/lib/compiler/matrix.ts +0 -135
- package/src/workflow/lib/compiler/mount-plan.ts +0 -190
- package/src/workflow/lib/compiler/payload-reach-in.ts +0 -497
- package/src/workflow/lib/compiler/permissions.ts +0 -64
- package/src/workflow/lib/compiler/retry-timeout.ts +0 -105
- package/src/workflow/lib/compiler/review-step.ts +0 -548
- package/src/workflow/lib/compiler/types.ts +0 -172
- package/src/workflow/lib/compiler/variable-requirements.ts +0 -208
- package/src/workflow/lib/deliverable-spec-introspection-error.ts +0 -63
- package/src/workflow/lib/deliverable-spec-keys.ts +0 -147
- package/src/workflow/lib/deliverable-spec-source-scan.ts +0 -280
- package/src/workflow/lib/dispatch-inputs/index.ts +0 -160
- package/src/workflow/lib/dispatch-inputs/to-json-schema.ts +0 -60
- package/src/workflow/lib/duration.ts +0 -43
- package/src/workflow/lib/errors.ts +0 -37
- package/src/workflow/lib/expression/ast.ts +0 -108
- package/src/workflow/lib/expression/context.ts +0 -148
- package/src/workflow/lib/expression/evaluator.ts +0 -492
- package/src/workflow/lib/expression/index.ts +0 -28
- package/src/workflow/lib/expression/interpolation.ts +0 -84
- package/src/workflow/lib/expression/parser.ts +0 -264
- package/src/workflow/lib/expression/template.ts +0 -117
- package/src/workflow/lib/expression/tokenizer.ts +0 -200
- package/src/workflow/lib/expression/tokens.ts +0 -30
- package/src/workflow/lib/expression/walk-artifact-refs.ts +0 -232
- package/src/workflow/lib/installation-resource-kind.ts +0 -107
- package/src/workflow/lib/schemas-loader.ts +0 -64
- package/src/workflow/lib/serializer.ts +0 -30
- package/src/workflow/lib/types.ts +0 -417
- package/src/workflow/lib/validate.ts +0 -199
- package/src/workspace-manifest/index.ts +0 -27
- package/src/workspace-manifest/lib/compile.ts +0 -619
- package/src/workspace-manifest/lib/interpolate.ts +0 -166
- package/src/workspace-manifest/lib/resolve-extends.ts +0 -260
- package/src/workspace-manifest/lib/schema.ts +0 -692
- 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
|
-
}
|