@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
|
@@ -26,20 +26,23 @@
|
|
|
26
26
|
* `componentBom` fall back to `null` exactly as before — no regression for
|
|
27
27
|
* ledgers with no persisted manifests at all.
|
|
28
28
|
*/
|
|
29
|
-
import { resolve } from "node:path";
|
|
29
|
+
import { resolve, join, dirname } from "node:path";
|
|
30
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
31
|
import { getHeadCommit, fetchLifecycle, pushLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
31
32
|
import {
|
|
32
33
|
appendReleaseRecord,
|
|
33
34
|
readReleaseLedger,
|
|
34
35
|
listReleaseEnvironments,
|
|
36
|
+
latestPerComponent,
|
|
35
37
|
InvalidReleaseRecordError,
|
|
36
38
|
} from "../../lifecycle/release-ledger";
|
|
37
39
|
import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
|
|
38
40
|
import { commandBuildParams } from "../build-params-cli";
|
|
39
41
|
import { buildChangeSet } from "../../lifecycle/change-set";
|
|
40
42
|
import { buildLedgerEntries, componentBomSummary, type BuildLedgerEntry } from "../../lifecycle/build-ledger";
|
|
41
|
-
import { findBuildManifestByArtifactDigest } from "../../lifecycle/build-ledger-store";
|
|
43
|
+
import { findBuildManifestByArtifactDigest, readBuildManifest } from "../../lifecycle/build-ledger-store";
|
|
42
44
|
import type { ComponentBomSummary } from "../../lifecycle/build-ledger";
|
|
45
|
+
import type { BuildArchiveManifest } from "../../components/verbs/build-archive";
|
|
43
46
|
import { loadChantConfig } from "../../config";
|
|
44
47
|
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
45
48
|
import { isResourceDeclarable } from "../../declarable";
|
|
@@ -51,6 +54,7 @@ import type { LexiconPlugin } from "../../lexicon";
|
|
|
51
54
|
import { normalizeObservation, mergeObservations, unobservedAll, type NormalizedObservation } from "../../observation";
|
|
52
55
|
import type { Phase, Component } from "../../components/component";
|
|
53
56
|
import { deployUnits } from "../../components/deploy-units";
|
|
57
|
+
import { sortedJsonReplacer } from "../../utils";
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* chant components release <env> --component <name> --digest <sha256:...>
|
|
@@ -147,6 +151,159 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
/** One entry of `chant components export`'s per-entry result (text and `--json` output). */
|
|
155
|
+
interface ExportEntryResult {
|
|
156
|
+
kind: string;
|
|
157
|
+
path: string;
|
|
158
|
+
digest: string;
|
|
159
|
+
status: "materialized" | "missing";
|
|
160
|
+
/** Set only when `status` is "missing" — why the source file couldn't be read. */
|
|
161
|
+
reason?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* chant components export <env> --component <name> [--digest <manifestDigest>]
|
|
166
|
+
* -o <dir> [--json]
|
|
167
|
+
*
|
|
168
|
+
* Materializes a persisted `BuildArchiveManifest` (#609,
|
|
169
|
+
* ../../lifecycle/build-ledger-store.ts) to a portable directory: every
|
|
170
|
+
* `image`/`template`/`asset`/`sbom` entry is copied byte-for-byte from where
|
|
171
|
+
* the build that produced it left it on disk (its `path`, resolved against
|
|
172
|
+
* the current working directory — the same directory a component's
|
|
173
|
+
* `docker-build`/`addArchiveTemplate`/etc. step wrote it into) under that
|
|
174
|
+
* same archive-relative path in `-o <dir>`, plus a `manifest.json` so the
|
|
175
|
+
* directory is self-describing without chant. No re-synthesis: this reads
|
|
176
|
+
* bytes a prior `chant build`/`run --components` already produced, never
|
|
177
|
+
* regenerates them (#929) — the "byte-identical, promote-by-digest" contract
|
|
178
|
+
* the build archive is built on (see ../../components/verbs/build-archive.ts).
|
|
179
|
+
*
|
|
180
|
+
* Manifest resolution:
|
|
181
|
+
* - `--digest <manifestDigest>` reads it directly (`readBuildManifest`),
|
|
182
|
+
* independent of any environment/release record.
|
|
183
|
+
* - Otherwise resolves "the manifest behind this component's most recent
|
|
184
|
+
* successful run" in `<env>`: the latest release record's own
|
|
185
|
+
* `manifestDigest` when it was recorded, else a reverse lookup by its
|
|
186
|
+
* promoted artifact digest (`findBuildManifestByArtifactDigest`) — the
|
|
187
|
+
* same join `chant components status` already performs.
|
|
188
|
+
*
|
|
189
|
+
* A source file missing on disk (the build that produced the manifest ran
|
|
190
|
+
* somewhere else, or its output was since cleaned) is reported per entry
|
|
191
|
+
* rather than silently dropped; the command exits non-zero so an incomplete
|
|
192
|
+
* export is never mistaken for a complete one.
|
|
193
|
+
*/
|
|
194
|
+
export async function runComponentsExport(ctx: CommandContext): Promise<number> {
|
|
195
|
+
const { args } = ctx;
|
|
196
|
+
const environment = args.extraPositional;
|
|
197
|
+
const component = args.component;
|
|
198
|
+
const outDir = args.output;
|
|
199
|
+
const usage = "chant components export <env> --component <name> [--digest <manifestDigest>] -o <dir>";
|
|
200
|
+
|
|
201
|
+
if (!outDir) {
|
|
202
|
+
console.error(formatError({ message: "-o/--output <dir> is required", hint: usage }));
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await fetchLifecycle();
|
|
207
|
+
|
|
208
|
+
let manifest: BuildArchiveManifest;
|
|
209
|
+
|
|
210
|
+
if (args.digest) {
|
|
211
|
+
const resolved = await readBuildManifest(args.digest);
|
|
212
|
+
if (!resolved) {
|
|
213
|
+
console.error(formatError({ message: `No persisted build manifest found for digest ${args.digest}` }));
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
if (component && resolved.component !== component) {
|
|
217
|
+
console.error(formatWarning({
|
|
218
|
+
message: `--component ${component} does not match the resolved manifest's component (${resolved.component}) — exporting the manifest at --digest ${args.digest} anyway`,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
manifest = resolved;
|
|
222
|
+
} else {
|
|
223
|
+
if (!environment || !component) {
|
|
224
|
+
console.error(formatError({
|
|
225
|
+
message: "Environment and --component are required unless --digest <manifestDigest> is given",
|
|
226
|
+
hint: usage,
|
|
227
|
+
}));
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
const ledger = await readReleaseLedger(environment);
|
|
231
|
+
const latest = latestPerComponent(ledger.records).get(component);
|
|
232
|
+
if (!latest) {
|
|
233
|
+
console.error(formatError({
|
|
234
|
+
message: `No release record for component "${component}" in environment "${environment}"`,
|
|
235
|
+
}));
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
const resolved = latest.manifestDigest
|
|
239
|
+
? await readBuildManifest(latest.manifestDigest)
|
|
240
|
+
: await findBuildManifestByArtifactDigest(latest.digest);
|
|
241
|
+
if (!resolved) {
|
|
242
|
+
console.error(formatError({
|
|
243
|
+
message: `No persisted build manifest found for ${component}@${environment}'s recorded digest ${latest.digest}`,
|
|
244
|
+
hint: "The build predates #609, or was recorded via `chant components release` alone with no corresponding `chant build`/`run --components`.",
|
|
245
|
+
}));
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
manifest = resolved;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const targetDir = resolve(outDir);
|
|
252
|
+
const sourceRoot = resolve(".");
|
|
253
|
+
await mkdir(targetDir, { recursive: true });
|
|
254
|
+
|
|
255
|
+
const entries = [...manifest.contents].sort((a, b) => a.path.localeCompare(b.path));
|
|
256
|
+
const results: ExportEntryResult[] = [];
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
try {
|
|
259
|
+
const bytes = await readFile(resolve(sourceRoot, entry.path));
|
|
260
|
+
const target = join(targetDir, entry.path);
|
|
261
|
+
await mkdir(dirname(target), { recursive: true });
|
|
262
|
+
await writeFile(target, bytes);
|
|
263
|
+
results.push({ kind: entry.kind, path: entry.path, digest: entry.digest, status: "materialized" });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
results.push({
|
|
266
|
+
kind: entry.kind,
|
|
267
|
+
path: entry.path,
|
|
268
|
+
digest: entry.digest,
|
|
269
|
+
status: "missing",
|
|
270
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await writeFile(join(targetDir, "manifest.json"), JSON.stringify(manifest, sortedJsonReplacer, 2));
|
|
276
|
+
|
|
277
|
+
const missing = results.filter((r) => r.status === "missing");
|
|
278
|
+
|
|
279
|
+
if (args.json) {
|
|
280
|
+
console.log(JSON.stringify({
|
|
281
|
+
component: manifest.component,
|
|
282
|
+
manifestDigest: manifest.manifestDigest,
|
|
283
|
+
outDir: targetDir,
|
|
284
|
+
entries: results,
|
|
285
|
+
}, null, 2));
|
|
286
|
+
} else {
|
|
287
|
+
console.log(formatBold(`${manifest.component} (${manifest.manifestDigest}) -> ${targetDir}`));
|
|
288
|
+
for (const r of results) {
|
|
289
|
+
const status = r.status === "materialized" ? formatSuccess("ok") : formatError({ message: r.reason ?? "missing" });
|
|
290
|
+
console.log(` ${r.kind.padEnd(10)}${r.path.padEnd(40)}${status}`);
|
|
291
|
+
}
|
|
292
|
+
console.log(` ${"manifest".padEnd(10)}${"manifest.json".padEnd(40)}${formatSuccess("ok")}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (missing.length > 0) {
|
|
296
|
+
console.error(formatError({
|
|
297
|
+
message: `${missing.length} of ${results.length} archive entr${missing.length === 1 ? "y" : "ies"} not found on disk — export incomplete`,
|
|
298
|
+
hint: `Run this from the checkout where the build that produced ${manifest.manifestDigest} still has its output on disk.`,
|
|
299
|
+
}));
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.error(formatSuccess(`Exported ${results.length} entr${results.length === 1 ? "y" : "ies"} + manifest.json to ${targetDir}`));
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
150
307
|
/** One row of `chant components status` JSON output. */
|
|
151
308
|
interface StatusJsonRow {
|
|
152
309
|
component: string;
|
|
@@ -595,7 +752,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
|
|
|
595
752
|
export async function runComponentsUnknown(ctx: CommandContext): Promise<number> {
|
|
596
753
|
console.error(formatError({
|
|
597
754
|
message: `Unknown components subcommand: ${ctx.args.path}`,
|
|
598
|
-
hint: "Available: chant components status [env], chant components release <env>",
|
|
755
|
+
hint: "Available: chant components status [env], chant components release <env>, chant components export <env>",
|
|
599
756
|
}));
|
|
600
757
|
return 1;
|
|
601
758
|
}
|
|
@@ -431,6 +431,26 @@ describe("runGraph", () => {
|
|
|
431
431
|
expect(discoverMock).not.toHaveBeenCalled();
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
+
test("--components --format ir carries declared composites (#1492); undeclared components have no composites attr", async () => {
|
|
435
|
+
lintMock.mockResolvedValue({ success: true });
|
|
436
|
+
componentGraphMock.mockResolvedValue({
|
|
437
|
+
success: true,
|
|
438
|
+
order: ["shared-foundation", "loom-backend"],
|
|
439
|
+
waves: [["shared-foundation"], ["loom-backend"]],
|
|
440
|
+
edges: [{ from: "loom-backend", to: "shared-foundation" }],
|
|
441
|
+
files: {},
|
|
442
|
+
composites: { "shared-foundation": ["ArtifactBucket", "OperatorRole"] },
|
|
443
|
+
});
|
|
444
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", components: true }), plugins: [], serializers: [] });
|
|
445
|
+
expect(exit).toBe(0);
|
|
446
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
447
|
+
expect(ir.nodes.find((n: { id: string }) => n.id === "shared-foundation").attrs.composites).toEqual([
|
|
448
|
+
"ArtifactBucket",
|
|
449
|
+
"OperatorRole",
|
|
450
|
+
]);
|
|
451
|
+
expect("composites" in ir.nodes.find((n: { id: string }) => n.id === "loom-backend").attrs).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
434
454
|
test("--components --format mermaid lanes the components by wave", async () => {
|
|
435
455
|
lintMock.mockResolvedValue({ success: true });
|
|
436
456
|
componentGraphClean();
|
|
@@ -580,7 +580,16 @@ async function runComponentGraphView(
|
|
|
580
580
|
// `liveNames` (#1491): the resources this component owns — declared, or
|
|
581
581
|
// the contract's `[name]` identity fallback — so a consumer can join
|
|
582
582
|
// the component DAG to the resource graph instead of guessing at kinds.
|
|
583
|
-
|
|
583
|
+
// `composites` (#1492): the composite kind(s) this component's
|
|
584
|
+
// resources come from, when the component declared them explicitly —
|
|
585
|
+
// omitted from attrs (not defaulted) when it didn't, so a consumer
|
|
586
|
+
// knows to fall back to its own naming-convention guess rather than
|
|
587
|
+
// reading "no composites" from an empty array.
|
|
588
|
+
attrs: {
|
|
589
|
+
wave: waveOf.get(name) ?? null,
|
|
590
|
+
liveNames: graph.liveNames?.[name] ?? [name],
|
|
591
|
+
...(graph.composites?.[name] ? { composites: graph.composites[name] } : {}),
|
|
592
|
+
},
|
|
584
593
|
// Deep-link the node to its `*.component.ts` (behold's inspect panel).
|
|
585
594
|
...(graph.files?.[name] ? { sourceLoc: { file: graph.files[name] } } : {}),
|
|
586
595
|
})),
|
|
@@ -821,6 +821,96 @@ describe("runLifecyclePlan", () => {
|
|
|
821
821
|
expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
|
|
822
822
|
});
|
|
823
823
|
|
|
824
|
+
// #1665 — per-change disruption. Core defines the contract and reports it;
|
|
825
|
+
// the lexicon that owns the spec answers, and every degradation is `unknown`.
|
|
826
|
+
describe("disruption classification (#1665)", () => {
|
|
827
|
+
const drifted = (lexicon: string, type: string) => {
|
|
828
|
+
readSnapshotMock.mockResolvedValue(JSON.stringify({
|
|
829
|
+
lexicon,
|
|
830
|
+
environment: "prod",
|
|
831
|
+
commit: "abc",
|
|
832
|
+
timestamp: "2026-04-01T00:00:00Z",
|
|
833
|
+
resources: { db: meta({ type, attributes: { Engine: "postgres" } }) },
|
|
834
|
+
}));
|
|
835
|
+
return staticDescribeResources({ db: meta({ type, attributes: { Engine: "mysql" } }) });
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
test("a lexicon's verdict reaches --json and the stderr warning", async () => {
|
|
839
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
840
|
+
const plugins: LexiconPlugin[] = [
|
|
841
|
+
createMockPlugin({
|
|
842
|
+
name: "aws",
|
|
843
|
+
emulator: awsEmulatorStub,
|
|
844
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
845
|
+
classifyDisruption: ({ changes }) =>
|
|
846
|
+
Object.fromEntries(
|
|
847
|
+
changes.map((c) => [
|
|
848
|
+
c.name,
|
|
849
|
+
{ disruption: "destroy" as const, because: c.deltas.map((d) => d.path), detail: "Engine is create-only" },
|
|
850
|
+
]),
|
|
851
|
+
),
|
|
852
|
+
}),
|
|
853
|
+
];
|
|
854
|
+
const exit = await runLifecyclePlan({
|
|
855
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
856
|
+
plugins,
|
|
857
|
+
serializers: plugins.map((p) => p.serializer),
|
|
858
|
+
});
|
|
859
|
+
expect(exit).toBe(0);
|
|
860
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
861
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
862
|
+
expect(db).toMatchObject({
|
|
863
|
+
action: "update",
|
|
864
|
+
disruption: "destroy",
|
|
865
|
+
disruptionDetail: "Engine is create-only",
|
|
866
|
+
});
|
|
867
|
+
expect(db.disruptionBecause).toContain("attributes.Engine");
|
|
868
|
+
// The --json shape has no column for it, so the warning is what a CI
|
|
869
|
+
// consumer hears.
|
|
870
|
+
expect(stderrBuf.join("\n")).toContain("replace the resource rather than mutating it in place");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("a lexicon with no classifier leaves every update unknown, never in-place", async () => {
|
|
874
|
+
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["db"] }));
|
|
875
|
+
const plugins: LexiconPlugin[] = [
|
|
876
|
+
createMockPlugin({ name: "k8s", describeResources: drifted("k8s", "K8s::Apps::Deployment") }),
|
|
877
|
+
];
|
|
878
|
+
const exit = await runLifecyclePlan({
|
|
879
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
880
|
+
plugins,
|
|
881
|
+
serializers: plugins.map((p) => p.serializer),
|
|
882
|
+
});
|
|
883
|
+
expect(exit).toBe(0);
|
|
884
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
885
|
+
const db = plan.entries.find((e: { name: string }) => e.name === "db");
|
|
886
|
+
expect(db.disruption).toBe("unknown");
|
|
887
|
+
expect(db.disruptionDetail).toContain("k8s lexicon does not classify disruption");
|
|
888
|
+
expect(stderrBuf.join("\n")).toContain("could not be classified");
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test("the human render carries the verdict", async () => {
|
|
892
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["db"] }));
|
|
893
|
+
const plugins: LexiconPlugin[] = [
|
|
894
|
+
createMockPlugin({
|
|
895
|
+
name: "aws",
|
|
896
|
+
emulator: awsEmulatorStub,
|
|
897
|
+
describeResources: drifted("aws", "AWS::RDS::DBInstance"),
|
|
898
|
+
classifyDisruption: () => ({ db: { disruption: "in-place" as const, detail: "no create-only property changed" } }),
|
|
899
|
+
}),
|
|
900
|
+
];
|
|
901
|
+
const exit = await runLifecyclePlan({
|
|
902
|
+
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
903
|
+
plugins,
|
|
904
|
+
serializers: plugins.map((p) => p.serializer),
|
|
905
|
+
});
|
|
906
|
+
expect(exit).toBe(0);
|
|
907
|
+
const out = stdoutBuf.join("\n");
|
|
908
|
+
expect(out).toContain("Disruption: 1 in-place");
|
|
909
|
+
expect(out).toContain("— in-place: no create-only property changed");
|
|
910
|
+
expect(stderrBuf.join("\n")).not.toContain("could not be classified");
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
824
914
|
// #1832 — effect receipts are declared, diffed, and observed like any
|
|
825
915
|
// resource, but observe-only to the generic apply path: the plan compares
|
|
826
916
|
// live value to resolved expectation and proposes the fire, never a create.
|
|
@@ -3,8 +3,9 @@ import { commandBuildParams } from "../build-params-cli";
|
|
|
3
3
|
import { build } from "../../build";
|
|
4
4
|
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
5
5
|
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
6
|
-
import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
|
|
6
|
+
import { deepDiffForLexicon, type DeclaredEntities } from "../../lifecycle/deep-observe";
|
|
7
7
|
import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
|
|
8
|
+
import { describePathOrigin, getPathProvenance } from "../../provenance";
|
|
8
9
|
import {
|
|
9
10
|
acceptDeviations,
|
|
10
11
|
baselineForLexicon,
|
|
@@ -19,6 +20,7 @@ import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
|
19
20
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
20
21
|
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
21
22
|
import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
|
|
23
|
+
import { annotateDisruption, disruptionNotices } from "../../lifecycle/disruption";
|
|
22
24
|
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
23
25
|
import {
|
|
24
26
|
formatUnobserved,
|
|
@@ -55,7 +57,7 @@ import type { ChantConfig } from "../../config";
|
|
|
55
57
|
* then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
|
|
56
58
|
* so their build digests stay consistent.
|
|
57
59
|
*/
|
|
58
|
-
function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
60
|
+
export function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
59
61
|
return resolve(args.src ?? config.sourceDir ?? ".");
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -803,13 +805,17 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
803
805
|
// declared name the reader can never resolve reads as missing (see
|
|
804
806
|
// lifecycle/observe.ts).
|
|
805
807
|
const declared = new Set<string>();
|
|
806
|
-
const entities = new Map
|
|
808
|
+
const entities: DeclaredEntities = new Map();
|
|
807
809
|
for (const [name, entity] of args.buildResult.entities) {
|
|
808
810
|
if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
|
|
809
811
|
declared.add(name);
|
|
812
|
+
// Path origins are carried across explicitly (#1443): this map is plain
|
|
813
|
+
// objects, and the symbol-keyed provenance channel does not survive it.
|
|
814
|
+
const pathOrigins = getPathProvenance(entity);
|
|
810
815
|
entities.set(name, {
|
|
811
816
|
entityType: entity.entityType,
|
|
812
817
|
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
818
|
+
...(pathOrigins ? { pathOrigins } : {}),
|
|
813
819
|
});
|
|
814
820
|
}
|
|
815
821
|
}
|
|
@@ -964,7 +970,10 @@ function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
|
|
|
964
970
|
const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
|
|
965
971
|
const live = "live" in change ? formatValue(change.live) : "<absent>";
|
|
966
972
|
const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
|
|
967
|
-
|
|
973
|
+
// Both sides of the attribution, where each is known (#1443/#1189).
|
|
974
|
+
const from = change.origin ? ` [from: ${describePathOrigin(change.origin)}]` : "";
|
|
975
|
+
const owner = change.owner ? ` [owner: ${change.owner}]` : "";
|
|
976
|
+
console.log(` ${change.path}: ${declared} → ${live}${baseline}${from}${owner}`);
|
|
968
977
|
}
|
|
969
978
|
}
|
|
970
979
|
}
|
|
@@ -1256,7 +1265,17 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1256
1265
|
// Attribution survives the flat merge below (#1674).
|
|
1257
1266
|
lexicon: lexiconName,
|
|
1258
1267
|
});
|
|
1259
|
-
|
|
1268
|
+
|
|
1269
|
+
// Disruption (#1665) is asked of THIS lexicon, before the merge: the
|
|
1270
|
+
// deltas are paths into its own observation shape, and the replacement
|
|
1271
|
+
// rules are in the spec it compiled. A lexicon that answers nothing
|
|
1272
|
+
// leaves every update `unknown` — never `in-place`.
|
|
1273
|
+
const classified = await annotateDisruption(
|
|
1274
|
+
cs,
|
|
1275
|
+
environment,
|
|
1276
|
+
plugin.classifyDisruption ? (o) => plugin.classifyDisruption!(o) : undefined,
|
|
1277
|
+
);
|
|
1278
|
+
merged.entries.push(...classified.entries);
|
|
1260
1279
|
checked++;
|
|
1261
1280
|
}
|
|
1262
1281
|
} finally {
|
|
@@ -1291,6 +1310,12 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1291
1310
|
}));
|
|
1292
1311
|
}
|
|
1293
1312
|
|
|
1313
|
+
// Same reason as above (#1665): the `--json` and `--report gitlab-mr` shapes
|
|
1314
|
+
// have no column for how much an update hurts.
|
|
1315
|
+
for (const notice of disruptionNotices(merged)) {
|
|
1316
|
+
console.error(formatWarning({ message: notice }));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1294
1319
|
// `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
|
|
1295
1320
|
// human render. Write it to a file (`tfplan.json`) in CI and declare it as
|
|
1296
1321
|
// `artifacts:reports:terraform` to light up the merge-request widget.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
3
|
+
import type { OpConfig } from "../../op/types";
|
|
4
|
+
import type { EffectReceiptRef } from "../../op/receipt-store";
|
|
5
|
+
import type { WorkflowHistoryRaw, HistoryEvent } from "./run-client";
|
|
6
|
+
|
|
7
|
+
// Minimal history-event builders. Field shapes match what `fetchNormalizedHistory`
|
|
8
|
+
// produces: short PascalCase `eventType`, decimal-string `eventId`/`scheduledEventId`.
|
|
9
|
+
function scheduled(eventId: string, fn: string, at: Date): HistoryEvent {
|
|
10
|
+
return {
|
|
11
|
+
eventId,
|
|
12
|
+
eventType: "ActivityTaskScheduled",
|
|
13
|
+
eventTime: at,
|
|
14
|
+
activityTaskScheduledEventAttributes: { activityId: eventId, activityType: { name: fn } },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function completed(scheduledEventId: string, at: Date): HistoryEvent {
|
|
18
|
+
return {
|
|
19
|
+
eventType: "ActivityTaskCompleted",
|
|
20
|
+
eventTime: at,
|
|
21
|
+
activityTaskCompletedEventAttributes: { scheduledEventId },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function failed(scheduledEventId: string, at: Date, message = "boom"): HistoryEvent {
|
|
25
|
+
return {
|
|
26
|
+
eventType: "ActivityTaskFailed",
|
|
27
|
+
eventTime: at,
|
|
28
|
+
activityTaskFailedEventAttributes: { scheduledEventId, failure: { message } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const T0 = new Date("2026-05-01T00:00:00Z");
|
|
33
|
+
const sec = (n: number) => new Date(T0.getTime() + n * 1000);
|
|
34
|
+
|
|
35
|
+
describe("extractStepRecords", () => {
|
|
36
|
+
test("joins two phases' activities to their declared phase, in order", () => {
|
|
37
|
+
const config: OpConfig = {
|
|
38
|
+
name: "op", overview: "o",
|
|
39
|
+
phases: [
|
|
40
|
+
{ name: "Build", steps: [{ kind: "activity", fn: "build" }] },
|
|
41
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "deploy" }] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const history: WorkflowHistoryRaw = {
|
|
45
|
+
events: [
|
|
46
|
+
scheduled("1", "build", sec(0)),
|
|
47
|
+
completed("1", sec(1)),
|
|
48
|
+
scheduled("2", "deploy", sec(2)),
|
|
49
|
+
completed("2", sec(4)),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const records = extractStepRecords(config, history);
|
|
53
|
+
expect(records).toEqual([
|
|
54
|
+
{ phase: "Build", fn: "build", status: "ok", durationMs: 1000 },
|
|
55
|
+
{ phase: "Deploy", fn: "deploy", status: "ok", durationMs: 2000 },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("two steps calling the same activity each get their own scheduled event (no last-wins collision)", () => {
|
|
60
|
+
const config: OpConfig = {
|
|
61
|
+
name: "op", overview: "o",
|
|
62
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "dup" }, { kind: "activity", fn: "dup" }] }],
|
|
63
|
+
};
|
|
64
|
+
const history: WorkflowHistoryRaw = {
|
|
65
|
+
events: [
|
|
66
|
+
scheduled("1", "dup", sec(0)),
|
|
67
|
+
completed("1", sec(1)), // first "dup": 1000ms, ok
|
|
68
|
+
scheduled("2", "dup", sec(1)),
|
|
69
|
+
failed("2", sec(1.5), "second dup failed"), // second "dup": fails
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
const records = extractStepRecords(config, history);
|
|
73
|
+
expect(records).toEqual([
|
|
74
|
+
{ phase: "P1", fn: "dup", status: "ok", durationMs: 1000 },
|
|
75
|
+
{ phase: "P1", fn: "dup", status: "fail", durationMs: 500, error: "second dup failed" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a step not yet scheduled produces no record unless final", () => {
|
|
80
|
+
const config: OpConfig = {
|
|
81
|
+
name: "op", overview: "o",
|
|
82
|
+
phases: [
|
|
83
|
+
{ name: "P1", steps: [{ kind: "activity", fn: "a" }] },
|
|
84
|
+
{ name: "P2", steps: [{ kind: "activity", fn: "b" }] },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0)), completed("1", sec(1))] };
|
|
88
|
+
|
|
89
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
90
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
91
|
+
]);
|
|
92
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
93
|
+
{ phase: "P1", fn: "a", status: "ok", durationMs: 1000 },
|
|
94
|
+
{ phase: "P2", fn: "b", status: "skipped", durationMs: 0 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a scheduled-but-not-yet-settled step produces no record", () => {
|
|
99
|
+
const config: OpConfig = {
|
|
100
|
+
name: "op", overview: "o",
|
|
101
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "a" }] }],
|
|
102
|
+
};
|
|
103
|
+
const history: WorkflowHistoryRaw = { events: [scheduled("1", "a", sec(0))] };
|
|
104
|
+
expect(extractStepRecords(config, history)).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a retried activity resolves to its last outcome (Failed attempt then eventual Completed)", () => {
|
|
108
|
+
const config: OpConfig = {
|
|
109
|
+
name: "op", overview: "o",
|
|
110
|
+
phases: [{ name: "P1", steps: [{ kind: "activity", fn: "flaky" }] }],
|
|
111
|
+
};
|
|
112
|
+
const history: WorkflowHistoryRaw = {
|
|
113
|
+
events: [
|
|
114
|
+
scheduled("1", "flaky", sec(0)),
|
|
115
|
+
failed("1", sec(1), "attempt 1 failed"),
|
|
116
|
+
completed("1", sec(3)), // eventual success
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
120
|
+
{ phase: "P1", fn: "flaky", status: "ok", durationMs: 3000 },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("effect step: matched receipt — only receiptRead settles; nested + receiptWrite are skipped only when final", () => {
|
|
125
|
+
const receipt: EffectReceiptRef = { name: "r", effect: "e", flavor: "existence", inputs: {} };
|
|
126
|
+
const config: OpConfig = {
|
|
127
|
+
name: "op", overview: "o",
|
|
128
|
+
phases: [{
|
|
129
|
+
name: "Effect",
|
|
130
|
+
steps: [{ kind: "effect", receipt, steps: [{ kind: "activity", fn: "applyIt" }] }],
|
|
131
|
+
}],
|
|
132
|
+
};
|
|
133
|
+
const history: WorkflowHistoryRaw = {
|
|
134
|
+
events: [scheduled("1", "receiptRead", sec(0)), completed("1", sec(1))],
|
|
135
|
+
};
|
|
136
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
137
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
138
|
+
]);
|
|
139
|
+
expect(extractStepRecords(config, history, { final: true })).toEqual([
|
|
140
|
+
{ phase: "Effect", fn: "receiptRead", status: "ok", durationMs: 1000 },
|
|
141
|
+
{ phase: "Effect", fn: "applyIt", status: "skipped", durationMs: 0 },
|
|
142
|
+
{ phase: "Effect", fn: "receiptWrite", status: "skipped", durationMs: 0 },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("onFailure phases are matched in reverse declared order, matching how the workflow actually runs them", () => {
|
|
147
|
+
const config: OpConfig = {
|
|
148
|
+
name: "op", overview: "o",
|
|
149
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] }],
|
|
150
|
+
onFailure: [
|
|
151
|
+
{ name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
|
|
152
|
+
{ name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const history: WorkflowHistoryRaw = {
|
|
156
|
+
events: [
|
|
157
|
+
scheduled("1", "boom", sec(0)),
|
|
158
|
+
failed("1", sec(1), "boom"),
|
|
159
|
+
// Compensation runs C2 then C1 (reverse of declared order).
|
|
160
|
+
scheduled("2", "comp2", sec(2)),
|
|
161
|
+
completed("2", sec(3)),
|
|
162
|
+
scheduled("3", "comp1", sec(3)),
|
|
163
|
+
completed("3", sec(4)),
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
expect(extractStepRecords(config, history)).toEqual([
|
|
167
|
+
{ phase: "Main", fn: "boom", status: "fail", durationMs: 1000, error: "boom" },
|
|
168
|
+
{ phase: "C2", fn: "comp2", status: "ok", durationMs: 1000 },
|
|
169
|
+
{ phase: "C1", fn: "comp1", status: "ok", durationMs: 1000 },
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("countActivities", () => {
|
|
175
|
+
test("counts scheduled and completed events", () => {
|
|
176
|
+
const history: WorkflowHistoryRaw = {
|
|
177
|
+
events: [scheduled("1", "a", sec(0)), scheduled("2", "b", sec(0)), completed("1", sec(1))],
|
|
178
|
+
};
|
|
179
|
+
expect(countActivities(history)).toEqual({ completed: 1, scheduled: 2 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("empty history", () => {
|
|
183
|
+
expect(countActivities({})).toEqual({ completed: 0, scheduled: 0 });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("queryGateState", () => {
|
|
188
|
+
test("returns the query result when the handle supports it", async () => {
|
|
189
|
+
const handle = { query: async () => ({ signalName: "g", since: "t" }) } as unknown as Parameters<typeof queryGateState>[0];
|
|
190
|
+
expect(await queryGateState(handle)).toEqual({ signalName: "g", since: "t" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("returns null when the workflow reports no pending gate", async () => {
|
|
194
|
+
const handle = { query: async () => null } as unknown as Parameters<typeof queryGateState>[0];
|
|
195
|
+
expect(await queryGateState(handle)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns undefined (not an error) when the query isn't registered — e.g. an Op with no gates", async () => {
|
|
199
|
+
const handle = { query: async () => { throw new Error("unregistered query"); } } as unknown as Parameters<typeof queryGateState>[0];
|
|
200
|
+
expect(await queryGateState(handle)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|