@intentius/chant 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +93 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +14 -2
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +103 -2
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +45 -9
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate mode's Op counterpart (#927): synthesize scheduled CI for
|
|
3
|
+
* stateless Ops — the cron-triggered sibling of `generateComponentsPipeline`
|
|
4
|
+
* (../components/cli-support.ts, #563/#688). A component pipeline triggers a
|
|
5
|
+
* deploy-time graph on `workflow_dispatch`; this triggers already-built Ops
|
|
6
|
+
* on a cron, each via `chant run <name>` on the local executor — the same
|
|
7
|
+
* one-shot invocation documented at docs/src/content/docs/guide/ops.mdx. Core
|
|
8
|
+
* owns only Op discovery, so a typo'd name fails fast before any CI is
|
|
9
|
+
* generated; the CI-specific synthesis is contributed by the target
|
|
10
|
+
* lexicon's `generateOpPipeline` (../lexicon.ts), exactly like
|
|
11
|
+
* `generateComponentPipeline`.
|
|
12
|
+
*
|
|
13
|
+
* There is no dependency graph between scheduled Ops the way there is
|
|
14
|
+
* between components — `Op.depends` orders phases within one run, not
|
|
15
|
+
* separately scheduled cron triggers — so this does not call
|
|
16
|
+
* `resolveComponentGraph`; each `ScheduledOpSpec` is generated independently.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { discoverOps } from "./discover";
|
|
20
|
+
import {
|
|
21
|
+
isLexiconPlugin,
|
|
22
|
+
type LexiconPlugin,
|
|
23
|
+
type ComponentPipelineOptions,
|
|
24
|
+
type ScheduledOpSpec,
|
|
25
|
+
type OpPipelineFile,
|
|
26
|
+
type OpPipelineJob,
|
|
27
|
+
} from "../lexicon";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load a lexicon's plugin (`@intentius/chant-lexicon-<name>`) to reach its
|
|
31
|
+
* `generateOpPipeline`. Tolerant: returns null when the package can't be
|
|
32
|
+
* resolved. Kept local (rather than imported from
|
|
33
|
+
* `../components/cli-support.ts`) so this module carries no dependency on
|
|
34
|
+
* `components/`.
|
|
35
|
+
*/
|
|
36
|
+
async function loadLexiconPlugin(name: string): Promise<LexiconPlugin | null> {
|
|
37
|
+
let mod: Record<string, unknown>;
|
|
38
|
+
try {
|
|
39
|
+
mod = (await import(`@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
for (const value of Object.values(mod)) {
|
|
44
|
+
if (isLexiconPlugin(value)) return value;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Result of generating scheduled CI for a set of Ops (generate mode's Op counterpart, #927). */
|
|
50
|
+
export interface GenerateOpsPipelineResult {
|
|
51
|
+
success: boolean;
|
|
52
|
+
/** One generated CI file per scheduled Op, when `success` is true. */
|
|
53
|
+
files?: OpPipelineFile[];
|
|
54
|
+
/** Every generated job, for a machine-readable view. */
|
|
55
|
+
jobs?: OpPipelineJob[];
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Synthesize scheduled CI for `ops` targeting `lexicon`: validate every named
|
|
61
|
+
* Op exists (`discoverOps`, under the git root — mirrors `chant run <name>`'s
|
|
62
|
+
* own resolution), then dispatch to the lexicon's `generateOpPipeline`. No
|
|
63
|
+
* deploy/audit logic is inlined into the generated YAML — the finding-mode
|
|
64
|
+
* behavior lives in the Op's own activity args, set by the composite that
|
|
65
|
+
* built it (`WorkflowAuditOp`, `PipelineAuditOp`, `ReconcileOp`, …); this
|
|
66
|
+
* function only wires the cron trigger and the token/permission surface the
|
|
67
|
+
* declared `findingMode` needs.
|
|
68
|
+
*/
|
|
69
|
+
export async function generateOpsPipeline(
|
|
70
|
+
ops: ScheduledOpSpec[],
|
|
71
|
+
lexicon: string,
|
|
72
|
+
options?: ComponentPipelineOptions,
|
|
73
|
+
cwd?: string,
|
|
74
|
+
): Promise<GenerateOpsPipelineResult> {
|
|
75
|
+
const plugin = await loadLexiconPlugin(lexicon);
|
|
76
|
+
if (!plugin?.generateOpPipeline) {
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
error: `Lexicon "${lexicon}" does not support Op generate mode (no generateOpPipeline). GitLab, GitHub, and Forgejo are supported today.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const discovered = await discoverOps({ cwd });
|
|
84
|
+
if (discovered.errors.length > 0) {
|
|
85
|
+
return { success: false, error: discovered.errors.join("\n") };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unknown = ops.filter((spec) => !discovered.ops.has(spec.name));
|
|
89
|
+
if (unknown.length > 0) {
|
|
90
|
+
const known = [...discovered.ops.keys()].sort().join(", ");
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
error: `Unknown Op(s): ${unknown.map((s) => s.name).join(", ")}.${known ? ` Known Ops: ${known}` : " No Ops discovered."}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { files, jobs } = plugin.generateOpPipeline(ops, options);
|
|
98
|
+
return { success: true, files, jobs };
|
|
99
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -23,8 +23,38 @@ export type {
|
|
|
23
23
|
} from "./receipt-store";
|
|
24
24
|
export { discoverOps } from "./discover";
|
|
25
25
|
export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
|
26
|
+
export { generateOpsPipeline } from "./generate-pipeline";
|
|
27
|
+
export type { GenerateOpsPipelineResult } from "./generate-pipeline";
|
|
26
28
|
export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
|
|
27
29
|
export type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
28
30
|
export { runOpLocally, parseDuration, findGate, LocalGateUnsupportedError, OpRunFailure } from "./local-executor";
|
|
29
31
|
export type { StepRecord, OpRunResult } from "./local-executor";
|
|
30
32
|
export { renderHuman, renderJson } from "./local-output";
|
|
33
|
+
export {
|
|
34
|
+
activityContract, isActivityContract, collectActivityContracts, validateActivitySteps, KNOWN_ACTIVITY_PROFILES,
|
|
35
|
+
pathExistsInSchema,
|
|
36
|
+
} from "./activity-contract";
|
|
37
|
+
export type { ActivityContract, ActivityContractIssue } from "./activity-contract";
|
|
38
|
+
export { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope, makeOutProxy } from "./step-output-ref";
|
|
39
|
+
export type { StepOutputRef, WithStepRefs } from "./step-output-ref";
|
|
40
|
+
export type { NamedActivityStep } from "./builders";
|
|
41
|
+
export {
|
|
42
|
+
eq, neq, gt, gte, lt, lte, truthy, falsy, allOf, anyOf,
|
|
43
|
+
evaluatePredicate, isWellFormedPredicate, predicateReferencesField,
|
|
44
|
+
run, report,
|
|
45
|
+
when, duplicateRuleIds,
|
|
46
|
+
DEFAULT_FLAP_THRESHOLD,
|
|
47
|
+
} from "./converge-rule";
|
|
48
|
+
export type {
|
|
49
|
+
FieldComparisonOp, FieldTruthinessOp,
|
|
50
|
+
FieldComparisonPredicate, FieldTruthinessPredicate, AllOfPredicate, AnyOfPredicate, SymptomPredicate,
|
|
51
|
+
RunAction, ReportAction, RuleAction, ConvergeRule,
|
|
52
|
+
} from "./converge-rule";
|
|
53
|
+
export {
|
|
54
|
+
discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
|
|
55
|
+
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
56
|
+
} from "./operator";
|
|
57
|
+
export type { OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions } from "./operator";
|
|
58
|
+
export { classifyOpVerbClass, isGated } from "./op-verb-class";
|
|
59
|
+
export type { OpVerbClass } from "./op-verb-class";
|
|
60
|
+
export { takeProfileAndId } from "./builders";
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
LocalGateUnsupportedError,
|
|
9
9
|
OpRunFailure,
|
|
10
10
|
} from "./local-executor";
|
|
11
|
+
import { stepOutput } from "./step-output-ref";
|
|
11
12
|
|
|
12
13
|
// Fast profiles so retry/timeout tests run in milliseconds.
|
|
13
14
|
const PROFILES: Record<string, ActivityProfile> = {
|
|
@@ -261,3 +262,94 @@ describe("runOpLocally — gate rejection", () => {
|
|
|
261
262
|
expect(gated?.signalName).toBe("g");
|
|
262
263
|
});
|
|
263
264
|
});
|
|
265
|
+
|
|
266
|
+
describe("runOpLocally — step-output references (#1290)", () => {
|
|
267
|
+
test("a later step receives the producer's resolved value, not the placeholder", async () => {
|
|
268
|
+
const received: unknown[] = [];
|
|
269
|
+
const activities = new Map<string, ActivityFn>([
|
|
270
|
+
["produce", async () => ({ driftedStacks: ["a", "b"], count: 2 })],
|
|
271
|
+
["consume", async (args) => { received.push(args); }],
|
|
272
|
+
]);
|
|
273
|
+
const config = op({
|
|
274
|
+
phases: [{ name: "P", steps: [
|
|
275
|
+
{ kind: "activity", fn: "produce", id: "diff" },
|
|
276
|
+
{ kind: "activity", fn: "consume", args: { stacks: stepOutput("diff", "driftedStacks") } },
|
|
277
|
+
] }],
|
|
278
|
+
});
|
|
279
|
+
const result = await runOpLocally(config, activities, PROFILES);
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
expect(received).toEqual([{ stacks: ["a", "b"] }]);
|
|
282
|
+
// The recorded step also shows the resolved value, not the raw ref object.
|
|
283
|
+
expect(result.records[1].args).toEqual({ stacks: ["a", "b"] });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a whole-value reference (no path) passes the entire producer result", async () => {
|
|
287
|
+
const received: unknown[] = [];
|
|
288
|
+
const activities = new Map<string, ActivityFn>([
|
|
289
|
+
["produce", async () => ({ ok: true, detail: "x" })],
|
|
290
|
+
["consume", async (args) => { received.push(args); }],
|
|
291
|
+
]);
|
|
292
|
+
const config = op({
|
|
293
|
+
phases: [{ name: "P", steps: [
|
|
294
|
+
{ kind: "activity", fn: "produce", id: "p" },
|
|
295
|
+
{ kind: "activity", fn: "consume", args: { result: stepOutput("p") } },
|
|
296
|
+
] }],
|
|
297
|
+
});
|
|
298
|
+
await runOpLocally(config, activities, PROFILES);
|
|
299
|
+
expect(received).toEqual([{ result: { ok: true, detail: "x" } }]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("resolves a reference nested inside an object and an array", async () => {
|
|
303
|
+
const received: unknown[] = [];
|
|
304
|
+
const activities = new Map<string, ActivityFn>([
|
|
305
|
+
["produce", async () => ({ name: "prod" })],
|
|
306
|
+
["consume", async (args) => { received.push(args); }],
|
|
307
|
+
]);
|
|
308
|
+
const config = op({
|
|
309
|
+
phases: [{ name: "P", steps: [
|
|
310
|
+
{ kind: "activity", fn: "produce", id: "p" },
|
|
311
|
+
{
|
|
312
|
+
kind: "activity",
|
|
313
|
+
fn: "consume",
|
|
314
|
+
args: { config: { tags: ["static", stepOutput("p", "name")] } },
|
|
315
|
+
},
|
|
316
|
+
] }],
|
|
317
|
+
});
|
|
318
|
+
await runOpLocally(config, activities, PROFILES);
|
|
319
|
+
expect(received).toEqual([{ config: { tags: ["static", "prod"] } }]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("an unresolvable path resolves to undefined instead of throwing", async () => {
|
|
323
|
+
const received: unknown[] = [];
|
|
324
|
+
const activities = new Map<string, ActivityFn>([
|
|
325
|
+
["produce", async () => ({})],
|
|
326
|
+
["consume", async (args) => { received.push(args); }],
|
|
327
|
+
]);
|
|
328
|
+
const config = op({
|
|
329
|
+
phases: [{ name: "P", steps: [
|
|
330
|
+
{ kind: "activity", fn: "produce", id: "p" },
|
|
331
|
+
{ kind: "activity", fn: "consume", args: { v: stepOutput("p", "a.b") } },
|
|
332
|
+
] }],
|
|
333
|
+
});
|
|
334
|
+
const result = await runOpLocally(config, activities, PROFILES);
|
|
335
|
+
expect(result.ok).toBe(true);
|
|
336
|
+
expect(received).toEqual([{ v: undefined }]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("e2e: chant run (non-temporal) end to end — producer object flows to consumer", async () => {
|
|
340
|
+
const activities = new Map<string, ActivityFn>([
|
|
341
|
+
["lifecycleDiff", async () => ({ driftedStacks: ["stack-a"], drifted: true })],
|
|
342
|
+
["applyStacks", async (args) => ({ applied: args.stacks })],
|
|
343
|
+
]);
|
|
344
|
+
const config = op({
|
|
345
|
+
phases: [
|
|
346
|
+
{ name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "diff", args: { env: "prod" } }] },
|
|
347
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "applyStacks", args: { stacks: stepOutput("diff", "driftedStacks") } }] },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
const result = await runOpLocally(config, activities, PROFILES);
|
|
351
|
+
expect(result.ok).toBe(true);
|
|
352
|
+
expect(result.records.every((r) => r.status === "ok")).toBe(true);
|
|
353
|
+
expect(result.records[1].args).toEqual({ stacks: ["stack-a"] });
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/op/local-executor.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
|
|
15
15
|
import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
|
|
16
16
|
import type { ReceiptReadResult } from "./receipt-store";
|
|
17
|
+
import { isStepOutputRef } from "./step-output-ref";
|
|
17
18
|
|
|
18
19
|
// ── Records ─────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -94,6 +95,25 @@ function resolvePath(value: unknown, path?: string): unknown {
|
|
|
94
95
|
);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Deep-walk `value`, replacing every {@link StepOutputRef} with the recorded
|
|
100
|
+
* result of its producer step, resolved through the ref's optional dot-path
|
|
101
|
+
* exactly like the serializer's compiled `__rN?.path?.segments` (chant #1290)
|
|
102
|
+
* — `resolvePath` gives the same "undefined intermediate resolves to
|
|
103
|
+
* undefined, never throws" semantics. A reference to a step whose result
|
|
104
|
+
* isn't in `resultsById` (never ran, or ran out of the scope
|
|
105
|
+
* `validateStepOutputRefs` allows) resolves to `undefined`, the same way a
|
|
106
|
+
* producer whose declared field is absent would.
|
|
107
|
+
*/
|
|
108
|
+
function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string, unknown>): unknown {
|
|
109
|
+
if (isStepOutputRef(value)) return resolvePath(resultsById.get(value.step), value.path);
|
|
110
|
+
if (Array.isArray(value)) return value.map((v) => resolveStepOutputRefs(v, resultsById));
|
|
111
|
+
if (value && typeof value === "object") {
|
|
112
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveStepOutputRefs(v, resultsById)]));
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
/** Find the first gate step anywhere in the Op (phases + onFailure, including
|
|
98
118
|
* gates nested inside effect steps), if any. */
|
|
99
119
|
export function findGate(config: OpConfig): GateStep | undefined {
|
|
@@ -160,15 +180,22 @@ interface RanStep {
|
|
|
160
180
|
result?: unknown;
|
|
161
181
|
}
|
|
162
182
|
|
|
163
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Run one activity step with retry + timeout. Never throws — returns a
|
|
185
|
+
* record. Any {@link StepOutputRef} in `step.args` is resolved against
|
|
186
|
+
* `resultsById` before the activity is called (#1290) — the local executor
|
|
187
|
+
* is a first-class peer of the Temporal path, so an unresolved placeholder
|
|
188
|
+
* must never reach an activity function; see `resolveStepOutputRefs`.
|
|
189
|
+
*/
|
|
164
190
|
async function runStep(
|
|
165
191
|
step: ActivityStep,
|
|
166
192
|
phaseName: string,
|
|
167
193
|
activities: Map<string, ActivityFn>,
|
|
168
194
|
profiles: Record<string, ActivityProfile>,
|
|
195
|
+
resultsById: Map<string, unknown>,
|
|
169
196
|
signal?: AbortSignal,
|
|
170
197
|
): Promise<RanStep> {
|
|
171
|
-
const args = step.args ?? {}
|
|
198
|
+
const args = resolveStepOutputRefs(step.args ?? {}, resultsById) as Record<string, unknown>;
|
|
172
199
|
const base = { phase: phaseName, fn: step.fn, args };
|
|
173
200
|
const start = Date.now();
|
|
174
201
|
|
|
@@ -198,6 +225,7 @@ async function runStep(
|
|
|
198
225
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
199
226
|
try {
|
|
200
227
|
const result = await callWithTimeout(fn, args, timeoutMs, signal);
|
|
228
|
+
if (step.id) resultsById.set(step.id, result);
|
|
201
229
|
const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
|
|
202
230
|
if (step.outcomeAttribute) {
|
|
203
231
|
record.outcome = {
|
|
@@ -258,11 +286,12 @@ async function runEffectStep(
|
|
|
258
286
|
phaseName: string,
|
|
259
287
|
activities: Map<string, ActivityFn>,
|
|
260
288
|
profiles: Record<string, ActivityProfile>,
|
|
289
|
+
resultsById: Map<string, unknown>,
|
|
261
290
|
signal?: AbortSignal,
|
|
262
291
|
): Promise<{ records: StepRecord[]; failed: boolean }> {
|
|
263
292
|
const records: StepRecord[] = [];
|
|
264
293
|
|
|
265
|
-
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
|
|
294
|
+
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
|
|
266
295
|
records.push(read.record);
|
|
267
296
|
if (read.record.status === "fail") return { records, failed: true };
|
|
268
297
|
|
|
@@ -291,7 +320,7 @@ async function runEffectStep(
|
|
|
291
320
|
// Gates are pre-flighted by findGate; only activities remain here.
|
|
292
321
|
const nestedActivities = step.steps.filter(isActivity);
|
|
293
322
|
for (let i = 0; i < nestedActivities.length; i++) {
|
|
294
|
-
const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
|
|
323
|
+
const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, resultsById, signal);
|
|
295
324
|
records.push(ran.record);
|
|
296
325
|
if (ran.record.status === "fail") {
|
|
297
326
|
// Receipt left untouched (stale) — the next run re-proposes the effect.
|
|
@@ -314,6 +343,7 @@ async function runEffectStep(
|
|
|
314
343
|
phaseName,
|
|
315
344
|
activities,
|
|
316
345
|
profiles,
|
|
346
|
+
resultsById,
|
|
317
347
|
signal,
|
|
318
348
|
);
|
|
319
349
|
records.push(wrote.record);
|
|
@@ -325,6 +355,7 @@ async function runPhase(
|
|
|
325
355
|
phase: PhaseDefinition,
|
|
326
356
|
activities: Map<string, ActivityFn>,
|
|
327
357
|
profiles: Record<string, ActivityProfile>,
|
|
358
|
+
resultsById: Map<string, unknown>,
|
|
328
359
|
signal?: AbortSignal,
|
|
329
360
|
): Promise<StepRecord[]> {
|
|
330
361
|
// Defensive: gates are pre-flighted, but never execute one if it slips
|
|
@@ -343,7 +374,7 @@ async function runPhase(
|
|
|
343
374
|
}
|
|
344
375
|
const steps = phase.steps.filter(isActivity);
|
|
345
376
|
const records = (
|
|
346
|
-
await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
|
|
377
|
+
await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)))
|
|
347
378
|
).map((r) => r.record);
|
|
348
379
|
if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
|
|
349
380
|
return records;
|
|
@@ -365,7 +396,7 @@ async function runPhase(
|
|
|
365
396
|
for (let i = 0; i < steps.length; i++) {
|
|
366
397
|
const step = steps[i];
|
|
367
398
|
if (isEffect(step)) {
|
|
368
|
-
const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
|
|
399
|
+
const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, resultsById, signal);
|
|
369
400
|
records.push(...effRecords);
|
|
370
401
|
if (failed) {
|
|
371
402
|
skipRemaining(i + 1);
|
|
@@ -373,7 +404,7 @@ async function runPhase(
|
|
|
373
404
|
}
|
|
374
405
|
continue;
|
|
375
406
|
}
|
|
376
|
-
const { record } = await runStep(step, phase.name, activities, profiles, signal);
|
|
407
|
+
const { record } = await runStep(step, phase.name, activities, profiles, resultsById, signal);
|
|
377
408
|
records.push(record);
|
|
378
409
|
if (record.status === "fail") {
|
|
379
410
|
// Mark the remaining steps in this phase as skipped, then abort.
|
|
@@ -416,10 +447,15 @@ export async function runOpLocally(
|
|
|
416
447
|
const records: StepRecord[] = [];
|
|
417
448
|
const start = Date.now();
|
|
418
449
|
|
|
450
|
+
// Completed step id → its activity result (#1290). Populated as main-phase
|
|
451
|
+
// steps finish, so a later step's step-output references resolve to real
|
|
452
|
+
// values before reaching an activity function — see `runStep`.
|
|
453
|
+
const resultsById = new Map<string, unknown>();
|
|
454
|
+
|
|
419
455
|
try {
|
|
420
456
|
for (const phase of config.phases) {
|
|
421
457
|
if (signal?.aborted) throw new PhaseFailure([]);
|
|
422
|
-
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
458
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
|
|
423
459
|
}
|
|
424
460
|
} catch (err) {
|
|
425
461
|
if (err instanceof PhaseFailure) records.push(...err.records);
|
|
@@ -429,7 +465,7 @@ export async function runOpLocally(
|
|
|
429
465
|
if (!signal?.aborted) {
|
|
430
466
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
431
467
|
try {
|
|
432
|
-
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
468
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
|
|
433
469
|
} catch (compErr) {
|
|
434
470
|
if (compErr instanceof PhaseFailure) records.push(...compErr.records);
|
|
435
471
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { classifyOpVerbClass, isGated } from "./op-verb-class";
|
|
3
|
+
import type { OpConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
function cfg(phases: OpConfig["phases"], onFailure?: OpConfig["onFailure"]): Pick<OpConfig, "phases" | "onFailure"> {
|
|
6
|
+
return { phases, ...(onFailure ? { onFailure } : {}) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("classifyOpVerbClass", () => {
|
|
10
|
+
test("read-only for a WatchOp-shaped composition (snapshot + diff)", () => {
|
|
11
|
+
const config = cfg([
|
|
12
|
+
{ name: "Snapshot", steps: [{ kind: "activity", fn: "lifecycleSnapshot", args: { env: "prod" } }] },
|
|
13
|
+
{ name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] },
|
|
14
|
+
]);
|
|
15
|
+
expect(classifyOpVerbClass(config)).toBe("read-only");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("read-only for an empty phase list", () => {
|
|
19
|
+
expect(classifyOpVerbClass(cfg([]))).toBe("read-only");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("mutating for nativeApply with deleteMode never", () => {
|
|
23
|
+
const config = cfg([
|
|
24
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "never" } }] },
|
|
25
|
+
]);
|
|
26
|
+
expect(classifyOpVerbClass(config)).toBe("mutating");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mutating for nativeApply with no deleteMode at all", () => {
|
|
30
|
+
const config = cfg([
|
|
31
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist" } }] },
|
|
32
|
+
]);
|
|
33
|
+
expect(classifyOpVerbClass(config)).toBe("mutating");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("destructive for nativeApply with deleteMode owned-only", () => {
|
|
37
|
+
const config = cfg([
|
|
38
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "owned-only" } }] },
|
|
39
|
+
]);
|
|
40
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("destructive for nativeApply with deleteMode gated", () => {
|
|
44
|
+
const config = cfg([
|
|
45
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "gated" } }] },
|
|
46
|
+
]);
|
|
47
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("destructive for envTeardown regardless of args", () => {
|
|
51
|
+
const config = cfg([{ name: "Teardown", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "staging" } }] }]);
|
|
52
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("destructive for awsDelete/azDelete/gcpDelete/chantTeardown", () => {
|
|
56
|
+
for (const fn of ["awsDelete", "azDelete", "gcpDelete", "chantTeardown"]) {
|
|
57
|
+
const config = cfg([{ name: "P", steps: [{ kind: "activity", fn, args: {} }] }]);
|
|
58
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("fail-closed: an unrecognized activity fn is mutating, not read-only", () => {
|
|
63
|
+
const config = cfg([{ name: "P", steps: [{ kind: "activity", fn: "someBespokeCustomActivity", args: {} }] }]);
|
|
64
|
+
expect(classifyOpVerbClass(config)).toBe("mutating");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a single destructive step outranks other read-only/mutating steps in the same Op", () => {
|
|
68
|
+
const config = cfg([
|
|
69
|
+
{ name: "Diff", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] },
|
|
70
|
+
{ name: "Teardown", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "prod" } }] },
|
|
71
|
+
]);
|
|
72
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("checks onFailure phases too", () => {
|
|
76
|
+
const config = cfg(
|
|
77
|
+
[{ name: "Apply", steps: [{ kind: "activity", fn: "lifecycleDiff", args: { env: "prod" } }] }],
|
|
78
|
+
[{ name: "Rollback", steps: [{ kind: "activity", fn: "envTeardown", args: { env: "prod" } }] }],
|
|
79
|
+
);
|
|
80
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("looks inside an effect step's nested activity steps", () => {
|
|
84
|
+
const config = cfg([
|
|
85
|
+
{
|
|
86
|
+
name: "Seed",
|
|
87
|
+
steps: [
|
|
88
|
+
{
|
|
89
|
+
kind: "effect",
|
|
90
|
+
receipt: { name: "seeded", lexicon: "aws" } as never,
|
|
91
|
+
steps: [{ kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "prod", output: "dist", deleteMode: "gated" } }],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(classifyOpVerbClass(config)).toBe("destructive");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("isGated", () => {
|
|
101
|
+
test("false with no gate step", () => {
|
|
102
|
+
expect(isGated(cfg([{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }]))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("true with a gate step in the main phases", () => {
|
|
106
|
+
expect(
|
|
107
|
+
isGated(
|
|
108
|
+
cfg([
|
|
109
|
+
{ name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] },
|
|
110
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] },
|
|
111
|
+
]),
|
|
112
|
+
),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("true with a gate step in onFailure", () => {
|
|
117
|
+
expect(
|
|
118
|
+
isGated(
|
|
119
|
+
cfg(
|
|
120
|
+
[{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }],
|
|
121
|
+
[{ name: "Rollback", steps: [{ kind: "gate", signalName: "approve-rollback" }] }],
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Op verb classification (#1484) — generalizes `delete: "never" | "owned-only"
|
|
3
|
+
* | "gated"` (`../lifecycle` teardown/apply's `DeleteMode`) up one level, to
|
|
4
|
+
* the whole Op: read-only, mutating, or destructive. `ConvergeOp`'s
|
|
5
|
+
* dial × verb-class matrix (the issue's "Autonomy" table) needs to answer
|
|
6
|
+
* "what kind of thing is this dispatched Op, from the outside" for an
|
|
7
|
+
* arbitrary declared Op — including one this project's authors wrote, not
|
|
8
|
+
* just the pre-built composites — so classification walks `OpConfig`'s
|
|
9
|
+
* steps rather than trusting a self-reported tag.
|
|
10
|
+
*
|
|
11
|
+
* There is no existing per-Op verb-class field (`searchAttributes` is a free
|
|
12
|
+
* `Record<string,string>` an author may or may not set — `ApplyOp` tags
|
|
13
|
+
* `{ Apply: "true" }` but nothing requires it, and it carries no verb-class
|
|
14
|
+
* semantics on its own) and no existing "which activity names are
|
|
15
|
+
* destructive" registry (`../lint/pipeline-change-gate.ts`'s classifier is
|
|
16
|
+
* shaped for a `Component` composition's capability-verb steps, not an
|
|
17
|
+
* `OpConfig`'s activity steps — a genuinely different graph). This module is
|
|
18
|
+
* that registry's Op-shaped sibling: a small, explicit set of known
|
|
19
|
+
* mutating/destructive activity function names, plus the one piece of
|
|
20
|
+
* structured signal that already exists (`nativeApply`'s `deleteMode` arg).
|
|
21
|
+
*
|
|
22
|
+
* Fail-closed by construction: an activity `fn` this module doesn't
|
|
23
|
+
* recognize is classified `mutating`, never `read-only` — an unknown step
|
|
24
|
+
* might do anything, and "unknown never remediates" (the issue's own honesty
|
|
25
|
+
* requirement for symptoms) applies here too: this module never silently
|
|
26
|
+
* clears an Op to free-run just because its steps aren't in the known-safe
|
|
27
|
+
* list.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { findGate } from "./local-executor";
|
|
31
|
+
import type { ActivityStep, EffectStep, OpConfig, StepDefinition } from "./types";
|
|
32
|
+
|
|
33
|
+
export type OpVerbClass = "read-only" | "mutating" | "destructive";
|
|
34
|
+
|
|
35
|
+
/** Activity function names known to be read-only — verification, observation, audit. Anything not in this set is treated as at least `mutating` (fail-closed). */
|
|
36
|
+
const READ_ONLY_ACTIVITY_FNS: ReadonlySet<string> = new Set([
|
|
37
|
+
"lifecycleSnapshot",
|
|
38
|
+
"lifecycleDiff",
|
|
39
|
+
"httpCheck",
|
|
40
|
+
"policyGate",
|
|
41
|
+
"workflowSupplyChainAudit",
|
|
42
|
+
"pipelineSupplyChainAudit",
|
|
43
|
+
"receiptStaleness",
|
|
44
|
+
"waitForStack",
|
|
45
|
+
"waitForReady",
|
|
46
|
+
"waitForArgoSync",
|
|
47
|
+
"spriteReadFile",
|
|
48
|
+
"spriteListDir",
|
|
49
|
+
"listCheckpoints",
|
|
50
|
+
"convergeTick",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Activity function names that always delete/destroy, regardless of args. */
|
|
54
|
+
const ALWAYS_DESTRUCTIVE_ACTIVITY_FNS: ReadonlySet<string> = new Set([
|
|
55
|
+
"envTeardown",
|
|
56
|
+
"chantTeardown",
|
|
57
|
+
"azDelete",
|
|
58
|
+
"awsDelete",
|
|
59
|
+
"gcpDelete",
|
|
60
|
+
"spriteDestroy",
|
|
61
|
+
"k3dDown",
|
|
62
|
+
"k3sUninstall",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function stepsOf(config: Pick<OpConfig, "phases" | "onFailure">): ActivityStep[] {
|
|
66
|
+
const all: StepDefinition[] = [...config.phases, ...(config.onFailure ?? [])].flatMap((p) => p.steps);
|
|
67
|
+
const flat: ActivityStep[] = [];
|
|
68
|
+
for (const step of all) {
|
|
69
|
+
if (step.kind === "activity") flat.push(step);
|
|
70
|
+
else if (step.kind === "effect") flat.push(...(step as EffectStep).steps.filter((s): s is ActivityStep => s.kind === "activity"));
|
|
71
|
+
}
|
|
72
|
+
return flat;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify an Op's own composition. Pure — reads only the passed config, no
|
|
77
|
+
* I/O. `nativeApply`'s `deleteMode` (`"never" | "owned-only" | "gated"`) is
|
|
78
|
+
* read straight off the step's `args` — `"owned-only"`/`"gated"` are
|
|
79
|
+
* destructive (both can delete a live resource), `"never"`/absent is merely
|
|
80
|
+
* mutating (creates/updates only). A step whose `fn` matches neither the
|
|
81
|
+
* read-only nor the destructive set is `mutating` by default (fail-closed).
|
|
82
|
+
*/
|
|
83
|
+
export function classifyOpVerbClass(config: Pick<OpConfig, "phases" | "onFailure">): OpVerbClass {
|
|
84
|
+
let sawMutating = false;
|
|
85
|
+
|
|
86
|
+
for (const step of stepsOf(config)) {
|
|
87
|
+
if (ALWAYS_DESTRUCTIVE_ACTIVITY_FNS.has(step.fn)) return "destructive";
|
|
88
|
+
|
|
89
|
+
if (step.fn === "nativeApply") {
|
|
90
|
+
const deleteMode = step.args?.deleteMode;
|
|
91
|
+
if (deleteMode === "owned-only" || deleteMode === "gated") return "destructive";
|
|
92
|
+
sawMutating = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (step.fn === "compensateApply") {
|
|
97
|
+
// Rollback compensation — mutating (it re-applies a prior state), not
|
|
98
|
+
// itself a fresh destructive act.
|
|
99
|
+
sawMutating = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (READ_ONLY_ACTIVITY_FNS.has(step.fn)) continue;
|
|
104
|
+
|
|
105
|
+
// Unknown or known-mutating fn: fail-closed to mutating.
|
|
106
|
+
sawMutating = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return sawMutating ? "mutating" : "read-only";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Does this Op have its own approval gate anywhere in its phases (main or `onFailure`)? Thin re-export of `findGate` under the name this module's callers reach for. */
|
|
113
|
+
export function isGated(config: Pick<OpConfig, "phases" | "onFailure">): boolean {
|
|
114
|
+
return findGate(config as OpConfig) !== undefined;
|
|
115
|
+
}
|