@xemahq/dsl 0.1.1
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/LICENSE +201 -0
- package/dist/deliverable-spec/index.d.ts +3 -0
- package/dist/deliverable-spec/index.d.ts.map +1 -0
- package/dist/deliverable-spec/index.js +19 -0
- package/dist/deliverable-spec/index.js.map +1 -0
- package/dist/deliverable-spec/lib/schema.d.ts +151 -0
- package/dist/deliverable-spec/lib/schema.d.ts.map +1 -0
- package/dist/deliverable-spec/lib/schema.js +139 -0
- package/dist/deliverable-spec/lib/schema.js.map +1 -0
- package/dist/deliverable-spec/lib/types.d.ts +8 -0
- package/dist/deliverable-spec/lib/types.d.ts.map +1 -0
- package/dist/deliverable-spec/lib/types.js +3 -0
- package/dist/deliverable-spec/lib/types.js.map +1 -0
- package/dist/payload-codec/index.d.ts +8 -0
- package/dist/payload-codec/index.d.ts.map +1 -0
- package/dist/payload-codec/index.js +27 -0
- package/dist/payload-codec/index.js.map +1 -0
- package/dist/payload-codec/lib/blob-store.d.ts +37 -0
- package/dist/payload-codec/lib/blob-store.d.ts.map +1 -0
- package/dist/payload-codec/lib/blob-store.js +0 -0
- package/dist/payload-codec/lib/blob-store.js.map +1 -0
- package/dist/payload-codec/lib/codec-context.d.ts +6 -0
- package/dist/payload-codec/lib/codec-context.d.ts.map +1 -0
- package/dist/payload-codec/lib/codec-context.js +16 -0
- package/dist/payload-codec/lib/codec-context.js.map +1 -0
- package/dist/payload-codec/lib/codec.d.ts +51 -0
- package/dist/payload-codec/lib/codec.d.ts.map +1 -0
- package/dist/payload-codec/lib/codec.js +330 -0
- package/dist/payload-codec/lib/codec.js.map +1 -0
- package/dist/payload-codec/lib/enums.d.ts +18 -0
- package/dist/payload-codec/lib/enums.d.ts.map +1 -0
- package/dist/payload-codec/lib/enums.js +23 -0
- package/dist/payload-codec/lib/enums.js.map +1 -0
- package/dist/payload-codec/lib/errors.d.ts +18 -0
- package/dist/payload-codec/lib/errors.d.ts.map +1 -0
- package/dist/payload-codec/lib/errors.js +39 -0
- package/dist/payload-codec/lib/errors.js.map +1 -0
- package/dist/payload-codec/lib/http-blob-store.d.ts +21 -0
- package/dist/payload-codec/lib/http-blob-store.d.ts.map +1 -0
- package/dist/payload-codec/lib/http-blob-store.js +139 -0
- package/dist/payload-codec/lib/http-blob-store.js.map +1 -0
- package/dist/payload-codec/lib/lru-cache.d.ts +12 -0
- package/dist/payload-codec/lib/lru-cache.d.ts.map +1 -0
- package/dist/payload-codec/lib/lru-cache.js +59 -0
- package/dist/payload-codec/lib/lru-cache.js.map +1 -0
- package/dist/schema/action.schema.json +181 -0
- package/dist/schema/reusable-workflow.schema.json +46 -0
- package/dist/schema/workflow.schema.json +373 -0
- package/dist/workflow/index.d.ts +14 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +49 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/lib/action-input-validator.d.ts +10 -0
- package/dist/workflow/lib/action-input-validator.d.ts.map +1 -0
- package/dist/workflow/lib/action-input-validator.js +69 -0
- package/dist/workflow/lib/action-input-validator.js.map +1 -0
- package/dist/workflow/lib/compiler/action-shape.d.ts +5 -0
- package/dist/workflow/lib/compiler/action-shape.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/action-shape.js +43 -0
- package/dist/workflow/lib/compiler/action-shape.js.map +1 -0
- package/dist/workflow/lib/compiler/canonical-json.d.ts +3 -0
- package/dist/workflow/lib/compiler/canonical-json.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/canonical-json.js +45 -0
- package/dist/workflow/lib/compiler/canonical-json.js.map +1 -0
- package/dist/workflow/lib/compiler/compile.d.ts +4 -0
- package/dist/workflow/lib/compiler/compile.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/compile.js +794 -0
- package/dist/workflow/lib/compiler/compile.js.map +1 -0
- package/dist/workflow/lib/compiler/concurrency.d.ts +5 -0
- package/dist/workflow/lib/compiler/concurrency.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/concurrency.js +104 -0
- package/dist/workflow/lib/compiler/concurrency.js.map +1 -0
- package/dist/workflow/lib/compiler/dag.d.ts +10 -0
- package/dist/workflow/lib/compiler/dag.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/dag.js +74 -0
- package/dist/workflow/lib/compiler/dag.js.map +1 -0
- package/dist/workflow/lib/compiler/index.d.ts +6 -0
- package/dist/workflow/lib/compiler/index.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/index.js +14 -0
- package/dist/workflow/lib/compiler/index.js.map +1 -0
- package/dist/workflow/lib/compiler/inputs.d.ts +4 -0
- package/dist/workflow/lib/compiler/inputs.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/inputs.js +108 -0
- package/dist/workflow/lib/compiler/inputs.js.map +1 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.d.ts +9 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.js +76 -0
- package/dist/workflow/lib/compiler/installation-resource-validator.js.map +1 -0
- package/dist/workflow/lib/compiler/manifest-source.d.ts +4 -0
- package/dist/workflow/lib/compiler/manifest-source.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/manifest-source.js +100 -0
- package/dist/workflow/lib/compiler/manifest-source.js.map +1 -0
- package/dist/workflow/lib/compiler/matrix.d.ts +4 -0
- package/dist/workflow/lib/compiler/matrix.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/matrix.js +76 -0
- package/dist/workflow/lib/compiler/matrix.js.map +1 -0
- package/dist/workflow/lib/compiler/mount-plan.d.ts +4 -0
- package/dist/workflow/lib/compiler/mount-plan.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/mount-plan.js +96 -0
- package/dist/workflow/lib/compiler/mount-plan.js.map +1 -0
- package/dist/workflow/lib/compiler/payload-reach-in.d.ts +14 -0
- package/dist/workflow/lib/compiler/payload-reach-in.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/payload-reach-in.js +273 -0
- package/dist/workflow/lib/compiler/payload-reach-in.js.map +1 -0
- package/dist/workflow/lib/compiler/permissions.d.ts +6 -0
- package/dist/workflow/lib/compiler/permissions.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/permissions.js +43 -0
- package/dist/workflow/lib/compiler/permissions.js.map +1 -0
- package/dist/workflow/lib/compiler/retry-timeout.d.ts +6 -0
- package/dist/workflow/lib/compiler/retry-timeout.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/retry-timeout.js +64 -0
- package/dist/workflow/lib/compiler/retry-timeout.js.map +1 -0
- package/dist/workflow/lib/compiler/review-step.d.ts +18 -0
- package/dist/workflow/lib/compiler/review-step.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/review-step.js +247 -0
- package/dist/workflow/lib/compiler/review-step.js.map +1 -0
- package/dist/workflow/lib/compiler/types.d.ts +42 -0
- package/dist/workflow/lib/compiler/types.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/types.js +3 -0
- package/dist/workflow/lib/compiler/types.js.map +1 -0
- package/dist/workflow/lib/compiler/variable-requirements.d.ts +5 -0
- package/dist/workflow/lib/compiler/variable-requirements.d.ts.map +1 -0
- package/dist/workflow/lib/compiler/variable-requirements.js +119 -0
- package/dist/workflow/lib/compiler/variable-requirements.js.map +1 -0
- package/dist/workflow/lib/deliverable-spec-keys.d.ts +3 -0
- package/dist/workflow/lib/deliverable-spec-keys.d.ts.map +1 -0
- package/dist/workflow/lib/deliverable-spec-keys.js +90 -0
- package/dist/workflow/lib/deliverable-spec-keys.js.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/index.d.ts +23 -0
- package/dist/workflow/lib/dispatch-inputs/index.d.ts.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/index.js +106 -0
- package/dist/workflow/lib/dispatch-inputs/index.js.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts +3 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.d.ts.map +1 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.js +43 -0
- package/dist/workflow/lib/dispatch-inputs/to-json-schema.js.map +1 -0
- package/dist/workflow/lib/duration.d.ts +2 -0
- package/dist/workflow/lib/duration.d.ts.map +1 -0
- package/dist/workflow/lib/duration.js +26 -0
- package/dist/workflow/lib/duration.js.map +1 -0
- package/dist/workflow/lib/errors.d.ts +9 -0
- package/dist/workflow/lib/errors.d.ts.map +1 -0
- package/dist/workflow/lib/errors.js +28 -0
- package/dist/workflow/lib/errors.js.map +1 -0
- package/dist/workflow/lib/expression/ast.d.ts +61 -0
- package/dist/workflow/lib/expression/ast.d.ts.map +1 -0
- package/dist/workflow/lib/expression/ast.js +34 -0
- package/dist/workflow/lib/expression/ast.js.map +1 -0
- package/dist/workflow/lib/expression/context.d.ts +63 -0
- package/dist/workflow/lib/expression/context.d.ts.map +1 -0
- package/dist/workflow/lib/expression/context.js +32 -0
- package/dist/workflow/lib/expression/context.js.map +1 -0
- package/dist/workflow/lib/expression/evaluator.d.ts +5 -0
- package/dist/workflow/lib/expression/evaluator.d.ts.map +1 -0
- package/dist/workflow/lib/expression/evaluator.js +291 -0
- package/dist/workflow/lib/expression/evaluator.js.map +1 -0
- package/dist/workflow/lib/expression/index.d.ts +9 -0
- package/dist/workflow/lib/expression/index.d.ts.map +1 -0
- package/dist/workflow/lib/expression/index.js +26 -0
- package/dist/workflow/lib/expression/index.js.map +1 -0
- package/dist/workflow/lib/expression/interpolation.d.ts +9 -0
- package/dist/workflow/lib/expression/interpolation.d.ts.map +1 -0
- package/dist/workflow/lib/expression/interpolation.js +51 -0
- package/dist/workflow/lib/expression/interpolation.js.map +1 -0
- package/dist/workflow/lib/expression/parser.d.ts +4 -0
- package/dist/workflow/lib/expression/parser.d.ts.map +1 -0
- package/dist/workflow/lib/expression/parser.js +203 -0
- package/dist/workflow/lib/expression/parser.js.map +1 -0
- package/dist/workflow/lib/expression/template.d.ts +18 -0
- package/dist/workflow/lib/expression/template.d.ts.map +1 -0
- package/dist/workflow/lib/expression/template.js +63 -0
- package/dist/workflow/lib/expression/template.js.map +1 -0
- package/dist/workflow/lib/expression/tokenizer.d.ts +3 -0
- package/dist/workflow/lib/expression/tokenizer.d.ts.map +1 -0
- package/dist/workflow/lib/expression/tokenizer.js +153 -0
- package/dist/workflow/lib/expression/tokenizer.js.map +1 -0
- package/dist/workflow/lib/expression/tokens.d.ts +25 -0
- package/dist/workflow/lib/expression/tokens.d.ts.map +1 -0
- package/dist/workflow/lib/expression/tokens.js +24 -0
- package/dist/workflow/lib/expression/tokens.js.map +1 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.d.ts +5 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.d.ts.map +1 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.js +138 -0
- package/dist/workflow/lib/expression/walk-artifact-refs.js.map +1 -0
- package/dist/workflow/lib/installation-resource-kind.d.ts +14 -0
- package/dist/workflow/lib/installation-resource-kind.d.ts.map +1 -0
- package/dist/workflow/lib/installation-resource-kind.js +59 -0
- package/dist/workflow/lib/installation-resource-kind.js.map +1 -0
- package/dist/workflow/lib/schemas-loader.d.ts +4 -0
- package/dist/workflow/lib/schemas-loader.d.ts.map +1 -0
- package/dist/workflow/lib/schemas-loader.js +36 -0
- package/dist/workflow/lib/schemas-loader.js.map +1 -0
- package/dist/workflow/lib/serializer.d.ts +3 -0
- package/dist/workflow/lib/serializer.d.ts.map +1 -0
- package/dist/workflow/lib/serializer.js +15 -0
- package/dist/workflow/lib/serializer.js.map +1 -0
- package/dist/workflow/lib/types.d.ts +179 -0
- package/dist/workflow/lib/types.d.ts.map +1 -0
- package/dist/workflow/lib/types.js +3 -0
- package/dist/workflow/lib/types.js.map +1 -0
- package/dist/workflow/lib/validate.d.ts +8 -0
- package/dist/workflow/lib/validate.d.ts.map +1 -0
- package/dist/workflow/lib/validate.js +119 -0
- package/dist/workflow/lib/validate.js.map +1 -0
- package/dist/workspace-manifest/index.d.ts +6 -0
- package/dist/workspace-manifest/index.d.ts.map +1 -0
- package/dist/workspace-manifest/index.js +22 -0
- package/dist/workspace-manifest/index.js.map +1 -0
- package/dist/workspace-manifest/lib/compile.d.ts +8 -0
- package/dist/workspace-manifest/lib/compile.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/compile.js +439 -0
- package/dist/workspace-manifest/lib/compile.js.map +1 -0
- package/dist/workspace-manifest/lib/interpolate.d.ts +12 -0
- package/dist/workspace-manifest/lib/interpolate.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/interpolate.js +81 -0
- package/dist/workspace-manifest/lib/interpolate.js.map +1 -0
- package/dist/workspace-manifest/lib/resolve-extends.d.ts +10 -0
- package/dist/workspace-manifest/lib/resolve-extends.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/resolve-extends.js +108 -0
- package/dist/workspace-manifest/lib/resolve-extends.js.map +1 -0
- package/dist/workspace-manifest/lib/schema.d.ts +710 -0
- package/dist/workspace-manifest/lib/schema.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/schema.js +355 -0
- package/dist/workspace-manifest/lib/schema.js.map +1 -0
- package/dist/workspace-manifest/lib/types.d.ts +153 -0
- package/dist/workspace-manifest/lib/types.d.ts.map +1 -0
- package/dist/workspace-manifest/lib/types.js +10 -0
- package/dist/workspace-manifest/lib/types.js.map +1 -0
- package/package.json +79 -0
- package/schema/action.schema.json +181 -0
- package/schema/reusable-workflow.schema.json +46 -0
- package/schema/workflow.schema.json +373 -0
- package/src/deliverable-spec/index.ts +19 -0
- package/src/deliverable-spec/lib/schema.ts +248 -0
- package/src/deliverable-spec/lib/types.ts +26 -0
- package/src/payload-codec/index.ts +40 -0
- package/src/payload-codec/lib/blob-store.ts +0 -0
- package/src/payload-codec/lib/codec-context.ts +38 -0
- package/src/payload-codec/lib/codec.ts +593 -0
- package/src/payload-codec/lib/enums.ts +58 -0
- package/src/payload-codec/lib/errors.ts +54 -0
- package/src/payload-codec/lib/http-blob-store.ts +257 -0
- package/src/payload-codec/lib/lru-cache.ts +81 -0
- package/src/workflow/index.ts +98 -0
- package/src/workflow/lib/action-input-validator.ts +160 -0
- package/src/workflow/lib/compiler/action-shape.ts +71 -0
- package/src/workflow/lib/compiler/canonical-json.ts +53 -0
- package/src/workflow/lib/compiler/compile.ts +1518 -0
- package/src/workflow/lib/compiler/concurrency.ts +223 -0
- package/src/workflow/lib/compiler/dag.ts +108 -0
- package/src/workflow/lib/compiler/index.ts +10 -0
- package/src/workflow/lib/compiler/inputs.ts +199 -0
- package/src/workflow/lib/compiler/installation-resource-validator.ts +114 -0
- package/src/workflow/lib/compiler/manifest-source.ts +176 -0
- package/src/workflow/lib/compiler/matrix.ts +135 -0
- package/src/workflow/lib/compiler/mount-plan.ts +202 -0
- package/src/workflow/lib/compiler/payload-reach-in.ts +497 -0
- package/src/workflow/lib/compiler/permissions.ts +64 -0
- package/src/workflow/lib/compiler/retry-timeout.ts +105 -0
- package/src/workflow/lib/compiler/review-step.ts +517 -0
- package/src/workflow/lib/compiler/types.ts +170 -0
- package/src/workflow/lib/compiler/variable-requirements.ts +208 -0
- package/src/workflow/lib/deliverable-spec-keys.ts +109 -0
- package/src/workflow/lib/dispatch-inputs/index.ts +160 -0
- package/src/workflow/lib/dispatch-inputs/to-json-schema.ts +60 -0
- package/src/workflow/lib/duration.ts +48 -0
- package/src/workflow/lib/errors.ts +37 -0
- package/src/workflow/lib/expression/ast.ts +108 -0
- package/src/workflow/lib/expression/context.ts +148 -0
- package/src/workflow/lib/expression/evaluator.ts +492 -0
- package/src/workflow/lib/expression/index.ts +28 -0
- package/src/workflow/lib/expression/interpolation.ts +84 -0
- package/src/workflow/lib/expression/parser.ts +264 -0
- package/src/workflow/lib/expression/template.ts +117 -0
- package/src/workflow/lib/expression/tokenizer.ts +200 -0
- package/src/workflow/lib/expression/tokens.ts +30 -0
- package/src/workflow/lib/expression/walk-artifact-refs.ts +232 -0
- package/src/workflow/lib/installation-resource-kind.ts +107 -0
- package/src/workflow/lib/schemas-loader.ts +64 -0
- package/src/workflow/lib/serializer.ts +30 -0
- package/src/workflow/lib/types.ts +361 -0
- package/src/workflow/lib/validate.ts +199 -0
- package/src/workspace-manifest/index.ts +27 -0
- package/src/workspace-manifest/lib/compile.ts +608 -0
- package/src/workspace-manifest/lib/interpolate.ts +140 -0
- package/src/workspace-manifest/lib/resolve-extends.ts +260 -0
- package/src/workspace-manifest/lib/schema.ts +612 -0
- package/src/workspace-manifest/lib/types.ts +392 -0
|
@@ -0,0 +1,1518 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionExecutionKind,
|
|
3
|
+
ActionKind,
|
|
4
|
+
MatrixStrategyKind,
|
|
5
|
+
WorkflowErrorCode,
|
|
6
|
+
type ActionRef,
|
|
7
|
+
type CompiledJob,
|
|
8
|
+
type CompiledRun,
|
|
9
|
+
type PermissionResource,
|
|
10
|
+
type PermissionScope,
|
|
11
|
+
} from '@xemahq/kernel-contracts/workflow';
|
|
12
|
+
import { WorkflowDslError } from '../errors';
|
|
13
|
+
import {
|
|
14
|
+
compileExpression,
|
|
15
|
+
ExpressionNodeKind,
|
|
16
|
+
extractInterpolations,
|
|
17
|
+
stripInterpolation,
|
|
18
|
+
type ExpressionNode,
|
|
19
|
+
} from '../expression';
|
|
20
|
+
import type { WorkflowDocument } from '../types';
|
|
21
|
+
import { canonicalJsonSha256 } from './canonical-json';
|
|
22
|
+
import {
|
|
23
|
+
compileConcurrency,
|
|
24
|
+
validateConcurrencyGroupTemplate,
|
|
25
|
+
} from './concurrency';
|
|
26
|
+
import { topologicalSort } from './dag';
|
|
27
|
+
import { bindTriggerInputs } from './inputs';
|
|
28
|
+
import { compileStrategy } from './matrix';
|
|
29
|
+
import { compileManifestSource } from './manifest-source';
|
|
30
|
+
import { compileMountPlan } from './mount-plan';
|
|
31
|
+
import { assertJobPermissionsFit, normalizePermissions } from './permissions';
|
|
32
|
+
import {
|
|
33
|
+
computeReviewNeedsExtensions,
|
|
34
|
+
rewriteReviewStepWith,
|
|
35
|
+
validateReviewSteps,
|
|
36
|
+
} from './review-step';
|
|
37
|
+
import {
|
|
38
|
+
resolveJobRetry,
|
|
39
|
+
resolveJobTimeout,
|
|
40
|
+
resolveWorkflowDefaults,
|
|
41
|
+
} from './retry-timeout';
|
|
42
|
+
import {
|
|
43
|
+
collectPayloadReachInsForJob,
|
|
44
|
+
validatePayloadReachIns,
|
|
45
|
+
} from './payload-reach-in';
|
|
46
|
+
import { validateInstallationResourceBindings } from './installation-resource-validator';
|
|
47
|
+
import {
|
|
48
|
+
resolveWalletRequirements,
|
|
49
|
+
validateVariableReferences,
|
|
50
|
+
} from './variable-requirements';
|
|
51
|
+
import type {
|
|
52
|
+
CompileInput,
|
|
53
|
+
ResolvedAgent,
|
|
54
|
+
ResolvedDeliverableSpec,
|
|
55
|
+
ResolvedRef,
|
|
56
|
+
} from './types';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compile a validated workflow document into a CompiledRun. Deterministic:
|
|
60
|
+
* the same (workflow, trigger, resolvedRefs) always produces the same
|
|
61
|
+
* CompiledRun modulo `snapshotCreatedAt`. Pure function; no I/O.
|
|
62
|
+
*
|
|
63
|
+
* Responsibilities:
|
|
64
|
+
* 1. Bind trigger inputs against declared schemas (fail-fast).
|
|
65
|
+
* 2. Resolve concurrency group to a concrete lease key.
|
|
66
|
+
* 3. Resolve action/reusable-workflow refs using the pre-fetched map.
|
|
67
|
+
* 4. Pre-compile every authored expression (so runtime only deals with
|
|
68
|
+
* already-validated ASTs — not done yet in this pass, we validate
|
|
69
|
+
* without caching because ASTs aren't serializable into the CompiledRun).
|
|
70
|
+
* 5. Expand static matrices; record dynamic matrix metadata for runtime.
|
|
71
|
+
* 6. Topologically sort jobs; reject cycles + unknown needs.
|
|
72
|
+
* 7. Enforce job-level permissions are a subset of workflow permissions.
|
|
73
|
+
* 8. Emit CompiledRun with a deterministic sha256.
|
|
74
|
+
*/
|
|
75
|
+
export function compileWorkflow(input: CompileInput): CompiledRun {
|
|
76
|
+
const { workflow, workflowRef, trigger, resolvedRefs, workflowDefinitionVersionSha256 } = input;
|
|
77
|
+
|
|
78
|
+
const inputs = bindTriggerInputs(workflow, trigger, input.previewMode ?? false);
|
|
79
|
+
const vars: Readonly<Record<string, unknown>> = workflow.vars ?? {};
|
|
80
|
+
const requiredVariables = resolveWalletRequirements(
|
|
81
|
+
workflow,
|
|
82
|
+
input.resolvedWallets,
|
|
83
|
+
);
|
|
84
|
+
const requiredWallets = Object.freeze([
|
|
85
|
+
...(workflow.requires?.wallets ?? []),
|
|
86
|
+
]) as readonly string[];
|
|
87
|
+
const permissions = normalizePermissions(workflow.permissions);
|
|
88
|
+
const concurrency = compileConcurrency(workflow.concurrency, trigger, inputs, vars);
|
|
89
|
+
const defaults = resolveWorkflowDefaults(workflow.defaults);
|
|
90
|
+
|
|
91
|
+
// Validate every expression everywhere at compile time. We throw away the
|
|
92
|
+
// compiled ASTs because CompiledRun is a serializable DTO; the worker will
|
|
93
|
+
// recompile from the same source strings — determinism is preserved
|
|
94
|
+
// because the source text itself is a function of the workflow document.
|
|
95
|
+
validateAllAuthoredExpressions(workflow);
|
|
96
|
+
|
|
97
|
+
// Cross-validate every `${{ vars.X }}` / `${{ secrets.X }}` reference
|
|
98
|
+
// against the union of wallet contents the engine pre-fetched (plus the
|
|
99
|
+
// YAML-static `vars:` block for `vars.*`). Authors get a typo /
|
|
100
|
+
// stale-name error at compile time instead of a runtime
|
|
101
|
+
// DSL_EXPRESSION_INVALID against an unknown property.
|
|
102
|
+
validateVariableReferences(
|
|
103
|
+
workflow,
|
|
104
|
+
requiredVariables,
|
|
105
|
+
input.resolvedWallets,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Validate every literal cross-document reference (agent slugs, deliverable
|
|
109
|
+
// spec refs) against the resolved maps the engine pre-fetched. Expression-
|
|
110
|
+
// shaped values are skipped — they bind at dispatch and the activity's
|
|
111
|
+
// own preflight catches unknowns at runtime. Empty maps opt out (e.g.
|
|
112
|
+
// preview-raw cannot reach the registries).
|
|
113
|
+
validateAllLiteralReferences(
|
|
114
|
+
workflow,
|
|
115
|
+
input.resolvedAgents ?? {},
|
|
116
|
+
input.resolvedDeliverableSpecs ?? {},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// `xema/review@*` semantic checks: subject/redraft shape, redraft.step
|
|
120
|
+
// exists/is-agent/is-in-needs/is-not-matrix, redraft-without-subject
|
|
121
|
+
// forbidden. Run BEFORE expression validation so author errors surface
|
|
122
|
+
// with clear messages before less-helpful generic errors (e.g.
|
|
123
|
+
// "unknown step in needs"). The rewrite + needs extension happen
|
|
124
|
+
// alongside the per-job compile loop below.
|
|
125
|
+
validateReviewSteps(workflow);
|
|
126
|
+
const reviewNeedsExtensions = computeReviewNeedsExtensions(workflow);
|
|
127
|
+
|
|
128
|
+
// `matrixGather:` implicitly depends on the gathered jobKeys, so fold
|
|
129
|
+
// them into `needs` BEFORE the topological sort. This keeps authors from
|
|
130
|
+
// having to duplicate each key; the compiler validates referenced keys
|
|
131
|
+
// below (must exist + must be matrix jobs).
|
|
132
|
+
validateMatrixGather(workflow);
|
|
133
|
+
|
|
134
|
+
// Cross-job validation: every `needs.<X>.outputs.…` access must match
|
|
135
|
+
// upstream's strategy + keyBy. Catches `needs.matrix.outputs.deliverables`
|
|
136
|
+
// (silent-passing-then-runtime-error) and `byKey[…]` against
|
|
137
|
+
// un-`keyBy`-ed upstreams at compile time.
|
|
138
|
+
validateAllNeedsAccessShapes(workflow);
|
|
139
|
+
|
|
140
|
+
// Spec-aware field validation: every
|
|
141
|
+
// `${{ ...outputs.deliverable.content.value.<field> }}` access must
|
|
142
|
+
// reference a `<field>` that exists as a top-level key on the producing
|
|
143
|
+
// job's declared deliverable spec. Skips when the engine couldn't
|
|
144
|
+
// pre-fetch spec content (preview mode or specs without introspectable
|
|
145
|
+
// shape).
|
|
146
|
+
validateDeliverableValueExpressions(workflow, input.resolvedDeliverableSpecs ?? {});
|
|
147
|
+
|
|
148
|
+
// §C.1 payload reach-in validation: `needs.<X>.outputs.<name>.<field>`
|
|
149
|
+
// chains where `<field>` is not an ArtifactRef envelope field reach
|
|
150
|
+
// INTO the artifact's parsed payload. The validator asserts the
|
|
151
|
+
// upstream job has a schema-bearing `with.deliverableSpecRef` and
|
|
152
|
+
// that `<field>` is a top-level key on that spec — fail-fast with
|
|
153
|
+
// `DSL_FIELD_NOT_TYPED` / `DSL_FIELD_NOT_IN_SCHEMA` at compile time
|
|
154
|
+
// so authors don't see a generic "Unknown property" at runtime.
|
|
155
|
+
validatePayloadReachIns(
|
|
156
|
+
workflow,
|
|
157
|
+
input.resolvedDeliverableSpecs ?? {},
|
|
158
|
+
resolvedRefs,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const orderedJobs = topologicalSort(
|
|
162
|
+
Object.entries(workflow.jobs).map(([key, decl]) => {
|
|
163
|
+
const authoredNeeds = decl.needs ?? [];
|
|
164
|
+
const gather = decl.matrixGather ?? [];
|
|
165
|
+
const reviewExt = reviewNeedsExtensions[key] ?? [];
|
|
166
|
+
const merged =
|
|
167
|
+
gather.length > 0 || reviewExt.length > 0
|
|
168
|
+
? Array.from(new Set([...authoredNeeds, ...gather, ...reviewExt]))
|
|
169
|
+
: authoredNeeds;
|
|
170
|
+
return { key, payload: decl, needs: merged };
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const compiledJobs: CompiledJob[] = orderedJobs.map(({ key, payload, needs }) => {
|
|
175
|
+
const resolved = lookupResolvedRef(payload.uses, resolvedRefs);
|
|
176
|
+
const actionRef: ActionRef = {
|
|
177
|
+
id: resolved.id,
|
|
178
|
+
version: resolved.version,
|
|
179
|
+
manifestSha256: resolved.manifestSha256,
|
|
180
|
+
executionKind: resolveJobExecutionKind(key, payload.with, resolved),
|
|
181
|
+
taskQueue: resolved.taskQueue,
|
|
182
|
+
actionKind: resolveActionKind(resolved),
|
|
183
|
+
isReusableWorkflow: resolved.isReusableWorkflow,
|
|
184
|
+
// Pin the manifest's `inputs:` schema into the compiled ref so the
|
|
185
|
+
// worker validates `with:` against the schema that was in effect at
|
|
186
|
+
// compile time — not whatever's currently published. Reusable
|
|
187
|
+
// workflows have their own `workflow_call.inputs`, validated by
|
|
188
|
+
// their own compile pass.
|
|
189
|
+
inputsSchema: resolved.isReusableWorkflow
|
|
190
|
+
? null
|
|
191
|
+
: resolved.actionManifest?.spec.inputs ?? null,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const jobPermissions = normalizePermissions(payload.permissions);
|
|
195
|
+
assertJobPermissionsFit(jobPermissions, permissions, key);
|
|
196
|
+
|
|
197
|
+
const strategy = compileStrategy(payload.strategy, key);
|
|
198
|
+
|
|
199
|
+
// When the job uses a reusable workflow, mount planning is governed
|
|
200
|
+
// by the reusable workflow's own jobs, not by this job. So we emit an
|
|
201
|
+
// empty plan — the child workflow's compiler run will produce its own.
|
|
202
|
+
const mountPlan = resolved.isReusableWorkflow
|
|
203
|
+
? { readOnly: {}, readWrite: {} }
|
|
204
|
+
: compileMountPlan(key, payload.with, resolved.actionManifest);
|
|
205
|
+
|
|
206
|
+
// For agent-shaped actions, pre-resolve the manifest source so the
|
|
207
|
+
// worker sees a single discriminated shape (`ref` / `inline` /
|
|
208
|
+
// `inline-deferred`) instead of branching on input fields at
|
|
209
|
+
// dispatch. Reusable-workflow jobs return null — their nested
|
|
210
|
+
// CompiledRun owns its own agent steps.
|
|
211
|
+
const manifestSource = resolved.isReusableWorkflow
|
|
212
|
+
? null
|
|
213
|
+
: compileManifestSource(key, payload.with, resolved.actionManifest);
|
|
214
|
+
|
|
215
|
+
const retry = resolveJobRetry(
|
|
216
|
+
defaults.retry,
|
|
217
|
+
resolved.actionManifest?.spec.retryDefaults ?? null,
|
|
218
|
+
payload.retry,
|
|
219
|
+
);
|
|
220
|
+
const timeoutMs = resolveJobTimeout(
|
|
221
|
+
defaults.timeoutMs,
|
|
222
|
+
resolved.actionManifest?.spec.timeoutDefaults ?? null,
|
|
223
|
+
payload.timeout,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Validate `if` expression shape now — runtime evaluator only fails on
|
|
227
|
+
// unknown bindings after this point. Plan §G shorthand: rewrite a
|
|
228
|
+
// bare `needs.<X>` reference to `needs.<X>.outcome == 'ok'` so the
|
|
229
|
+
// evaluator sees the explicit success check (an envelope object is
|
|
230
|
+
// truthy even on failure — silent always-true is the trap §G
|
|
231
|
+
// explicitly avoids).
|
|
232
|
+
const ifExpression = payload.if !== undefined
|
|
233
|
+
? (() => {
|
|
234
|
+
const body = stripInterpolation(payload.if!);
|
|
235
|
+
const rewritten = rewriteBareNeedsInIf(body);
|
|
236
|
+
compileExpression(rewritten);
|
|
237
|
+
return rewritten;
|
|
238
|
+
})()
|
|
239
|
+
: null;
|
|
240
|
+
|
|
241
|
+
// For `xema/review@*` steps, rewrite `with:` from the author shape
|
|
242
|
+
// (`subject` + `redraft.step`) to the worker contract
|
|
243
|
+
// (`subjects` + embedded `redraft: { uses, with }`). Other steps
|
|
244
|
+
// pass through verbatim.
|
|
245
|
+
const compiledWith = rewriteReviewStepWith(key, payload, workflow);
|
|
246
|
+
|
|
247
|
+
// Compile-time installation-binding gate: when the engine threaded
|
|
248
|
+
// an installationScope through CompileInput, walk every literal
|
|
249
|
+
// `x-installation-resource` field in the `with:` block and confirm
|
|
250
|
+
// its value is bound to the calling installation. Rejects unbound
|
|
251
|
+
// walletIds (etc.) BEFORE the dispatch creates a run, instead of
|
|
252
|
+
// failing 3 activities later when integration-adapters refuses to
|
|
253
|
+
// mint credentials. Skips entirely for system / org-wide dispatches.
|
|
254
|
+
validateInstallationResourceBindings({
|
|
255
|
+
jobKey: key,
|
|
256
|
+
actionId: actionRef.id,
|
|
257
|
+
inputsSchema: actionRef.inputsSchema,
|
|
258
|
+
withValue: compiledWith,
|
|
259
|
+
scope: input.installationScope,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
jobKey: key,
|
|
264
|
+
title: payload.title ?? null,
|
|
265
|
+
needs,
|
|
266
|
+
matrixGather: Object.freeze([...(payload.matrixGather ?? [])]) as readonly string[],
|
|
267
|
+
ifExpression,
|
|
268
|
+
strategy,
|
|
269
|
+
action: actionRef,
|
|
270
|
+
mountPlan,
|
|
271
|
+
manifestSource,
|
|
272
|
+
with: Object.freeze({ ...compiledWith }) as Readonly<Record<string, unknown>>,
|
|
273
|
+
// Strip the `${{ ... }}` wrapper at compile time so the runtime
|
|
274
|
+
// evaluator receives expression bodies directly — same convention
|
|
275
|
+
// as `ifExpression`. `validateAllAuthoredExpressions` already
|
|
276
|
+
// compiled each body to surface invalid expressions as DSL errors.
|
|
277
|
+
outputs: compileOutputsMap(payload.outputs, `jobs.${key}.outputs`),
|
|
278
|
+
retry,
|
|
279
|
+
timeoutMs,
|
|
280
|
+
permissions: jobPermissions,
|
|
281
|
+
// The DSL emits null; the engine (which owns the reusable-workflow
|
|
282
|
+
// registry) post-processes each CompiledRun and attaches the nested
|
|
283
|
+
// compiled body for jobs whose action is a reusable workflow.
|
|
284
|
+
reusableCompiledRun: null,
|
|
285
|
+
payloadReachIns: Object.freeze(
|
|
286
|
+
collectPayloadReachInsForJob(
|
|
287
|
+
workflow,
|
|
288
|
+
key,
|
|
289
|
+
payload,
|
|
290
|
+
input.resolvedDeliverableSpecs ?? {},
|
|
291
|
+
resolvedRefs,
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
}) as CompiledJob;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const snapshotCreatedAt = input.trigger.triggeredAt;
|
|
298
|
+
const workflowCallOutputs = compileOutputsMap(
|
|
299
|
+
workflow.on.workflow_call?.outputs,
|
|
300
|
+
'on.workflow_call.outputs',
|
|
301
|
+
);
|
|
302
|
+
const workflowOutputs = compileWorkflowOutputs(workflow);
|
|
303
|
+
|
|
304
|
+
const baseCompiled = {
|
|
305
|
+
compiledRunVersion: 1 as const,
|
|
306
|
+
workflowRef,
|
|
307
|
+
trigger,
|
|
308
|
+
inputs,
|
|
309
|
+
vars,
|
|
310
|
+
requiredWallets,
|
|
311
|
+
requiredVariables,
|
|
312
|
+
permissions,
|
|
313
|
+
concurrency,
|
|
314
|
+
defaults,
|
|
315
|
+
jobs: Object.freeze(compiledJobs) as readonly CompiledJob[],
|
|
316
|
+
snapshotCreatedAt,
|
|
317
|
+
workflowDefinitionVersionSha256,
|
|
318
|
+
workflowCallOutputs,
|
|
319
|
+
workflowOutputs,
|
|
320
|
+
// Spread only when present so a run dispatched without a briefcase
|
|
321
|
+
// produces a CompiledRun without the field — keeps snapshots from
|
|
322
|
+
// older runs hash-stable while letting new dispatches carry the
|
|
323
|
+
// briefcase into the run's permanent record.
|
|
324
|
+
...(input.briefcase !== undefined && { briefcase: input.briefcase }),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const snapshotSha256 = canonicalJsonSha256(baseCompiled);
|
|
328
|
+
|
|
329
|
+
const compiled: CompiledRun = {
|
|
330
|
+
...baseCompiled,
|
|
331
|
+
snapshotSha256,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Final shape check: strategy presence vs static cardinality makes
|
|
335
|
+
// sense. Dynamic strategies cannot satisfy permission budgets at compile
|
|
336
|
+
// time — the runtime enforces against the declared maxEntries instead.
|
|
337
|
+
assertStrategyInvariants(compiled);
|
|
338
|
+
|
|
339
|
+
return compiled;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve an action's semantic `ActionKind` from its manifest. Reusable
|
|
344
|
+
* workflows are always `DISPATCH` (their `uses:` resolves to another
|
|
345
|
+
* workflow). Otherwise read the manifest's `spec.actionKind`; any
|
|
346
|
+
* unknown string falls back to `GENERIC` so third-party manifests stay
|
|
347
|
+
* safe with this compiler's closed enum.
|
|
348
|
+
*/
|
|
349
|
+
/**
|
|
350
|
+
* Plan §G shorthand: rewrite a bare `needs.<X>` reference at the top
|
|
351
|
+
* level of an `if:` expression to `needs.<X>.outcome == 'ok'`. Authors
|
|
352
|
+
* write `if: needs.draft` to mean "run if draft succeeded"; without
|
|
353
|
+
* this rewrite the evaluator sees the upstream's envelope object
|
|
354
|
+
* (always truthy) and the gate becomes a silent no-op.
|
|
355
|
+
*
|
|
356
|
+
* Only the BARE form is rewritten — anything compound (comparison,
|
|
357
|
+
* boolean op, function call, payload reach-in) is left alone so
|
|
358
|
+
* authors who want the raw value can still get it.
|
|
359
|
+
*/
|
|
360
|
+
function rewriteBareNeedsInIf(body: string): string {
|
|
361
|
+
let ast: ExpressionNode;
|
|
362
|
+
try {
|
|
363
|
+
ast = compileExpression(body);
|
|
364
|
+
} catch {
|
|
365
|
+
// Defer to the caller's compileExpression — it'll re-throw with the
|
|
366
|
+
// proper DSL error envelope.
|
|
367
|
+
return body;
|
|
368
|
+
}
|
|
369
|
+
const upstreamKey = matchBareNeedsRef(ast);
|
|
370
|
+
if (upstreamKey === null) return body;
|
|
371
|
+
return `needs.${upstreamKey}.outcome == 'ok'`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Match `needs.<X>` as a top-level expression (no descendant fields,
|
|
376
|
+
* no operators). Returns `<X>` on match, `null` otherwise.
|
|
377
|
+
*/
|
|
378
|
+
function matchBareNeedsRef(node: ExpressionNode): string | null {
|
|
379
|
+
if (node.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
380
|
+
const target = node.target;
|
|
381
|
+
if (target.kind !== ExpressionNodeKind.IDENTIFIER) return null;
|
|
382
|
+
if (target.name !== 'needs') return null;
|
|
383
|
+
return node.property;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Resolve a job's Temporal dispatch primitive.
|
|
388
|
+
*
|
|
389
|
+
* For most actions the execution kind is fixed by the manifest
|
|
390
|
+
* (`spec.executionKind`). An action MAY opt into per-job selection by
|
|
391
|
+
* declaring a top-level `executionKind` property in its `inputs:`
|
|
392
|
+
* schema — today only `xema/dispatch-workflow`, which serves both the
|
|
393
|
+
* fire-and-forget (`activity`) and await-completion (`child_workflow`)
|
|
394
|
+
* forms from a single manifest. When opted in, the job's
|
|
395
|
+
* `with.executionKind` overrides the manifest default.
|
|
396
|
+
*
|
|
397
|
+
* The value MUST be a literal — the dispatch primitive is chosen at
|
|
398
|
+
* compile time, so a `${{ }}` expression (which only resolves at
|
|
399
|
+
* dispatch) cannot drive it. Anything other than the two literals
|
|
400
|
+
* fails fast with `DSL_SEMANTIC_INVALID`.
|
|
401
|
+
*/
|
|
402
|
+
function resolveJobExecutionKind(
|
|
403
|
+
jobKey: string,
|
|
404
|
+
withBlock: Readonly<Record<string, unknown>> | undefined,
|
|
405
|
+
resolved: ResolvedRef,
|
|
406
|
+
): ActionExecutionKind {
|
|
407
|
+
const inputs = resolved.actionManifest?.spec.inputs;
|
|
408
|
+
const inputProps =
|
|
409
|
+
inputs && typeof inputs === 'object'
|
|
410
|
+
? (inputs as { properties?: unknown }).properties
|
|
411
|
+
: undefined;
|
|
412
|
+
const declaresExecutionKindInput =
|
|
413
|
+
inputProps !== null &&
|
|
414
|
+
typeof inputProps === 'object' &&
|
|
415
|
+
'executionKind' in (inputProps as Record<string, unknown>);
|
|
416
|
+
if (!declaresExecutionKindInput) {
|
|
417
|
+
return resolved.executionKind;
|
|
418
|
+
}
|
|
419
|
+
const raw = withBlock?.executionKind;
|
|
420
|
+
if (raw === undefined) {
|
|
421
|
+
return resolved.executionKind;
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
raw !== ActionExecutionKind.ACTIVITY &&
|
|
425
|
+
raw !== ActionExecutionKind.CHILD_WORKFLOW
|
|
426
|
+
) {
|
|
427
|
+
throw new WorkflowDslError(
|
|
428
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
429
|
+
`jobs.${jobKey}.with.executionKind must be the literal '${ActionExecutionKind.ACTIVITY}' or '${ActionExecutionKind.CHILD_WORKFLOW}' — the Temporal dispatch primitive is fixed at compile time, so expressions are not allowed.`,
|
|
430
|
+
{ jobKey, executionKind: raw },
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return raw;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveActionKind(resolved: ResolvedRef): ActionKind {
|
|
437
|
+
if (resolved.isReusableWorkflow) return ActionKind.DISPATCH;
|
|
438
|
+
const declared = resolved.actionManifest?.spec.actionKind;
|
|
439
|
+
if (declared === undefined) return ActionKind.GENERIC;
|
|
440
|
+
const known: ReadonlySet<string> = new Set<string>(Object.values(ActionKind));
|
|
441
|
+
return known.has(declared) ? (declared as ActionKind) : ActionKind.GENERIC;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function lookupResolvedRef(
|
|
445
|
+
uses: string,
|
|
446
|
+
resolvedRefs: Readonly<Record<string, ResolvedRef>>,
|
|
447
|
+
): ResolvedRef {
|
|
448
|
+
const ref = resolvedRefs[uses];
|
|
449
|
+
if (!ref) {
|
|
450
|
+
const isReusable = uses.startsWith('xema://workflow/');
|
|
451
|
+
throw new WorkflowDslError(
|
|
452
|
+
isReusable
|
|
453
|
+
? WorkflowErrorCode.DSL_UNKNOWN_REUSABLE_WORKFLOW
|
|
454
|
+
: WorkflowErrorCode.DSL_UNKNOWN_ACTION,
|
|
455
|
+
`Reference '${uses}' is not present in resolvedRefs map passed to compiler.`,
|
|
456
|
+
{ uses },
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return ref;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validateAllAuthoredExpressions(doc: WorkflowDocument): void {
|
|
463
|
+
// Walk every job's with/outputs/strategy.dynamic.from/if for `${{ ... }}`
|
|
464
|
+
// blocks and compile each. Uses extractInterpolations which enforces the
|
|
465
|
+
// "fully wrapped or pure literal" policy and rejects partial interpolation.
|
|
466
|
+
// The concurrency.group template is the one DSL field that opts in to
|
|
467
|
+
// partial interpolation; it has its own validator below.
|
|
468
|
+
for (const [key, job] of Object.entries(doc.jobs)) {
|
|
469
|
+
validateJobExpressions(key, job);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Reusable workflows declare their externally-visible outputs under
|
|
473
|
+
// `on.workflow_call.outputs`. Each value is a `${{ needs.<job>.outputs.* }}`
|
|
474
|
+
// expression evaluated by the parent runtime once the child finishes.
|
|
475
|
+
const callOutputs = doc.on.workflow_call?.outputs;
|
|
476
|
+
if (callOutputs) {
|
|
477
|
+
for (const expr of Object.values(callOutputs)) {
|
|
478
|
+
const body = stripInterpolation(expr);
|
|
479
|
+
compileExpression(body);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
validateConcurrencyGroupTemplate(doc.concurrency);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Walk every job's `with.agentSlug`, `with.reviewers[].agentSlug`, and
|
|
488
|
+
* `with.deliverableSpecRef` entries and assert each literal value
|
|
489
|
+
* resolves against the engine-supplied registry maps.
|
|
490
|
+
*
|
|
491
|
+
* Skips:
|
|
492
|
+
* - Expression-shaped values (`${{ … }}`). Those resolve at dispatch
|
|
493
|
+
* and the activity's preflight (`AGENT_NOT_REGISTERED`) catches
|
|
494
|
+
* unknowns at runtime — the compiler has no way to know what an
|
|
495
|
+
* unbound expression will evaluate to.
|
|
496
|
+
* - Empty maps. The engine signals "skip validation" by passing `{}`
|
|
497
|
+
* (e.g. for `preview-raw` where it cannot reach the registries).
|
|
498
|
+
*
|
|
499
|
+
* Errors are thrown one-at-a-time on the first miss, naming the exact
|
|
500
|
+
* job + field path + offending value so authors fix at the source.
|
|
501
|
+
*/
|
|
502
|
+
function validateAllLiteralReferences(
|
|
503
|
+
doc: WorkflowDocument,
|
|
504
|
+
resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
|
|
505
|
+
resolvedDeliverableSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
|
|
506
|
+
): void {
|
|
507
|
+
const agentValidationEnabled = Object.keys(resolvedAgents).length > 0;
|
|
508
|
+
const specValidationEnabled =
|
|
509
|
+
Object.keys(resolvedDeliverableSpecs).length > 0;
|
|
510
|
+
if (!agentValidationEnabled && !specValidationEnabled) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
for (const [jobKey, job] of Object.entries(doc.jobs)) {
|
|
514
|
+
validateJobLiteralReferences(
|
|
515
|
+
jobKey,
|
|
516
|
+
job,
|
|
517
|
+
agentValidationEnabled ? resolvedAgents : null,
|
|
518
|
+
specValidationEnabled ? resolvedDeliverableSpecs : null,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function validateJobLiteralReferences(
|
|
524
|
+
jobKey: string,
|
|
525
|
+
job: WorkflowDocument['jobs'][string],
|
|
526
|
+
resolvedAgents: Readonly<Record<string, ResolvedAgent>> | null,
|
|
527
|
+
resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>> | null,
|
|
528
|
+
): void {
|
|
529
|
+
const withMap = job.with as Record<string, unknown> | undefined;
|
|
530
|
+
if (withMap) {
|
|
531
|
+
if (resolvedAgents) {
|
|
532
|
+
assertLiteralAgent(
|
|
533
|
+
jobKey,
|
|
534
|
+
'with.agentSlug',
|
|
535
|
+
withMap['agentSlug'],
|
|
536
|
+
resolvedAgents,
|
|
537
|
+
);
|
|
538
|
+
validateReviewerAgents(jobKey, withMap['reviewers'], resolvedAgents);
|
|
539
|
+
}
|
|
540
|
+
if (resolvedSpecs) {
|
|
541
|
+
assertLiteralDeliverableSpec(
|
|
542
|
+
jobKey,
|
|
543
|
+
'with.deliverableSpecRef',
|
|
544
|
+
withMap['deliverableSpecRef'],
|
|
545
|
+
resolvedSpecs,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
assertVersioningMode(jobKey, withMap['versioning']);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function validateReviewerAgents(
|
|
553
|
+
jobKey: string,
|
|
554
|
+
reviewers: unknown,
|
|
555
|
+
resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
|
|
556
|
+
): void {
|
|
557
|
+
if (!Array.isArray(reviewers)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
for (let i = 0; i < reviewers.length; i++) {
|
|
561
|
+
const reviewer = reviewers[i];
|
|
562
|
+
if (
|
|
563
|
+
reviewer === null ||
|
|
564
|
+
typeof reviewer !== 'object' ||
|
|
565
|
+
Array.isArray(reviewer)
|
|
566
|
+
) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
assertLiteralAgent(
|
|
570
|
+
jobKey,
|
|
571
|
+
`with.reviewers[${i}].agentSlug`,
|
|
572
|
+
(reviewer as Record<string, unknown>)['agentSlug'],
|
|
573
|
+
resolvedAgents,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Match the literal/expression policy used by `validateAllAuthoredExpressions`:
|
|
580
|
+
* a value is "literal" when it is a string that does NOT contain `${{`. The
|
|
581
|
+
* full interpolation grammar is enforced separately by the expression pass —
|
|
582
|
+
* here we only care whether the compiler can reasonably know the bound value.
|
|
583
|
+
*/
|
|
584
|
+
function isLiteralStringValue(value: unknown): value is string {
|
|
585
|
+
return typeof value === 'string' && !value.includes('${{');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function assertLiteralAgent(
|
|
589
|
+
jobKey: string,
|
|
590
|
+
fieldPath: string,
|
|
591
|
+
value: unknown,
|
|
592
|
+
resolvedAgents: Readonly<Record<string, ResolvedAgent>>,
|
|
593
|
+
): void {
|
|
594
|
+
if (!isLiteralStringValue(value)) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (resolvedAgents[value] !== undefined) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const knownPreview = formatKnownPreview(Object.keys(resolvedAgents));
|
|
601
|
+
throw new WorkflowDslError(
|
|
602
|
+
WorkflowErrorCode.DSL_UNKNOWN_AGENT,
|
|
603
|
+
`Job '${jobKey}' ${fieldPath} = '${value}' is not a registered agent. Known: [${knownPreview}].`,
|
|
604
|
+
{ jobKey, fieldPath, value, knownCount: Object.keys(resolvedAgents).length },
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function assertLiteralDeliverableSpec(
|
|
609
|
+
jobKey: string,
|
|
610
|
+
fieldPath: string,
|
|
611
|
+
value: unknown,
|
|
612
|
+
resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
|
|
613
|
+
): void {
|
|
614
|
+
if (!isLiteralStringValue(value)) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (resolvedSpecs[value] !== undefined) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const knownPreview = formatKnownPreview(Object.keys(resolvedSpecs));
|
|
621
|
+
throw new WorkflowDslError(
|
|
622
|
+
WorkflowErrorCode.DSL_UNKNOWN_DELIVERABLE_SPEC,
|
|
623
|
+
`Job '${jobKey}' ${fieldPath} = '${value}' is not a registered deliverable spec. Known: [${knownPreview}].`,
|
|
624
|
+
{ jobKey, fieldPath, value, knownCount: Object.keys(resolvedSpecs).length },
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Closed-set validation for `with.versioning`. Mirrors
|
|
630
|
+
* `ArtifactVersioningMode` in `@xemahq/platform-common` — duplicated
|
|
631
|
+
* as a string literal here because workflow-contracts (Layer-1) must
|
|
632
|
+
* not depend on platform-common (Layer-2). Drift between the two is
|
|
633
|
+
* caught by a unit test in the artifact-store-api suite.
|
|
634
|
+
*/
|
|
635
|
+
const VALID_VERSIONING_MODES = ['append', 'new', 'replace'] as const;
|
|
636
|
+
|
|
637
|
+
function assertVersioningMode(jobKey: string, value: unknown): void {
|
|
638
|
+
if (value === undefined || value === null) return;
|
|
639
|
+
// Interpolated expressions land here as strings starting with `${{`;
|
|
640
|
+
// semantics are checked at runtime, so we only reject literal bad
|
|
641
|
+
// values at compile time.
|
|
642
|
+
if (typeof value === 'string' && value.startsWith('${{')) return;
|
|
643
|
+
if (
|
|
644
|
+
typeof value === 'string' &&
|
|
645
|
+
(VALID_VERSIONING_MODES as readonly string[]).includes(value)
|
|
646
|
+
) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
throw new WorkflowDslError(
|
|
650
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
651
|
+
`Job '${jobKey}' with.versioning = ${JSON.stringify(value)} is not a valid ArtifactVersioningMode. Allowed: [${VALID_VERSIONING_MODES.join(', ')}].`,
|
|
652
|
+
{
|
|
653
|
+
jobKey,
|
|
654
|
+
fieldPath: 'with.versioning',
|
|
655
|
+
value: typeof value === 'string' ? value : JSON.stringify(value),
|
|
656
|
+
allowed: [...VALID_VERSIONING_MODES],
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Lexicographic, stable, capped preview of known keys for the
|
|
663
|
+
* "Known: [...]" hint in DSL_UNKNOWN_* error messages. Capped at 12
|
|
664
|
+
* names so a registry of hundreds of agents does not produce a
|
|
665
|
+
* thousand-character error string.
|
|
666
|
+
*/
|
|
667
|
+
function formatKnownPreview(keys: readonly string[]): string {
|
|
668
|
+
if (keys.length === 0) {
|
|
669
|
+
return '(none)';
|
|
670
|
+
}
|
|
671
|
+
const sorted = [...keys].sort((a, b) => a.localeCompare(b));
|
|
672
|
+
if (sorted.length <= 12) {
|
|
673
|
+
return sorted.join(', ');
|
|
674
|
+
}
|
|
675
|
+
return `${sorted.slice(0, 12).join(', ')}, … (+${sorted.length - 12} more)`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function validateJobExpressions(
|
|
679
|
+
key: string,
|
|
680
|
+
job: WorkflowDocument['jobs'][string],
|
|
681
|
+
): void {
|
|
682
|
+
if (job.if !== undefined) {
|
|
683
|
+
const body = stripInterpolation(job.if);
|
|
684
|
+
compileExpression(body);
|
|
685
|
+
}
|
|
686
|
+
if (job.with) {
|
|
687
|
+
const extracted = extractInterpolations(job.with, [key, 'with']);
|
|
688
|
+
for (const ext of extracted) compileExpression(ext.source);
|
|
689
|
+
}
|
|
690
|
+
if (job.outputs) {
|
|
691
|
+
for (const expr of Object.values(job.outputs)) {
|
|
692
|
+
const body = stripInterpolation(expr);
|
|
693
|
+
compileExpression(body);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (job.strategy && 'dynamic' in job.strategy) {
|
|
697
|
+
const body = stripInterpolation(job.strategy.dynamic.from);
|
|
698
|
+
compileExpression(body);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Strip the `${{ ... }}` wrapper from each entry of an authored
|
|
704
|
+
* outputs map and return a frozen body→body map. The compiler's
|
|
705
|
+
* validation pass already compiled each expression, so this never sees
|
|
706
|
+
* a malformed body — but we still call `stripInterpolation` to
|
|
707
|
+
* normalize the storage format the runtime evaluator expects.
|
|
708
|
+
*
|
|
709
|
+
* Empty / missing input → frozen empty object so downstream code can
|
|
710
|
+
* treat the field as a non-nullable map.
|
|
711
|
+
*/
|
|
712
|
+
function compileOutputsMap(
|
|
713
|
+
raw: Readonly<Record<string, string>> | undefined,
|
|
714
|
+
contextLabel: string,
|
|
715
|
+
): Readonly<Record<string, string>> {
|
|
716
|
+
if (!raw || Object.keys(raw).length === 0) {
|
|
717
|
+
return Object.freeze({}) as Readonly<Record<string, string>>;
|
|
718
|
+
}
|
|
719
|
+
const stripped: Record<string, string> = {};
|
|
720
|
+
for (const [name, expr] of Object.entries(raw)) {
|
|
721
|
+
try {
|
|
722
|
+
stripped[name] = stripInterpolation(expr);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
// Re-throw with the explicit field path so authors see exactly
|
|
725
|
+
// which output expression was malformed.
|
|
726
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
727
|
+
throw new WorkflowDslError(
|
|
728
|
+
WorkflowErrorCode.DSL_EXPRESSION_INVALID,
|
|
729
|
+
`${contextLabel}.${name}: ${message}`,
|
|
730
|
+
{ contextLabel, name, expr },
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return Object.freeze(stripped) as Readonly<Record<string, string>>;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Compile + validate the workflow-level `outputs:` block. Each entry
|
|
739
|
+
* is cross-referenced against the workflow's `jobs[fromJob].outputs`
|
|
740
|
+
* map; missing references fail fast at compile time.
|
|
741
|
+
*
|
|
742
|
+
* The frozen result is consumed by biome-host-sdk (mcpWorkflowTools
|
|
743
|
+
* cross-validation), workflow-engine-api (RunResponseDto.deliverables
|
|
744
|
+
* projection), and the future workflow_call composition surfaces.
|
|
745
|
+
*/
|
|
746
|
+
function compileWorkflowOutputs(
|
|
747
|
+
workflow: WorkflowDocument,
|
|
748
|
+
): Readonly<Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>> {
|
|
749
|
+
const raw = workflow.outputs;
|
|
750
|
+
if (!raw || Object.keys(raw).length === 0) {
|
|
751
|
+
return Object.freeze({}) as Readonly<
|
|
752
|
+
Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>
|
|
753
|
+
>;
|
|
754
|
+
}
|
|
755
|
+
const seenSlugs = new Set<string>();
|
|
756
|
+
const compiled: Record<
|
|
757
|
+
string,
|
|
758
|
+
import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor
|
|
759
|
+
> = {};
|
|
760
|
+
for (const [name, decl] of Object.entries(raw)) {
|
|
761
|
+
const job = workflow.jobs[decl.fromJob];
|
|
762
|
+
if (!job) {
|
|
763
|
+
throw new WorkflowDslError(
|
|
764
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
765
|
+
`outputs.${name}: fromJob "${decl.fromJob}" is not a declared job`,
|
|
766
|
+
{ outputName: name, fromJob: decl.fromJob },
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
if (!job.outputs || !(decl.fromOutput in job.outputs)) {
|
|
770
|
+
throw new WorkflowDslError(
|
|
771
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
772
|
+
`outputs.${name}: job "${decl.fromJob}" does not declare an output named "${decl.fromOutput}"`,
|
|
773
|
+
{ outputName: name, fromJob: decl.fromJob, fromOutput: decl.fromOutput },
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (seenSlugs.has(decl.slug)) {
|
|
777
|
+
throw new WorkflowDslError(
|
|
778
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
779
|
+
`outputs.${name}: slug "${decl.slug}" is already used by another output`,
|
|
780
|
+
{ outputName: name, slug: decl.slug },
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
seenSlugs.add(decl.slug);
|
|
784
|
+
if (decl.kind === 'deliverable') {
|
|
785
|
+
compiled[name] = {
|
|
786
|
+
kind: 'deliverable',
|
|
787
|
+
slug: decl.slug,
|
|
788
|
+
fromJob: decl.fromJob,
|
|
789
|
+
fromOutput: decl.fromOutput,
|
|
790
|
+
deliverableSpecRef: decl.deliverableSpecRef,
|
|
791
|
+
...(decl.description ? { description: decl.description } : {}),
|
|
792
|
+
};
|
|
793
|
+
} else if (decl.kind === 'structured') {
|
|
794
|
+
compiled[name] = {
|
|
795
|
+
kind: 'structured',
|
|
796
|
+
slug: decl.slug,
|
|
797
|
+
fromJob: decl.fromJob,
|
|
798
|
+
fromOutput: decl.fromOutput,
|
|
799
|
+
...(decl.schemaRef ? { schemaRef: decl.schemaRef } : {}),
|
|
800
|
+
...(decl.description ? { description: decl.description } : {}),
|
|
801
|
+
};
|
|
802
|
+
} else {
|
|
803
|
+
compiled[name] = {
|
|
804
|
+
kind: 'text',
|
|
805
|
+
slug: decl.slug,
|
|
806
|
+
fromJob: decl.fromJob,
|
|
807
|
+
fromOutput: decl.fromOutput,
|
|
808
|
+
...(decl.description ? { description: decl.description } : {}),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return Object.freeze(compiled) as Readonly<
|
|
813
|
+
Record<string, import('@xemahq/kernel-contracts/workflow').WorkflowOutputDescriptor>
|
|
814
|
+
>;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Enforce the `matrixGather:` invariants up-front, before the topological
|
|
819
|
+
* sort so errors carry the authored jobKey rather than a less useful
|
|
820
|
+
* cycle/unknown-need complaint. Each entry must name a declared job, the
|
|
821
|
+
* target job must itself use a matrix strategy, and self-references are
|
|
822
|
+
* rejected.
|
|
823
|
+
*/
|
|
824
|
+
function validateMatrixGather(doc: WorkflowDocument): void {
|
|
825
|
+
for (const [jobKey, decl] of Object.entries(doc.jobs)) {
|
|
826
|
+
const gather = decl.matrixGather;
|
|
827
|
+
if (!gather || gather.length === 0) continue;
|
|
828
|
+
for (const target of gather) {
|
|
829
|
+
if (target === jobKey) {
|
|
830
|
+
throw new WorkflowDslError(
|
|
831
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
832
|
+
`Job '${jobKey}' matrixGather cannot reference itself.`,
|
|
833
|
+
{ jobKey, target },
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
const targetDecl = doc.jobs[target];
|
|
837
|
+
if (!targetDecl) {
|
|
838
|
+
throw new WorkflowDslError(
|
|
839
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
840
|
+
`Job '${jobKey}' matrixGather references unknown job '${target}'.`,
|
|
841
|
+
{ jobKey, target },
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
if (!targetDecl.strategy || (!('matrix' in targetDecl.strategy) && !('dynamic' in targetDecl.strategy))) {
|
|
845
|
+
throw new WorkflowDslError(
|
|
846
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
847
|
+
`Job '${jobKey}' matrixGather references '${target}' which has no matrix/dynamic strategy; gathering only makes sense across matrix entries.`,
|
|
848
|
+
{ jobKey, target },
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Cross-job shape validator for `needs.<X>.outputs.…` accesses. Catches
|
|
857
|
+
* the entire class of "YAML reads like the intent but runtime sees the
|
|
858
|
+
* wrong shape" bugs at compile time.
|
|
859
|
+
*
|
|
860
|
+
* Rules per upstream `<X>`:
|
|
861
|
+
* • non-matrix → any access OK; skip.
|
|
862
|
+
* • matrix WITHOUT `keyBy` →
|
|
863
|
+
* - `outputs.byKey…` → DSL_MATRIX_KEY_NOT_DECLARED.
|
|
864
|
+
* - `outputs.<word>` where `<word>` is not `length` / `byKey` →
|
|
865
|
+
* DSL_INVALID_MATRIX_OUTPUT_ACCESS (with three-pronged fix-it).
|
|
866
|
+
* - `outputs[<numeric literal>]` → OK (positional access).
|
|
867
|
+
* - bare `outputs` (no further deref) → OK (consumer takes the
|
|
868
|
+
* indexed map as-is).
|
|
869
|
+
* • matrix WITH `keyBy` →
|
|
870
|
+
* - `outputs.byKey[<string-literal>]` → OK.
|
|
871
|
+
* - `outputs.byKey[matrix.<binding>.<keyBy-path>]` → OK iff
|
|
872
|
+
* `<binding>` matches THIS job's matrix binding AND
|
|
873
|
+
* `<keyBy-path>` matches upstream's declared `keyBy`.
|
|
874
|
+
* - any other `outputs.byKey[<expr>]` → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
|
|
875
|
+
* - `outputs.<word>` other than `byKey` / `length` and not numeric
|
|
876
|
+
* index → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
|
|
877
|
+
* - `if:` body cannot use `matrix.*` (binding doesn't exist there)
|
|
878
|
+
* — caught generically by the existing evaluator at runtime;
|
|
879
|
+
* this validator does not duplicate that check.
|
|
880
|
+
*
|
|
881
|
+
* `outputs` and `on.workflow_call.outputs` use `job.outputs.*` /
|
|
882
|
+
* `needs.<X>.outputs.*` against the *child* run, which is identical
|
|
883
|
+
* shape — so the same rules apply.
|
|
884
|
+
*/
|
|
885
|
+
function validateAllNeedsAccessShapes(doc: WorkflowDocument): void {
|
|
886
|
+
for (const [jobKey, job] of Object.entries(doc.jobs)) {
|
|
887
|
+
const consumerBinding = extractConsumerBinding(job);
|
|
888
|
+
const expressions = collectJobExpressions(jobKey, job);
|
|
889
|
+
for (const { source, fieldPath } of expressions) {
|
|
890
|
+
const ast = compileExpression(source);
|
|
891
|
+
walkNeedsAccesses(ast, (access) => {
|
|
892
|
+
validateNeedsAccess(doc, jobKey, fieldPath, consumerBinding, access);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const callOutputs = doc.on.workflow_call?.outputs;
|
|
898
|
+
if (callOutputs) {
|
|
899
|
+
for (const [outputName, expr] of Object.entries(callOutputs)) {
|
|
900
|
+
const ast = compileExpression(stripInterpolation(expr));
|
|
901
|
+
walkNeedsAccesses(ast, (access) => {
|
|
902
|
+
// workflow_call.outputs has no consumer binding (it's a
|
|
903
|
+
// workflow-level output). Pass null and let the validator
|
|
904
|
+
// reject any matrix-binding indices.
|
|
905
|
+
validateNeedsAccess(
|
|
906
|
+
doc,
|
|
907
|
+
'<workflow_call.outputs>',
|
|
908
|
+
`on.workflow_call.outputs.${outputName}`,
|
|
909
|
+
null,
|
|
910
|
+
access,
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
interface CollectedExpression {
|
|
918
|
+
readonly source: string;
|
|
919
|
+
readonly fieldPath: string;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function collectJobExpressions(
|
|
923
|
+
jobKey: string,
|
|
924
|
+
job: WorkflowDocument['jobs'][string],
|
|
925
|
+
): readonly CollectedExpression[] {
|
|
926
|
+
const out: CollectedExpression[] = [];
|
|
927
|
+
if (job.if !== undefined) {
|
|
928
|
+
out.push({ source: stripInterpolation(job.if), fieldPath: `${jobKey}.if` });
|
|
929
|
+
}
|
|
930
|
+
if (job.with) {
|
|
931
|
+
for (const ext of extractInterpolations(job.with, [jobKey, 'with'])) {
|
|
932
|
+
out.push({ source: ext.source, fieldPath: ext.path.join('.') });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (job.outputs) {
|
|
936
|
+
for (const [name, expr] of Object.entries(job.outputs)) {
|
|
937
|
+
out.push({
|
|
938
|
+
source: stripInterpolation(expr),
|
|
939
|
+
fieldPath: `${jobKey}.outputs.${name}`,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (job.strategy && 'dynamic' in job.strategy) {
|
|
944
|
+
out.push({
|
|
945
|
+
source: stripInterpolation(job.strategy.dynamic.from),
|
|
946
|
+
fieldPath: `${jobKey}.strategy.dynamic.from`,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
return out;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The dynamic-matrix `as:` binding name for the current consumer job, or
|
|
954
|
+
* null when the consumer is not a dynamic matrix. Used by the validator
|
|
955
|
+
* to allow `byKey[matrix.<thisBinding>.<keyBy-path>]` indices.
|
|
956
|
+
*
|
|
957
|
+
* Static matrices bind axis names — those don't pair with upstream
|
|
958
|
+
* matrix-output keys (different cardinality semantics) so we don't
|
|
959
|
+
* surface them here.
|
|
960
|
+
*/
|
|
961
|
+
function extractConsumerBinding(
|
|
962
|
+
job: WorkflowDocument['jobs'][string],
|
|
963
|
+
): string | null {
|
|
964
|
+
if (job.strategy && 'dynamic' in job.strategy) {
|
|
965
|
+
return job.strategy.dynamic.as;
|
|
966
|
+
}
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
interface NeedsAccess {
|
|
971
|
+
readonly upstreamJobKey: string;
|
|
972
|
+
/**
|
|
973
|
+
* The chain of accesses *after* `needs.<X>.outputs`. Each step is
|
|
974
|
+
* either `{ kind: 'member', name: string }` (e.g. `.deliverables`,
|
|
975
|
+
* `.byKey`, `.length`) or `{ kind: 'index', node: ExpressionNode }`
|
|
976
|
+
* (e.g. `[0]`, `['cu-1']`, `[matrix.x.id]`). Empty chain means the
|
|
977
|
+
* expression is just `needs.<X>.outputs` (consumer takes the whole
|
|
978
|
+
* shape as-is).
|
|
979
|
+
*/
|
|
980
|
+
readonly steps: readonly AccessStep[];
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
type AccessStep =
|
|
984
|
+
| { readonly kind: 'member'; readonly name: string }
|
|
985
|
+
| { readonly kind: 'index'; readonly node: ExpressionNode };
|
|
986
|
+
|
|
987
|
+
function walkNeedsAccesses(
|
|
988
|
+
node: ExpressionNode,
|
|
989
|
+
visit: (access: NeedsAccess) => void,
|
|
990
|
+
): void {
|
|
991
|
+
// Try to interpret this node as a `needs.<X>.outputs.*` chain. If it
|
|
992
|
+
// is, emit the access and recurse into any index expressions inside
|
|
993
|
+
// it (those can also reference `needs.*`). Otherwise recurse into
|
|
994
|
+
// children generically.
|
|
995
|
+
const access = matchNeedsAccess(node);
|
|
996
|
+
if (access !== null) {
|
|
997
|
+
visit(access);
|
|
998
|
+
for (const step of access.steps) {
|
|
999
|
+
if (step.kind === 'index') {
|
|
1000
|
+
walkNeedsAccesses(step.node, visit);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// Generic recursion for non-needs subtrees.
|
|
1006
|
+
switch (node.kind) {
|
|
1007
|
+
case ExpressionNodeKind.MEMBER:
|
|
1008
|
+
walkNeedsAccesses(node.target, visit);
|
|
1009
|
+
return;
|
|
1010
|
+
case ExpressionNodeKind.INDEX:
|
|
1011
|
+
walkNeedsAccesses(node.target, visit);
|
|
1012
|
+
walkNeedsAccesses(node.index, visit);
|
|
1013
|
+
return;
|
|
1014
|
+
case ExpressionNodeKind.CALL:
|
|
1015
|
+
for (const arg of node.args) walkNeedsAccesses(arg, visit);
|
|
1016
|
+
return;
|
|
1017
|
+
case ExpressionNodeKind.UNARY_NOT:
|
|
1018
|
+
walkNeedsAccesses(node.operand, visit);
|
|
1019
|
+
return;
|
|
1020
|
+
case ExpressionNodeKind.BINARY_EQ:
|
|
1021
|
+
case ExpressionNodeKind.BINARY_NEQ:
|
|
1022
|
+
case ExpressionNodeKind.BINARY_AND:
|
|
1023
|
+
case ExpressionNodeKind.BINARY_OR:
|
|
1024
|
+
walkNeedsAccesses(node.left, visit);
|
|
1025
|
+
walkNeedsAccesses(node.right, visit);
|
|
1026
|
+
return;
|
|
1027
|
+
case ExpressionNodeKind.LITERAL:
|
|
1028
|
+
case ExpressionNodeKind.IDENTIFIER:
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Walk a node that might be an outermost `needs.<X>.outputs.<…>` chain.
|
|
1035
|
+
* Returns null if the chain root is not `needs.<X>.outputs`. The
|
|
1036
|
+
* returned chain is in author order (first access first), regardless
|
|
1037
|
+
* of how the parser built the AST.
|
|
1038
|
+
*/
|
|
1039
|
+
function matchNeedsAccess(node: ExpressionNode): NeedsAccess | null {
|
|
1040
|
+
// Collect the chain by walking down from outermost to innermost,
|
|
1041
|
+
// pushing each step onto a stack we'll reverse at the end.
|
|
1042
|
+
const reverseSteps: AccessStep[] = [];
|
|
1043
|
+
let cursor: ExpressionNode = node;
|
|
1044
|
+
while (
|
|
1045
|
+
cursor.kind === ExpressionNodeKind.MEMBER ||
|
|
1046
|
+
cursor.kind === ExpressionNodeKind.INDEX
|
|
1047
|
+
) {
|
|
1048
|
+
if (cursor.kind === ExpressionNodeKind.MEMBER) {
|
|
1049
|
+
reverseSteps.push({ kind: 'member', name: cursor.property });
|
|
1050
|
+
} else {
|
|
1051
|
+
reverseSteps.push({ kind: 'index', node: cursor.index });
|
|
1052
|
+
}
|
|
1053
|
+
cursor = cursor.target;
|
|
1054
|
+
}
|
|
1055
|
+
// Innermost must be `needs` IDENTIFIER, then the popped chain must
|
|
1056
|
+
// start with member('<jobKey>') member('outputs').
|
|
1057
|
+
if (
|
|
1058
|
+
cursor.kind !== ExpressionNodeKind.IDENTIFIER ||
|
|
1059
|
+
cursor.name !== 'needs'
|
|
1060
|
+
) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
const steps = reverseSteps.toReversed();
|
|
1064
|
+
if (steps.length < 2) return null;
|
|
1065
|
+
const first = steps[0]!;
|
|
1066
|
+
const second = steps[1]!;
|
|
1067
|
+
if (first.kind !== 'member') return null;
|
|
1068
|
+
if (second.kind !== 'member' || second.name !== 'outputs') return null;
|
|
1069
|
+
return {
|
|
1070
|
+
upstreamJobKey: first.name,
|
|
1071
|
+
steps: steps.slice(2),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function validateNeedsAccess(
|
|
1076
|
+
doc: WorkflowDocument,
|
|
1077
|
+
consumerJobKey: string,
|
|
1078
|
+
fieldPath: string,
|
|
1079
|
+
consumerBinding: string | null,
|
|
1080
|
+
access: NeedsAccess,
|
|
1081
|
+
): void {
|
|
1082
|
+
const upstream = doc.jobs[access.upstreamJobKey];
|
|
1083
|
+
if (!upstream) {
|
|
1084
|
+
// Unknown upstream — the topo sort already fails this with a more
|
|
1085
|
+
// useful message. Don't double-report.
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const isDynamicMatrix = !!upstream.strategy && 'dynamic' in upstream.strategy;
|
|
1089
|
+
const isStaticMatrix = !!upstream.strategy && 'matrix' in upstream.strategy;
|
|
1090
|
+
const isMatrix = isDynamicMatrix || isStaticMatrix;
|
|
1091
|
+
if (!isMatrix) {
|
|
1092
|
+
// Single (non-matrix) jobs accept any shape, EXCEPT `byKey[…]` —
|
|
1093
|
+
// single jobs don't have a keyed map. Catch the misuse at compile
|
|
1094
|
+
// time so authors don't get a confusing runtime "Unknown property
|
|
1095
|
+
// byKey" later.
|
|
1096
|
+
if (
|
|
1097
|
+
access.steps.length > 0 &&
|
|
1098
|
+
access.steps[0]!.kind === 'member' &&
|
|
1099
|
+
access.steps[0]!.name === 'byKey'
|
|
1100
|
+
) {
|
|
1101
|
+
throw new WorkflowDslError(
|
|
1102
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1103
|
+
`Job '${consumerJobKey}' ${fieldPath}: cannot read 'byKey' on '${access.upstreamJobKey}' because it is not a matrix job. 'byKey' is only available on matrix jobs that declare 'keyBy:'.`,
|
|
1104
|
+
{
|
|
1105
|
+
consumerJobKey,
|
|
1106
|
+
upstreamJobKey: access.upstreamJobKey,
|
|
1107
|
+
fieldPath,
|
|
1108
|
+
},
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const consumerJob = doc.jobs[consumerJobKey];
|
|
1115
|
+
if (consumerJob?.matrixGather?.includes(access.upstreamJobKey)) {
|
|
1116
|
+
rejectByKeyOnGathered(access, consumerJobKey, fieldPath);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const keyBy = isDynamicMatrix
|
|
1121
|
+
? (upstream.strategy as { dynamic: { keyBy?: string } }).dynamic.keyBy ?? null
|
|
1122
|
+
: null;
|
|
1123
|
+
|
|
1124
|
+
// Bare `needs.X.outputs` is always OK — consumer chooses to receive
|
|
1125
|
+
// the indexed map (or the keyed map; runtime exposes both).
|
|
1126
|
+
if (access.steps.length === 0) return;
|
|
1127
|
+
|
|
1128
|
+
const head = access.steps[0]!;
|
|
1129
|
+
|
|
1130
|
+
if (head.kind === 'member' && head.name === 'byKey') {
|
|
1131
|
+
validateByKeyChain(access, keyBy, consumerJobKey, consumerBinding, fieldPath);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (head.kind === 'member') {
|
|
1136
|
+
// `outputs.length` is part of the indexed-map shape — allowed.
|
|
1137
|
+
if (head.name === 'length') return;
|
|
1138
|
+
// Anything else (`outputs.deliverables`, etc.) is the bug we want
|
|
1139
|
+
// to catch at compile time.
|
|
1140
|
+
throw new WorkflowDslError(
|
|
1141
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1142
|
+
`Job '${consumerJobKey}' ${fieldPath}: cannot read 'needs.${access.upstreamJobKey}.outputs.${head.name}' because '${access.upstreamJobKey}' is a matrix job — its outputs are indexed by entry, not by field. Choose one: (1) read positionally with 'outputs[<n>].${head.name}'; (2) flatten with 'matrixGather: [${access.upstreamJobKey}]' on '${consumerJobKey}' and drop the '.${head.name}' suffix; (3) add 'keyBy: <field>' to '${access.upstreamJobKey}' and use 'outputs.byKey[<key>].${head.name}'.`,
|
|
1143
|
+
{
|
|
1144
|
+
consumerJobKey,
|
|
1145
|
+
upstreamJobKey: access.upstreamJobKey,
|
|
1146
|
+
fieldPath,
|
|
1147
|
+
offendingProperty: head.name,
|
|
1148
|
+
},
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// `head.kind === 'index'` — positional access. Numeric literals are
|
|
1153
|
+
// the canonical use; non-numeric string literals are nonsense on the
|
|
1154
|
+
// indexed map (no key '0' for a string), but a runtime evaluator
|
|
1155
|
+
// already errors clearly there. Don't over-validate.
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function rejectByKeyOnGathered(
|
|
1159
|
+
access: NeedsAccess,
|
|
1160
|
+
consumerJobKey: string,
|
|
1161
|
+
fieldPath: string,
|
|
1162
|
+
): void {
|
|
1163
|
+
const head = access.steps[0];
|
|
1164
|
+
if (head?.kind === 'member' && head.name === 'byKey') {
|
|
1165
|
+
throw new WorkflowDslError(
|
|
1166
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1167
|
+
`Job '${consumerJobKey}' ${fieldPath}: cannot read 'byKey' on '${access.upstreamJobKey}' because the consumer applies 'matrixGather' which flattens the upstream into an array. Drop 'matrixGather' to enable keyed access, or read positional entries via [n].`,
|
|
1168
|
+
{
|
|
1169
|
+
consumerJobKey,
|
|
1170
|
+
upstreamJobKey: access.upstreamJobKey,
|
|
1171
|
+
fieldPath,
|
|
1172
|
+
},
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function validateByKeyChain(
|
|
1178
|
+
access: NeedsAccess,
|
|
1179
|
+
upstreamKeyBy: string | null,
|
|
1180
|
+
consumerJobKey: string,
|
|
1181
|
+
consumerBinding: string | null,
|
|
1182
|
+
fieldPath: string,
|
|
1183
|
+
): void {
|
|
1184
|
+
if (upstreamKeyBy === null) {
|
|
1185
|
+
throw new WorkflowDslError(
|
|
1186
|
+
WorkflowErrorCode.DSL_MATRIX_KEY_NOT_DECLARED,
|
|
1187
|
+
`Job '${consumerJobKey}' ${fieldPath}: cannot read 'needs.${access.upstreamJobKey}.outputs.byKey' because '${access.upstreamJobKey}' has no 'keyBy:' declared on its matrix strategy. Add 'keyBy: <field>' to '${access.upstreamJobKey}.strategy.dynamic' to expose a keyed map.`,
|
|
1188
|
+
{
|
|
1189
|
+
consumerJobKey,
|
|
1190
|
+
upstreamJobKey: access.upstreamJobKey,
|
|
1191
|
+
fieldPath,
|
|
1192
|
+
},
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const indexStep = access.steps[1];
|
|
1196
|
+
if (indexStep?.kind !== 'index') {
|
|
1197
|
+
throw new WorkflowDslError(
|
|
1198
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1199
|
+
`Job '${consumerJobKey}' ${fieldPath}: 'needs.${access.upstreamJobKey}.outputs.byKey' must be followed by '[<key>]'. Use a string literal or 'matrix.<binding>.${upstreamKeyBy}'.`,
|
|
1200
|
+
{
|
|
1201
|
+
consumerJobKey,
|
|
1202
|
+
upstreamJobKey: access.upstreamJobKey,
|
|
1203
|
+
fieldPath,
|
|
1204
|
+
},
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
assertByKeyIndexShape(
|
|
1208
|
+
indexStep.node,
|
|
1209
|
+
upstreamKeyBy,
|
|
1210
|
+
consumerJobKey,
|
|
1211
|
+
consumerBinding,
|
|
1212
|
+
access.upstreamJobKey,
|
|
1213
|
+
fieldPath,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function assertByKeyIndexShape(
|
|
1218
|
+
indexNode: ExpressionNode,
|
|
1219
|
+
upstreamKeyBy: string,
|
|
1220
|
+
consumerJobKey: string,
|
|
1221
|
+
consumerBinding: string | null,
|
|
1222
|
+
upstreamJobKey: string,
|
|
1223
|
+
fieldPath: string,
|
|
1224
|
+
): void {
|
|
1225
|
+
// Acceptable forms:
|
|
1226
|
+
// • LITERAL string → OK
|
|
1227
|
+
// • IDENTIFIER('matrix') + MEMBER chain matching the consumer's
|
|
1228
|
+
// binding name and the upstream's keyBy path → OK
|
|
1229
|
+
// Anything else → DSL_INVALID_MATRIX_OUTPUT_ACCESS.
|
|
1230
|
+
|
|
1231
|
+
if (
|
|
1232
|
+
indexNode.kind === ExpressionNodeKind.LITERAL &&
|
|
1233
|
+
typeof indexNode.value === 'string'
|
|
1234
|
+
) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (
|
|
1239
|
+
indexNode.kind === ExpressionNodeKind.MEMBER ||
|
|
1240
|
+
indexNode.kind === ExpressionNodeKind.INDEX
|
|
1241
|
+
) {
|
|
1242
|
+
const path = collectMatrixMemberPath(indexNode);
|
|
1243
|
+
if (path) {
|
|
1244
|
+
if (consumerBinding === null) {
|
|
1245
|
+
throw new WorkflowDslError(
|
|
1246
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1247
|
+
`Job '${consumerJobKey}' ${fieldPath}: 'matrix.*' indices into 'needs.${upstreamJobKey}.outputs.byKey' require the consumer job to declare its own 'strategy.dynamic'. Use a string literal key instead, or convert '${consumerJobKey}' into a dynamic matrix.`,
|
|
1248
|
+
{ consumerJobKey, upstreamJobKey, fieldPath },
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
if (path.binding !== consumerBinding) {
|
|
1252
|
+
throw new WorkflowDslError(
|
|
1253
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1254
|
+
`Job '${consumerJobKey}' ${fieldPath}: 'matrix.${path.binding}' is not bound here — '${consumerJobKey}' binds 'matrix.${consumerBinding}'.`,
|
|
1255
|
+
{ consumerJobKey, upstreamJobKey, fieldPath },
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
if (path.path !== upstreamKeyBy) {
|
|
1259
|
+
throw new WorkflowDslError(
|
|
1260
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1261
|
+
`Job '${consumerJobKey}' ${fieldPath}: 'byKey' index reads 'matrix.${path.binding}.${path.path}' but '${upstreamJobKey}' is keyed by '${upstreamKeyBy}'. Match the upstream's 'keyBy:' path or change the upstream's 'keyBy:' to '${path.path}'.`,
|
|
1262
|
+
{ consumerJobKey, upstreamJobKey, fieldPath },
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
throw new WorkflowDslError(
|
|
1270
|
+
WorkflowErrorCode.DSL_INVALID_MATRIX_OUTPUT_ACCESS,
|
|
1271
|
+
`Job '${consumerJobKey}' ${fieldPath}: 'needs.${upstreamJobKey}.outputs.byKey[…]' index must be a string literal or 'matrix.<binding>.${upstreamKeyBy}'.`,
|
|
1272
|
+
{ consumerJobKey, upstreamJobKey, fieldPath },
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Collapse a chain like `matrix.changeUnit.metadata.id` into
|
|
1278
|
+
* `{ binding: 'changeUnit', path: 'metadata.id' }`. Returns null if the
|
|
1279
|
+
* chain root isn't `matrix.<binding>` or contains an INDEX step (we
|
|
1280
|
+
* only support pure dotted paths in `keyBy`).
|
|
1281
|
+
*/
|
|
1282
|
+
function collectMatrixMemberPath(
|
|
1283
|
+
node: ExpressionNode,
|
|
1284
|
+
): { readonly binding: string; readonly path: string } | null {
|
|
1285
|
+
const reverseProperties: string[] = [];
|
|
1286
|
+
let cursor: ExpressionNode = node;
|
|
1287
|
+
while (cursor.kind === ExpressionNodeKind.MEMBER) {
|
|
1288
|
+
reverseProperties.push(cursor.property);
|
|
1289
|
+
cursor = cursor.target;
|
|
1290
|
+
}
|
|
1291
|
+
if (
|
|
1292
|
+
cursor.kind !== ExpressionNodeKind.IDENTIFIER ||
|
|
1293
|
+
cursor.name !== 'matrix'
|
|
1294
|
+
) {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
const props = reverseProperties.toReversed();
|
|
1298
|
+
if (props.length < 2) return null;
|
|
1299
|
+
const [binding, ...rest] = props;
|
|
1300
|
+
return { binding: binding!, path: rest.join('.') };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function assertStrategyInvariants(compiled: CompiledRun): void {
|
|
1304
|
+
for (const job of compiled.jobs) {
|
|
1305
|
+
if (job.strategy.kind === MatrixStrategyKind.STATIC && job.strategy.entries.length === 0) {
|
|
1306
|
+
throw new WorkflowDslError(
|
|
1307
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
1308
|
+
`Job '${job.jobKey}' static strategy expansion produced zero entries.`,
|
|
1309
|
+
{ jobKey: job.jobKey },
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Permission escalation is already checked per-job. Cycle checked in DAG.
|
|
1315
|
+
// No-op placeholder kept explicit for the next author.
|
|
1316
|
+
const _permissionRef: Readonly<Record<PermissionResource, PermissionScope>> = compiled.permissions;
|
|
1317
|
+
void _permissionRef;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Walk every authored expression and field-check
|
|
1322
|
+
* `<root>.outputs.deliverable.content.value.<field>` accesses (the JSON
|
|
1323
|
+
* payload for `JSON_SCHEMA` / `ZOD_SCHEMA` / `STRUCTURED_JSON` kinds).
|
|
1324
|
+
* `<field>` must be one of the producing job's spec `topLevelKeys` when
|
|
1325
|
+
* the engine pre-fetched them. Other content paths (`pages`, `text`,
|
|
1326
|
+
* `files`, `payload`, `manifestPath`) are kind-discriminated and the
|
|
1327
|
+
* activity-side harvester enforces them at runtime.
|
|
1328
|
+
*/
|
|
1329
|
+
function validateDeliverableValueExpressions(
|
|
1330
|
+
doc: WorkflowDocument,
|
|
1331
|
+
resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
|
|
1332
|
+
): void {
|
|
1333
|
+
if (Object.keys(resolvedSpecs).length === 0) return;
|
|
1334
|
+
|
|
1335
|
+
for (const [jobKey, job] of Object.entries(doc.jobs)) {
|
|
1336
|
+
const expressions = collectJobExpressions(jobKey, job);
|
|
1337
|
+
for (const { source, fieldPath } of expressions) {
|
|
1338
|
+
const ast = compileExpression(source);
|
|
1339
|
+
walkDeliverableAccesses(ast, (access) => {
|
|
1340
|
+
validateDeliverableAccess(doc, jobKey, fieldPath, access, resolvedSpecs);
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const callOutputs = doc.on.workflow_call?.outputs;
|
|
1346
|
+
if (callOutputs) {
|
|
1347
|
+
for (const [outputName, expr] of Object.entries(callOutputs)) {
|
|
1348
|
+
const ast = compileExpression(stripInterpolation(expr));
|
|
1349
|
+
walkDeliverableAccesses(ast, (access) => {
|
|
1350
|
+
validateDeliverableAccess(
|
|
1351
|
+
doc,
|
|
1352
|
+
'<workflow_call.outputs>',
|
|
1353
|
+
`on.workflow_call.outputs.${outputName}`,
|
|
1354
|
+
access,
|
|
1355
|
+
resolvedSpecs,
|
|
1356
|
+
);
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
interface ValueFieldDeliverableAccess {
|
|
1363
|
+
readonly upstreamJobKey: string | null;
|
|
1364
|
+
readonly field: string;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function walkDeliverableAccesses(
|
|
1368
|
+
node: ExpressionNode,
|
|
1369
|
+
visit: (access: ValueFieldDeliverableAccess) => void,
|
|
1370
|
+
): void {
|
|
1371
|
+
const valueField = matchDeliverableValueFieldAccess(node);
|
|
1372
|
+
if (valueField !== null) {
|
|
1373
|
+
visit(valueField);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
switch (node.kind) {
|
|
1377
|
+
case ExpressionNodeKind.MEMBER:
|
|
1378
|
+
walkDeliverableAccesses(node.target, visit);
|
|
1379
|
+
return;
|
|
1380
|
+
case ExpressionNodeKind.INDEX:
|
|
1381
|
+
walkDeliverableAccesses(node.target, visit);
|
|
1382
|
+
walkDeliverableAccesses(node.index, visit);
|
|
1383
|
+
return;
|
|
1384
|
+
case ExpressionNodeKind.CALL:
|
|
1385
|
+
for (const arg of node.args) walkDeliverableAccesses(arg, visit);
|
|
1386
|
+
return;
|
|
1387
|
+
case ExpressionNodeKind.UNARY_NOT:
|
|
1388
|
+
walkDeliverableAccesses(node.operand, visit);
|
|
1389
|
+
return;
|
|
1390
|
+
case ExpressionNodeKind.BINARY_EQ:
|
|
1391
|
+
case ExpressionNodeKind.BINARY_NEQ:
|
|
1392
|
+
case ExpressionNodeKind.BINARY_AND:
|
|
1393
|
+
case ExpressionNodeKind.BINARY_OR:
|
|
1394
|
+
walkDeliverableAccesses(node.left, visit);
|
|
1395
|
+
walkDeliverableAccesses(node.right, visit);
|
|
1396
|
+
return;
|
|
1397
|
+
case ExpressionNodeKind.LITERAL:
|
|
1398
|
+
case ExpressionNodeKind.IDENTIFIER:
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Match `<root>.outputs.deliverable.content.value.<field>`. `<root>` is
|
|
1405
|
+
* `job` (same-job projection), `needs.<jobKey>` (cross-job), or
|
|
1406
|
+
* `needs.<jobKey>.outputs.byKey[<...>]` (matrix-keyed cross-job — handled
|
|
1407
|
+
* structurally by walking through the byKey index).
|
|
1408
|
+
*/
|
|
1409
|
+
function matchDeliverableValueFieldAccess(
|
|
1410
|
+
node: ExpressionNode,
|
|
1411
|
+
): ValueFieldDeliverableAccess | null {
|
|
1412
|
+
if (node.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
1413
|
+
const field = node.property;
|
|
1414
|
+
|
|
1415
|
+
const valueNode = node.target;
|
|
1416
|
+
if (valueNode.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
1417
|
+
if (valueNode.property !== 'value') return null;
|
|
1418
|
+
|
|
1419
|
+
const contentNode = valueNode.target;
|
|
1420
|
+
if (contentNode.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
1421
|
+
if (contentNode.property !== 'content') return null;
|
|
1422
|
+
|
|
1423
|
+
const deliverableNode = contentNode.target;
|
|
1424
|
+
if (deliverableNode.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
1425
|
+
if (deliverableNode.property !== 'deliverable') return null;
|
|
1426
|
+
|
|
1427
|
+
const outputsNode = deliverableNode.target;
|
|
1428
|
+
if (outputsNode.kind !== ExpressionNodeKind.MEMBER) return null;
|
|
1429
|
+
if (outputsNode.property !== 'outputs') return null;
|
|
1430
|
+
|
|
1431
|
+
const rootNode = outputsNode.target;
|
|
1432
|
+
if (
|
|
1433
|
+
rootNode.kind === ExpressionNodeKind.IDENTIFIER &&
|
|
1434
|
+
rootNode.name === 'job'
|
|
1435
|
+
) {
|
|
1436
|
+
return { upstreamJobKey: null, field };
|
|
1437
|
+
}
|
|
1438
|
+
if (rootNode.kind === ExpressionNodeKind.MEMBER) {
|
|
1439
|
+
const needsRoot = rootNode.target;
|
|
1440
|
+
if (
|
|
1441
|
+
needsRoot.kind === ExpressionNodeKind.IDENTIFIER &&
|
|
1442
|
+
needsRoot.name === 'needs'
|
|
1443
|
+
) {
|
|
1444
|
+
return {
|
|
1445
|
+
upstreamJobKey: rootNode.property,
|
|
1446
|
+
field,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
// `needs.<X>.outputs.byKey[<...>].deliverable.content.value.<f>` —
|
|
1450
|
+
// walk through the index node.
|
|
1451
|
+
if (needsRoot.kind === ExpressionNodeKind.MEMBER) {
|
|
1452
|
+
const upstreamRoot = needsRoot.target;
|
|
1453
|
+
if (
|
|
1454
|
+
needsRoot.property === 'outputs' &&
|
|
1455
|
+
upstreamRoot.kind === ExpressionNodeKind.MEMBER
|
|
1456
|
+
) {
|
|
1457
|
+
// outputs.byKey[<...>] — outputs.target is needs.<X>
|
|
1458
|
+
const needsParent = upstreamRoot.target;
|
|
1459
|
+
if (
|
|
1460
|
+
upstreamRoot.property === 'byKey' &&
|
|
1461
|
+
needsParent.kind === ExpressionNodeKind.IDENTIFIER &&
|
|
1462
|
+
needsParent.name === 'needs'
|
|
1463
|
+
) {
|
|
1464
|
+
// outputsNode would actually be the index node; this branch
|
|
1465
|
+
// is only reachable via the INDEX path in walkDeliverableAccesses.
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function validateDeliverableAccess(
|
|
1475
|
+
doc: WorkflowDocument,
|
|
1476
|
+
consumerJobKey: string,
|
|
1477
|
+
fieldPath: string,
|
|
1478
|
+
access: ValueFieldDeliverableAccess,
|
|
1479
|
+
resolvedSpecs: Readonly<Record<string, ResolvedDeliverableSpec>>,
|
|
1480
|
+
): void {
|
|
1481
|
+
// value-field access: validate against producing job's spec when
|
|
1482
|
+
// introspectable.
|
|
1483
|
+
const producingJobKey = access.upstreamJobKey ?? consumerJobKey;
|
|
1484
|
+
const producingJob = doc.jobs[producingJobKey];
|
|
1485
|
+
if (!producingJob) return;
|
|
1486
|
+
|
|
1487
|
+
if (access.upstreamJobKey !== null) {
|
|
1488
|
+
// Explicit `outputs:` projection re-shapes the namespace; skip.
|
|
1489
|
+
if (producingJob.outputs && Object.keys(producingJob.outputs).length > 0) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const withMap = producingJob.with as Record<string, unknown> | undefined;
|
|
1495
|
+
const specRef = withMap?.['deliverableSpecRef'];
|
|
1496
|
+
if (typeof specRef !== 'string' || specRef.length === 0) return;
|
|
1497
|
+
// Skip expression-shaped refs — the spec is unknown until dispatch.
|
|
1498
|
+
if (specRef.includes('${{')) return;
|
|
1499
|
+
|
|
1500
|
+
const spec = resolvedSpecs[specRef];
|
|
1501
|
+
if (!spec) return;
|
|
1502
|
+
if (!spec.topLevelKeys || spec.topLevelKeys.length === 0) return;
|
|
1503
|
+
if (spec.topLevelKeys.includes(access.field)) return;
|
|
1504
|
+
|
|
1505
|
+
const knownPreview = formatKnownPreview(spec.topLevelKeys);
|
|
1506
|
+
throw new WorkflowDslError(
|
|
1507
|
+
WorkflowErrorCode.DSL_UNKNOWN_DELIVERABLE_FIELD,
|
|
1508
|
+
`${fieldPath}: deliverable.content.value.${access.field} is not a top-level field on deliverable spec '${spec.ref}' (produced by job '${producingJobKey}'). Known fields: [${knownPreview}].`,
|
|
1509
|
+
{
|
|
1510
|
+
consumerJobKey,
|
|
1511
|
+
fieldPath,
|
|
1512
|
+
producingJobKey,
|
|
1513
|
+
specRef: spec.ref,
|
|
1514
|
+
field: access.field,
|
|
1515
|
+
knownFields: [...spec.topLevelKeys],
|
|
1516
|
+
},
|
|
1517
|
+
);
|
|
1518
|
+
}
|