@intentius/chant 0.49.0 → 0.51.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/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.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 +47 -3
- 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/run-progress.d.ts +7 -5
- package/dist/components/run-progress.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 +134 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.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/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- 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 +6 -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/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- 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 +7 -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/testing.d.ts +23 -2
- package/dist/testing.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.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +30 -5
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +81 -1
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- 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 +47 -3
- 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/run-progress.ts +9 -5
- 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 +154 -2
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- 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/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- 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 +6 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- 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 +52 -10
- package/src/op/local-output.ts +1 -1
- 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/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
- 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,13 +14,20 @@
|
|
|
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
|
|
|
20
21
|
export interface StepRecord {
|
|
21
22
|
phase: string;
|
|
22
23
|
fn: string;
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Present for a local-executor record. Absent for one reconstructed from
|
|
26
|
+
* Temporal workflow history (op-progress.ts) — an activity's scheduled
|
|
27
|
+
* input isn't decoded there, so the field is simply omitted rather than
|
|
28
|
+
* populated with a guess.
|
|
29
|
+
*/
|
|
30
|
+
args?: Record<string, unknown>;
|
|
24
31
|
status: "ok" | "fail" | "skipped";
|
|
25
32
|
durationMs: number;
|
|
26
33
|
outcome?: { name: string; value: unknown };
|
|
@@ -94,6 +101,25 @@ function resolvePath(value: unknown, path?: string): unknown {
|
|
|
94
101
|
);
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Deep-walk `value`, replacing every {@link StepOutputRef} with the recorded
|
|
106
|
+
* result of its producer step, resolved through the ref's optional dot-path
|
|
107
|
+
* exactly like the serializer's compiled `__rN?.path?.segments` (chant #1290)
|
|
108
|
+
* — `resolvePath` gives the same "undefined intermediate resolves to
|
|
109
|
+
* undefined, never throws" semantics. A reference to a step whose result
|
|
110
|
+
* isn't in `resultsById` (never ran, or ran out of the scope
|
|
111
|
+
* `validateStepOutputRefs` allows) resolves to `undefined`, the same way a
|
|
112
|
+
* producer whose declared field is absent would.
|
|
113
|
+
*/
|
|
114
|
+
function resolveStepOutputRefs(value: unknown, resultsById: ReadonlyMap<string, unknown>): unknown {
|
|
115
|
+
if (isStepOutputRef(value)) return resolvePath(resultsById.get(value.step), value.path);
|
|
116
|
+
if (Array.isArray(value)) return value.map((v) => resolveStepOutputRefs(v, resultsById));
|
|
117
|
+
if (value && typeof value === "object") {
|
|
118
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveStepOutputRefs(v, resultsById)]));
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
/** Find the first gate step anywhere in the Op (phases + onFailure, including
|
|
98
124
|
* gates nested inside effect steps), if any. */
|
|
99
125
|
export function findGate(config: OpConfig): GateStep | undefined {
|
|
@@ -160,15 +186,22 @@ interface RanStep {
|
|
|
160
186
|
result?: unknown;
|
|
161
187
|
}
|
|
162
188
|
|
|
163
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Run one activity step with retry + timeout. Never throws — returns a
|
|
191
|
+
* record. Any {@link StepOutputRef} in `step.args` is resolved against
|
|
192
|
+
* `resultsById` before the activity is called (#1290) — the local executor
|
|
193
|
+
* is a first-class peer of the Temporal path, so an unresolved placeholder
|
|
194
|
+
* must never reach an activity function; see `resolveStepOutputRefs`.
|
|
195
|
+
*/
|
|
164
196
|
async function runStep(
|
|
165
197
|
step: ActivityStep,
|
|
166
198
|
phaseName: string,
|
|
167
199
|
activities: Map<string, ActivityFn>,
|
|
168
200
|
profiles: Record<string, ActivityProfile>,
|
|
201
|
+
resultsById: Map<string, unknown>,
|
|
169
202
|
signal?: AbortSignal,
|
|
170
203
|
): Promise<RanStep> {
|
|
171
|
-
const args = step.args ?? {}
|
|
204
|
+
const args = resolveStepOutputRefs(step.args ?? {}, resultsById) as Record<string, unknown>;
|
|
172
205
|
const base = { phase: phaseName, fn: step.fn, args };
|
|
173
206
|
const start = Date.now();
|
|
174
207
|
|
|
@@ -198,6 +231,7 @@ async function runStep(
|
|
|
198
231
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
199
232
|
try {
|
|
200
233
|
const result = await callWithTimeout(fn, args, timeoutMs, signal);
|
|
234
|
+
if (step.id) resultsById.set(step.id, result);
|
|
201
235
|
const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
|
|
202
236
|
if (step.outcomeAttribute) {
|
|
203
237
|
record.outcome = {
|
|
@@ -258,11 +292,12 @@ async function runEffectStep(
|
|
|
258
292
|
phaseName: string,
|
|
259
293
|
activities: Map<string, ActivityFn>,
|
|
260
294
|
profiles: Record<string, ActivityProfile>,
|
|
295
|
+
resultsById: Map<string, unknown>,
|
|
261
296
|
signal?: AbortSignal,
|
|
262
297
|
): Promise<{ records: StepRecord[]; failed: boolean }> {
|
|
263
298
|
const records: StepRecord[] = [];
|
|
264
299
|
|
|
265
|
-
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
|
|
300
|
+
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, resultsById, signal);
|
|
266
301
|
records.push(read.record);
|
|
267
302
|
if (read.record.status === "fail") return { records, failed: true };
|
|
268
303
|
|
|
@@ -291,7 +326,7 @@ async function runEffectStep(
|
|
|
291
326
|
// Gates are pre-flighted by findGate; only activities remain here.
|
|
292
327
|
const nestedActivities = step.steps.filter(isActivity);
|
|
293
328
|
for (let i = 0; i < nestedActivities.length; i++) {
|
|
294
|
-
const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
|
|
329
|
+
const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, resultsById, signal);
|
|
295
330
|
records.push(ran.record);
|
|
296
331
|
if (ran.record.status === "fail") {
|
|
297
332
|
// Receipt left untouched (stale) — the next run re-proposes the effect.
|
|
@@ -314,6 +349,7 @@ async function runEffectStep(
|
|
|
314
349
|
phaseName,
|
|
315
350
|
activities,
|
|
316
351
|
profiles,
|
|
352
|
+
resultsById,
|
|
317
353
|
signal,
|
|
318
354
|
);
|
|
319
355
|
records.push(wrote.record);
|
|
@@ -325,6 +361,7 @@ async function runPhase(
|
|
|
325
361
|
phase: PhaseDefinition,
|
|
326
362
|
activities: Map<string, ActivityFn>,
|
|
327
363
|
profiles: Record<string, ActivityProfile>,
|
|
364
|
+
resultsById: Map<string, unknown>,
|
|
328
365
|
signal?: AbortSignal,
|
|
329
366
|
): Promise<StepRecord[]> {
|
|
330
367
|
// Defensive: gates are pre-flighted, but never execute one if it slips
|
|
@@ -343,7 +380,7 @@ async function runPhase(
|
|
|
343
380
|
}
|
|
344
381
|
const steps = phase.steps.filter(isActivity);
|
|
345
382
|
const records = (
|
|
346
|
-
await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
|
|
383
|
+
await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, resultsById, signal)))
|
|
347
384
|
).map((r) => r.record);
|
|
348
385
|
if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
|
|
349
386
|
return records;
|
|
@@ -365,7 +402,7 @@ async function runPhase(
|
|
|
365
402
|
for (let i = 0; i < steps.length; i++) {
|
|
366
403
|
const step = steps[i];
|
|
367
404
|
if (isEffect(step)) {
|
|
368
|
-
const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
|
|
405
|
+
const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, resultsById, signal);
|
|
369
406
|
records.push(...effRecords);
|
|
370
407
|
if (failed) {
|
|
371
408
|
skipRemaining(i + 1);
|
|
@@ -373,7 +410,7 @@ async function runPhase(
|
|
|
373
410
|
}
|
|
374
411
|
continue;
|
|
375
412
|
}
|
|
376
|
-
const { record } = await runStep(step, phase.name, activities, profiles, signal);
|
|
413
|
+
const { record } = await runStep(step, phase.name, activities, profiles, resultsById, signal);
|
|
377
414
|
records.push(record);
|
|
378
415
|
if (record.status === "fail") {
|
|
379
416
|
// Mark the remaining steps in this phase as skipped, then abort.
|
|
@@ -416,10 +453,15 @@ export async function runOpLocally(
|
|
|
416
453
|
const records: StepRecord[] = [];
|
|
417
454
|
const start = Date.now();
|
|
418
455
|
|
|
456
|
+
// Completed step id → its activity result (#1290). Populated as main-phase
|
|
457
|
+
// steps finish, so a later step's step-output references resolve to real
|
|
458
|
+
// values before reaching an activity function — see `runStep`.
|
|
459
|
+
const resultsById = new Map<string, unknown>();
|
|
460
|
+
|
|
419
461
|
try {
|
|
420
462
|
for (const phase of config.phases) {
|
|
421
463
|
if (signal?.aborted) throw new PhaseFailure([]);
|
|
422
|
-
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
464
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
|
|
423
465
|
}
|
|
424
466
|
} catch (err) {
|
|
425
467
|
if (err instanceof PhaseFailure) records.push(...err.records);
|
|
@@ -429,7 +471,7 @@ export async function runOpLocally(
|
|
|
429
471
|
if (!signal?.aborted) {
|
|
430
472
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
431
473
|
try {
|
|
432
|
-
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
474
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, signal)));
|
|
433
475
|
} catch (compErr) {
|
|
434
476
|
if (compErr instanceof PhaseFailure) records.push(...compErr.records);
|
|
435
477
|
}
|
package/src/op/local-output.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
33
33
|
write(`[phase] ${currentPhase}`);
|
|
34
34
|
}
|
|
35
35
|
const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
|
|
36
|
-
const call = `${record.fn}(${formatArgs(record.args)})`;
|
|
36
|
+
const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
|
|
37
37
|
if (record.status === "skipped") {
|
|
38
38
|
write(` ${mark} ${call} skipped`);
|
|
39
39
|
} else {
|
|
@@ -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
|
+
}
|