@intentius/chant 0.124.24 → 0.124.26
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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +21 -7
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +200 -11
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +5 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +223 -221
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +21 -7
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/effect-receipt.ts +22 -0
- package/src/gate-decision.ts +2 -0
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/builders.ts +21 -1
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +248 -12
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/op/types.ts +32 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OPS017: a gate's aliases (#3949) can stand. An alias admits approvals of
|
|
3
|
+
* the plan a run planned, so the gate must bind a `plan`, and a gate that
|
|
4
|
+
* asks a decision point takes none. An alias must not name a gate that still
|
|
5
|
+
* exists in the build, or one approval would answer two gates, and two gates
|
|
6
|
+
* must not claim one alias. When the workspace's `identity.gates` names an
|
|
7
|
+
* alias, the new gate must be named too, with the alias's class, or
|
|
8
|
+
* approvals that needed a seal would count without one.
|
|
9
|
+
*
|
|
10
|
+
* The identity rules come from the caller, as SYS010's decide backends do:
|
|
11
|
+
* `chant build` and `chant lint` read them with
|
|
12
|
+
* {@link workspaceIdentityGates}, which loads workspace code only when an Op
|
|
13
|
+
* has aliases and the project sits in a workspace. Without them that part
|
|
14
|
+
* is skipped.
|
|
15
|
+
*/
|
|
16
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
|
|
17
|
+
import type { OpConfig } from "../../../op";
|
|
18
|
+
import { gateStepsOf, validateGateAliases } from "../../../op/gate-alias";
|
|
19
|
+
import { findWorkspaceRoot } from "../../../project-root";
|
|
20
|
+
import { isOpEntity } from "./support";
|
|
21
|
+
|
|
22
|
+
/** The Op configs among a build's entities, each with its entity key and lexicon. */
|
|
23
|
+
function opsOf(entities: Iterable<[string, unknown]>): Array<{ entityKey: string; lexicon?: string; config: OpConfig }> {
|
|
24
|
+
const ops: Array<{ entityKey: string; lexicon?: string; config: OpConfig }> = [];
|
|
25
|
+
for (const [entityKey, entity] of entities) {
|
|
26
|
+
if (!isOpEntity(entity as never)) continue;
|
|
27
|
+
const rec = entity as Record<string, unknown>;
|
|
28
|
+
const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
|
|
29
|
+
if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
|
|
30
|
+
ops.push({ entityKey, ...(typeof rec.lexicon === "string" ? { lexicon: rec.lexicon } : {}), config: props });
|
|
31
|
+
}
|
|
32
|
+
return ops;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasAliases = (ops: ReadonlyArray<{ config: OpConfig }>): boolean =>
|
|
36
|
+
ops.some(({ config }) => gateStepsOf(config).some(({ step }) => step.aliases !== undefined));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The gates the workspace around `projectDir` names under `identity.gates`,
|
|
40
|
+
* each with its class (null for any signer), for OPS017. Undefined when no
|
|
41
|
+
* Op among `entities` has aliases, outside a workspace, or when the
|
|
42
|
+
* declaration can't be read, which `chant workspace check` reports. The
|
|
43
|
+
* workspace modules load only past the first two checks, so a level-0
|
|
44
|
+
* build loads none.
|
|
45
|
+
*/
|
|
46
|
+
export async function workspaceIdentityGates(
|
|
47
|
+
projectDir: string,
|
|
48
|
+
entities: Iterable<[string, unknown]>,
|
|
49
|
+
): Promise<Map<string, string | null> | undefined> {
|
|
50
|
+
if (!hasAliases(opsOf(entities))) return undefined;
|
|
51
|
+
const found = findWorkspaceRoot(projectDir);
|
|
52
|
+
if (!found) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const { readDeclaration } = await import("../../../workspace/declaration");
|
|
55
|
+
const { workingTree } = await import("../../../workspace/tree");
|
|
56
|
+
const gates = readDeclaration(workingTree(found.dir)).identity?.gates ?? {};
|
|
57
|
+
return new Map(Object.values(gates).map((g) => [g.gate, g.class]));
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** OPS017, judging `identity.gates` by `identityGates` when given. */
|
|
64
|
+
export function ops017Check(identityGates?: ReadonlyMap<string, string | null>): PostSynthCheck {
|
|
65
|
+
return {
|
|
66
|
+
id: "OPS017",
|
|
67
|
+
description:
|
|
68
|
+
"A gate's aliases need a plan and no decision point, must not name a gate that still exists or another gate's alias, and keep any identity.gates rule an alias had",
|
|
69
|
+
|
|
70
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
71
|
+
const ops = opsOf(ctx.entities);
|
|
72
|
+
if (!hasAliases(ops)) return [];
|
|
73
|
+
const byName = new Map(ops.map((o) => [o.config.name, o]));
|
|
74
|
+
return validateGateAliases(ops.map((o) => o.config), identityGates).map((issue) => {
|
|
75
|
+
const op = byName.get(issue.opName);
|
|
76
|
+
return {
|
|
77
|
+
checkId: "OPS017",
|
|
78
|
+
severity: "error",
|
|
79
|
+
message: `Op "${issue.opName}", phase "${issue.phase}", gate:${issue.gate}: ${issue.message}`,
|
|
80
|
+
...(op ? { entity: op.entityKey } : {}),
|
|
81
|
+
...(op?.lexicon ? { lexicon: op.lexicon } : {}),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** OPS017 without the workspace's identity rules. */
|
|
89
|
+
export const ops017: PostSynthCheck = ops017Check();
|
|
@@ -89,6 +89,26 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
|
|
|
89
89
|
* stack members internally) simply declares none.
|
|
90
90
|
*/
|
|
91
91
|
entities?: string[];
|
|
92
|
+
/**
|
|
93
|
+
* What the activity returns that is secret (#3947): `true` for the whole
|
|
94
|
+
* return value, or the names of its top-level fields that are. A gate that
|
|
95
|
+
* resumes never stores a secret output in its checkpoint, so a gate whose
|
|
96
|
+
* later steps reference one doesn't resume, and OPS016 says so at build.
|
|
97
|
+
*/
|
|
98
|
+
secret?: true | string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a reference into `contract`'s return value at `path` reads a value
|
|
103
|
+
* the contract marks secret (#3947). A whole-value reference reads every
|
|
104
|
+
* field, so it is secret when any field is.
|
|
105
|
+
*/
|
|
106
|
+
export function readsSecretOutput(contract: Pick<ActivityContract, "secret"> | undefined, path?: string): boolean {
|
|
107
|
+
const secret = contract?.secret;
|
|
108
|
+
if (secret === undefined) return false;
|
|
109
|
+
if (secret === true) return true;
|
|
110
|
+
if (!path) return secret.length > 0;
|
|
111
|
+
return secret.includes(path.split(".")[0]!);
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
/** Declare an activity contract. */
|
|
@@ -96,7 +116,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
|
|
|
96
116
|
name: string,
|
|
97
117
|
args: ArgsSchema,
|
|
98
118
|
returns?: ReturnSchema,
|
|
99
|
-
opts?: { entities?: string[] },
|
|
119
|
+
opts?: { entities?: string[]; secret?: true | string[] },
|
|
100
120
|
): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
|
|
101
121
|
return {
|
|
102
122
|
[CONTRACT_BRAND]: true,
|
|
@@ -104,6 +124,7 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
|
|
|
104
124
|
args,
|
|
105
125
|
...(returns ? { returns } : {}),
|
|
106
126
|
...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
|
|
127
|
+
...(opts?.secret === true || (Array.isArray(opts?.secret) && opts.secret.length > 0) ? { secret: opts!.secret } : {}),
|
|
107
128
|
} as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
|
|
108
129
|
}
|
|
109
130
|
|
package/src/op/builders.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receip
|
|
|
5
5
|
import { receiptCheckInput } from "./receipt-store";
|
|
6
6
|
import { gateApprovalProblems } from "./gate-approval";
|
|
7
7
|
import { gatePointProblems } from "./gate-point";
|
|
8
|
+
import { gateAliasProblems } from "./gate-alias";
|
|
8
9
|
import { makeOutProxy, stepOutput, type StepOutputRef, type WithStepRefs } from "./step-output-ref";
|
|
9
10
|
import type { ChantBuildArgs } from "./activities/build";
|
|
10
11
|
import type { ShellCmdArgs } from "./activities/shell";
|
|
@@ -135,10 +136,23 @@ export function activity(
|
|
|
135
136
|
* The point's deciders and quorum decide, `chant workspace points answer`
|
|
136
137
|
* answers an open question, and the run passes on the answer. See
|
|
137
138
|
* {@link GateStep.point}.
|
|
139
|
+
*
|
|
140
|
+
* Pass `aliases` to keep the approvals recorded under the gate's old names
|
|
141
|
+
* after an Op is restructured (#3949): `gate("approve-tier-1", { plan:
|
|
142
|
+
* plan.out.planDigest, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] })`.
|
|
143
|
+
* A gate with aliases must bind a plan. See {@link GateStep.aliases}.
|
|
138
144
|
*/
|
|
139
145
|
export function gate(
|
|
140
146
|
name: string,
|
|
141
|
-
opts?: {
|
|
147
|
+
opts?: {
|
|
148
|
+
timeout?: string;
|
|
149
|
+
description?: string;
|
|
150
|
+
plan?: GateStep["plan"];
|
|
151
|
+
approval?: GateStep["approval"];
|
|
152
|
+
point?: GateStep["point"];
|
|
153
|
+
resume?: boolean;
|
|
154
|
+
aliases?: GateStep["aliases"];
|
|
155
|
+
},
|
|
142
156
|
): GateStep {
|
|
143
157
|
if (opts?.approval !== undefined) {
|
|
144
158
|
const problems = gateApprovalProblems(opts.approval);
|
|
@@ -148,6 +162,10 @@ export function gate(
|
|
|
148
162
|
const problems = gatePointProblems(opts.point, opts.approval !== undefined);
|
|
149
163
|
if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
|
|
150
164
|
}
|
|
165
|
+
if (opts?.aliases !== undefined) {
|
|
166
|
+
const problems = gateAliasProblems(opts.aliases, { plan: opts.plan, point: opts.point });
|
|
167
|
+
if (problems.length > 0) throw new Error(`gate(${JSON.stringify(name)}): ${problems[0]}`);
|
|
168
|
+
}
|
|
151
169
|
return {
|
|
152
170
|
kind: "gate",
|
|
153
171
|
gate: name,
|
|
@@ -156,6 +174,8 @@ export function gate(
|
|
|
156
174
|
...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
|
|
157
175
|
...(opts?.approval !== undefined ? { approval: opts.approval } : {}),
|
|
158
176
|
...(opts?.point !== undefined ? { point: opts.point } : {}),
|
|
177
|
+
...(opts?.resume ? { resume: true } : {}),
|
|
178
|
+
...(opts?.aliases !== undefined && opts.aliases.length > 0 ? { aliases: opts.aliases.map((a) => ({ ...(a.op !== undefined ? { op: a.op } : {}), gate: a.gate })) } : {}),
|
|
159
179
|
};
|
|
160
180
|
}
|
|
161
181
|
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* differing receipts as findings and never writes.
|
|
13
13
|
*/
|
|
14
14
|
import { describe, test, expect, vi } from "vitest";
|
|
15
|
-
import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION } from "../effect-receipt";
|
|
15
|
+
import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION, declaredExpectation, withStepOutputs } from "../effect-receipt";
|
|
16
|
+
import { stepOutput } from "./step-output-ref";
|
|
16
17
|
import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
|
|
17
18
|
import { effect, phase } from "./builders";
|
|
18
19
|
import {
|
|
@@ -324,3 +325,96 @@ describe("runOpLocally — effect steps", () => {
|
|
|
324
325
|
).rejects.toThrow(/parallel phase/);
|
|
325
326
|
});
|
|
326
327
|
});
|
|
328
|
+
|
|
329
|
+
// ── Step outputs inside an effect (#3948) ───────────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe("runOpLocally — an effect's nested steps read step outputs (#3948)", () => {
|
|
332
|
+
function refOp(): OpConfig {
|
|
333
|
+
return {
|
|
334
|
+
name: "seed-op",
|
|
335
|
+
overview: "",
|
|
336
|
+
phases: [
|
|
337
|
+
phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
|
|
338
|
+
phase("Seed", [effect(seeded, [step("runSeed", { file: stepOutput("pick", "file") })])]),
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
test("the resolved value reaches the nested step, and the receipt holds its digest", async () => {
|
|
344
|
+
const { store, values } = memStore();
|
|
345
|
+
const received: unknown[] = [];
|
|
346
|
+
let file = "a.sql";
|
|
347
|
+
const activities = activityMap(store, {
|
|
348
|
+
pick: async () => ({ file }),
|
|
349
|
+
runSeed: async (args) => void received.push(args),
|
|
350
|
+
});
|
|
351
|
+
const first = await runOpLocally(refOp(), activities, PROFILES);
|
|
352
|
+
expect(first.status).toBe("ok");
|
|
353
|
+
expect(received).toEqual([{ file: "a.sql" }]);
|
|
354
|
+
const written = values.get("seeded")!;
|
|
355
|
+
expect(written).not.toBe(receiptExpectation(seeded));
|
|
356
|
+
expect(declaredExpectation(written)).toBe(receiptExpectation(seeded));
|
|
357
|
+
|
|
358
|
+
// The same value: the receipt matches and the effect is skipped.
|
|
359
|
+
const second = await runOpLocally(refOp(), activities, PROFILES);
|
|
360
|
+
expect(received).toHaveLength(1);
|
|
361
|
+
expect(second.records.find((r) => r.fn === "receiptRead")?.outcome).toEqual({ name: "EffectApplied", value: true });
|
|
362
|
+
|
|
363
|
+
// A changed value fires the effect again, though the declared inputs did not change.
|
|
364
|
+
file = "b.sql";
|
|
365
|
+
await runOpLocally(refOp(), activities, PROFILES);
|
|
366
|
+
expect(received).toEqual([{ file: "a.sql" }, { file: "b.sql" }]);
|
|
367
|
+
expect(values.get("seeded")).not.toBe(written);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("a receipt written before the effect read outputs re-fires the effect once", async () => {
|
|
371
|
+
const { store, values } = memStore({ seeded: receiptExpectation(seeded) });
|
|
372
|
+
const ran = vi.fn();
|
|
373
|
+
const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), runSeed: async () => { ran(); } });
|
|
374
|
+
await runOpLocally(refOp(), activities, PROFILES);
|
|
375
|
+
expect(ran).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(declaredExpectation(values.get("seeded")!)).toBe(receiptExpectation(seeded));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("an existence receipt takes no outputs: it says the effect ran once", async () => {
|
|
380
|
+
const booted = EffectReceipt("booted", { effect: "bootstrap", flavor: "existence" });
|
|
381
|
+
const config: OpConfig = {
|
|
382
|
+
name: "boot-op",
|
|
383
|
+
overview: "",
|
|
384
|
+
phases: [
|
|
385
|
+
phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }]),
|
|
386
|
+
phase("Boot", [effect(booted, [step("boot", { file: stepOutput("pick", "file") })])]),
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
const { store, values } = memStore();
|
|
390
|
+
const activities = activityMap(store, { pick: async () => ({ file: "a.sql" }), boot: async () => {} });
|
|
391
|
+
await runOpLocally(config, activities, PROFILES);
|
|
392
|
+
expect(values.get("booted")).toBe(EXISTENCE_EXPECTATION);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("an effect in onFailure whose source step did not complete is skipped whole (A6)", async () => {
|
|
396
|
+
const config: OpConfig = {
|
|
397
|
+
name: "seed-op",
|
|
398
|
+
overview: "",
|
|
399
|
+
phases: [phase("Pick", [{ kind: "activity", fn: "pick", id: "pick" }])],
|
|
400
|
+
onFailure: [phase("Undo", [effect(seeded, [step("unseed", { file: stepOutput("pick", "file") })])])],
|
|
401
|
+
};
|
|
402
|
+
const { store, writes } = memStore();
|
|
403
|
+
const ran = vi.fn();
|
|
404
|
+
const activities = activityMap(store, { pick: async () => { throw new Error("boom"); }, unseed: async () => { ran(); } });
|
|
405
|
+
const failure = (await runOpLocally(config, activities, PROFILES).catch((e) => e)) as OpRunFailure;
|
|
406
|
+
expect(failure).toBeInstanceOf(OpRunFailure);
|
|
407
|
+
expect(ran).not.toHaveBeenCalled();
|
|
408
|
+
expect(writes).toEqual([]);
|
|
409
|
+
const skipped = failure.result.records.find((r) => r.fn === "effect:seeded");
|
|
410
|
+
expect(skipped?.status).toBe("skipped");
|
|
411
|
+
expect(skipped?.refusal).toMatch(/step "pick" did not complete/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("receiptStaleness compares the declared part of a value written with outputs", async () => {
|
|
415
|
+
const expected = receiptExpectation(seeded);
|
|
416
|
+
const { store } = memStore({ seeded: withStepOutputs(expected, "sha256:abc") });
|
|
417
|
+
const { receiptStaleness } = receiptActivities(store);
|
|
418
|
+
expect(await receiptStaleness({ receipts: [receiptCheckInput(seeded)] })).toEqual({ stale: false, findings: [] });
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3949 against a real `chant/lifecycle` branch: an approval recorded under
|
|
3
|
+
* a per-tier Op's gate passes the merged Op's gate that lists it as an
|
|
4
|
+
* alias. The run reads the old Op's ledger file, writes the carried line to
|
|
5
|
+
* its own, and the next run passes on that line alone.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { cleanScratch, commitAll, git, repo } from "../workspace/__fixtures__/contract-repo";
|
|
10
|
+
import { appendGateResolution, appendPendingGate, readGateLedger } from "../lifecycle/gate-ledger";
|
|
11
|
+
import { runOpLocally } from "./local-executor";
|
|
12
|
+
import { gitGateLedgerPort } from "./gate";
|
|
13
|
+
import { stepOutput } from "./step-output-ref";
|
|
14
|
+
import type { ActivityFn } from "./activity-registry";
|
|
15
|
+
import type { OpConfig } from "./types";
|
|
16
|
+
|
|
17
|
+
const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
|
|
18
|
+
|
|
19
|
+
let root: string;
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
root = repo({ "chant.config.json": "{}\n" }, false);
|
|
22
|
+
git(root, "config", "user.name", "Gate Test");
|
|
23
|
+
git(root, "config", "user.email", "gate@example.com");
|
|
24
|
+
commitAll(root, "c0");
|
|
25
|
+
});
|
|
26
|
+
afterAll(cleanScratch);
|
|
27
|
+
|
|
28
|
+
const applied: string[] = [];
|
|
29
|
+
const ACTIVITIES = new Map<string, ActivityFn>([
|
|
30
|
+
["plan", async () => ({ planDigest: PLAN })],
|
|
31
|
+
["apply", async (args) => (applied.push(String((args as { tier: string }).tier)), { ok: true })],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const merged: OpConfig = {
|
|
35
|
+
name: "upgrade",
|
|
36
|
+
overview: "upgrade every tier, one wave each",
|
|
37
|
+
phases: [
|
|
38
|
+
{ name: "Plan", steps: [{ kind: "activity", id: "plan", fn: "plan" }] },
|
|
39
|
+
{
|
|
40
|
+
name: "Wave 1",
|
|
41
|
+
steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }],
|
|
42
|
+
},
|
|
43
|
+
{ name: "Apply 1", steps: [{ kind: "activity", fn: "apply", args: { tier: "1" } }] },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("a gate alias on a real ledger", () => {
|
|
48
|
+
test("the merged Op passes on the old Op's approval, and keeps passing on the carried line", async () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const iso = (s: number) => new Date(now + s * 1000).toISOString();
|
|
51
|
+
await appendPendingGate(
|
|
52
|
+
{ op: "upgrade-tier-1", gate: "approve", timestamp: iso(-60), expiresAt: iso(3600), planDigest: PLAN, runId: "old-run" },
|
|
53
|
+
{ cwd: root },
|
|
54
|
+
);
|
|
55
|
+
await appendGateResolution({ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: iso(-30), planDigest: PLAN }, { cwd: root });
|
|
56
|
+
|
|
57
|
+
const port = gitGateLedgerPort({ cwd: root });
|
|
58
|
+
const first = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-1" });
|
|
59
|
+
expect(first.status).toBe("ok");
|
|
60
|
+
expect(first.records.find((r) => r.fn === "gate:approve-tier-1")?.approval).toMatchObject({ resolvedBy: "alice", via: "alias" });
|
|
61
|
+
|
|
62
|
+
const ledger = await readGateLedger("upgrade", { cwd: root });
|
|
63
|
+
expect(ledger.resolutions).toHaveLength(1);
|
|
64
|
+
expect(ledger.resolutions[0]).toMatchObject({
|
|
65
|
+
op: "upgrade",
|
|
66
|
+
gate: "approve-tier-1",
|
|
67
|
+
resolvedBy: "alice",
|
|
68
|
+
planDigest: PLAN,
|
|
69
|
+
carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: iso(-30) },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const second = await runOpLocally(merged, ACTIVITIES, {}, undefined, { cwd: root, gates: port, runId: "run-2" });
|
|
73
|
+
expect(second.status).toBe("ok");
|
|
74
|
+
expect(second.records.find((r) => r.fn === "gate:approve-tier-1")?.approval?.via).toBeUndefined();
|
|
75
|
+
expect((await readGateLedger("upgrade", { cwd: root })).resolutions).toHaveLength(1);
|
|
76
|
+
expect(applied).toEqual(["1", "1"]);
|
|
77
|
+
});
|
|
78
|
+
});
|