@intentius/chant 0.90.0 → 0.91.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/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +6 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -2
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/workspace-tools.test.ts +14 -1
- package/src/cli/mcp/workspace-tools.ts +49 -5
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +33 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records.schema.json +17 -0
- package/src/workspace/records.ts +33 -3
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
package/src/op/discover.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { OpConfig } from "./types";
|
|
|
5
5
|
import { walkDiscovery, workspaceMemberDirs } from "../discovery/walk";
|
|
6
6
|
import { hasDiscoveryMarkerSync } from "../discovery/files";
|
|
7
7
|
import { resolveDiscoveryGlobs } from "../config";
|
|
8
|
+
import { isStewardDeclaration, type StewardDeclaration } from "./steward";
|
|
8
9
|
|
|
9
10
|
export interface DiscoveredOp {
|
|
10
11
|
config: OpConfig;
|
|
@@ -152,6 +153,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
|
|
|
152
153
|
const mod = (await import(filePath)) as Record<string, unknown>;
|
|
153
154
|
const exported = opsExportedBy(mod);
|
|
154
155
|
|
|
156
|
+
// A file may hold a steward and no Op (#2731): the steward sits beside
|
|
157
|
+
// the Ops it runs, and `discoverStewards` reads it from here.
|
|
158
|
+
if (exported.length === 0 && Object.values(mod).some(isStewardDeclaration)) continue;
|
|
155
159
|
if (exported.length === 0) {
|
|
156
160
|
errors.push(
|
|
157
161
|
`${filePath}: exports no Op. Expected \`export default Op({...})\` or a named export such as \`export const deploy = Op({...})\``,
|
|
@@ -180,3 +184,70 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
|
|
|
180
184
|
|
|
181
185
|
return { ops, errors };
|
|
182
186
|
}
|
|
187
|
+
|
|
188
|
+
export interface DiscoveredSteward {
|
|
189
|
+
declaration: StewardDeclaration;
|
|
190
|
+
filePath: string;
|
|
191
|
+
exportName: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface StewardDiscoveryResult {
|
|
195
|
+
stewards: Map<string, DiscoveredSteward>;
|
|
196
|
+
/** Files that could not be imported. */
|
|
197
|
+
errors: string[];
|
|
198
|
+
/** Stewards dropped because they would be a second writer: a name or an Op another steward already has. */
|
|
199
|
+
conflicts: string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every steward declared in the project's `*.op.ts` files (#2731), found the
|
|
204
|
+
* way {@link discoverOps} finds Ops, keyed by the steward's name.
|
|
205
|
+
*
|
|
206
|
+
* Refuses, as errors, the two ways a project could end up with two writers:
|
|
207
|
+
* two stewards of one name, and one Op listed by two stewards. The steward
|
|
208
|
+
* declared first (in file walk order) keeps its entry; the other is dropped
|
|
209
|
+
* and reported in `conflicts`.
|
|
210
|
+
*/
|
|
211
|
+
export async function discoverStewards(opts?: { cwd?: string }): Promise<StewardDiscoveryResult> {
|
|
212
|
+
const errors: string[] = [];
|
|
213
|
+
const conflicts: string[] = [];
|
|
214
|
+
const stewards = new Map<string, DiscoveredSteward>();
|
|
215
|
+
const opOwner = new Map<string, string>();
|
|
216
|
+
|
|
217
|
+
const root = await findDiscoveryRoot(opts?.cwd);
|
|
218
|
+
const files = await collectOpFiles(root);
|
|
219
|
+
|
|
220
|
+
for (const filePath of files) {
|
|
221
|
+
let mod: Record<string, unknown>;
|
|
222
|
+
try {
|
|
223
|
+
mod = (await import(filePath)) as Record<string, unknown>;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const seen = new Set<unknown>();
|
|
229
|
+
const exportNames = ["default", ...Object.keys(mod).filter((k) => k !== "default")];
|
|
230
|
+
for (const exportName of exportNames) {
|
|
231
|
+
const value = mod[exportName];
|
|
232
|
+
if (!isStewardDeclaration(value) || seen.has(value)) continue;
|
|
233
|
+
seen.add(value);
|
|
234
|
+
const prior = stewards.get(value.name);
|
|
235
|
+
if (prior) {
|
|
236
|
+
conflicts.push(`Duplicate steward "${value.name}" in ${filePath} and ${prior.filePath}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const claimed = value.ops.find((op) => opOwner.has(op.name));
|
|
240
|
+
if (claimed) {
|
|
241
|
+
conflicts.push(
|
|
242
|
+
`${filePath}: steward "${value.name}" lists op "${claimed.name}", which steward "${opOwner.get(claimed.name)}" already runs. ` +
|
|
243
|
+
`Two stewards running one Op are two writers; list it on one of them.`,
|
|
244
|
+
);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const op of value.ops) opOwner.set(op.name, value.name);
|
|
248
|
+
stewards.set(value.name, { declaration: value, filePath, exportName });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { stewards, errors, conflicts };
|
|
253
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -15,8 +15,12 @@ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-life
|
|
|
15
15
|
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
|
|
16
16
|
export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
|
|
17
17
|
export type { FreshnessResult } from "./emulator-freshness";
|
|
18
|
-
export type { OpConfig, OpSchedule, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
|
|
19
|
-
export { outcomeAttributesOf } from "./types";
|
|
18
|
+
export type { OpConfig, OpSchedule, OpWorkLease, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
|
|
19
|
+
export { outcomeAttributesOf, WORK_LEASE_STEP_ID } from "./types";
|
|
20
|
+
export {
|
|
21
|
+
RunWorkLease, workLeaseOutput, stewardWorkHolder, workLeaseProblems, workLeaseNeedsRunItem, LEASE_LOST, WORK_BRANCH_PREFIX,
|
|
22
|
+
} from "./work-lease-run";
|
|
23
|
+
export type { WorkLeaseOutput, WorkLeaseRunResult, WorkClaimOutcome, RunWorkLeaseOptions } from "./work-lease-run";
|
|
20
24
|
export { isValidCronExpression, cronSyntaxMessage, cronMatches, cronDueBetween } from "./cron";
|
|
21
25
|
export {
|
|
22
26
|
WatchOp, ReconcileOp, ApplyOp, ConvergeOp,
|
|
@@ -100,8 +104,17 @@ export type {
|
|
|
100
104
|
} from "./converge-rule";
|
|
101
105
|
export {
|
|
102
106
|
discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
|
|
103
|
-
formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS,
|
|
107
|
+
formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS, acquireStewardLease,
|
|
104
108
|
} from "./operator";
|
|
109
|
+
export {
|
|
110
|
+
declareSteward, isStewardDeclaration, stewardFormFor, stewardOpConfig, normaliseStewardForm, stewardLeaseName,
|
|
111
|
+
STEWARD_KIND, STEWARD_FORMS, STEWARD_NAME_PATTERN, DEFAULT_STEWARD_ENV,
|
|
112
|
+
} from "./steward";
|
|
113
|
+
export type {
|
|
114
|
+
StewardDeclaration, StewardDeclarationConfig, StewardForm, StewardFormSpec, StewardOpInput,
|
|
115
|
+
} from "./steward";
|
|
116
|
+
export { discoverStewards } from "./discover";
|
|
117
|
+
export type { DiscoveredSteward, StewardDiscoveryResult } from "./discover";
|
|
105
118
|
export type {
|
|
106
119
|
OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions,
|
|
107
120
|
ChangeSubscriber, OperatorSignalEvent,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { lifecycleReceiptFile, lifecycleReceiptStore } from "./lifecycle-receipt-store";
|
|
7
|
+
import { receiptActivities, type EffectReceiptRef } from "./receipt-store";
|
|
8
|
+
|
|
9
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
10
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
11
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function initRepo(dir: string): void {
|
|
15
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
16
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
17
|
+
git(["config", "user.name", "Test"], dir);
|
|
18
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
19
|
+
git(["add", "README.md"], dir);
|
|
20
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RECEIPT: EffectReceiptRef = { name: "fly-migration:shop/001_init.sql", effect: "fly-migrate", flavor: "hash", inputs: {} };
|
|
24
|
+
|
|
25
|
+
describe("lifecycleReceiptStore", () => {
|
|
26
|
+
test("a receipt is absent, then written under <env>/receipts on chant/lifecycle", async () => {
|
|
27
|
+
await withTestDir(async (dir) => {
|
|
28
|
+
initRepo(dir);
|
|
29
|
+
const store = lifecycleReceiptStore({ environment: "prod", cwd: dir });
|
|
30
|
+
expect(await store.read(RECEIPT)).toBeUndefined();
|
|
31
|
+
await store.write(RECEIPT, "sha256:abc");
|
|
32
|
+
expect(await store.read(RECEIPT)).toBe("sha256:abc");
|
|
33
|
+
const shown = git(["show", `chant/lifecycle:prod/${lifecycleReceiptFile(RECEIPT.name)}`], dir);
|
|
34
|
+
expect(shown.exitCode).toBe(0);
|
|
35
|
+
expect(shown.stdout.trim()).toBe("sha256:abc");
|
|
36
|
+
// Another environment has its own receipts.
|
|
37
|
+
expect(await lifecycleReceiptStore({ environment: "dev", cwd: dir }).read(RECEIPT)).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("bound to core's receipt activities, the effect step's read-compare-write goes through it", async () => {
|
|
42
|
+
await withTestDir(async (dir) => {
|
|
43
|
+
initRepo(dir);
|
|
44
|
+
const acts = receiptActivities(lifecycleReceiptStore({ environment: "prod", cwd: dir }));
|
|
45
|
+
const first = await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" });
|
|
46
|
+
expect(first).toEqual({ current: null, expectation: "sha256:1", applied: false });
|
|
47
|
+
await acts.receiptWrite({ receipt: RECEIPT, expectation: "sha256:1" });
|
|
48
|
+
expect((await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" })).applied).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("with no environment and no CHANT_ENV it refuses rather than guessing", async () => {
|
|
53
|
+
const store = lifecycleReceiptStore({ env: {} });
|
|
54
|
+
await expect(store.read(RECEIPT)).rejects.toThrow(/no environment/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("receipt names become safe file names", () => {
|
|
58
|
+
expect(lifecycleReceiptFile("fly-migration:app/002 add.sql")).toBe("receipts/fly-migration_app_002_add.sql.receipt");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A receipt store on the `chant/lifecycle` branch (#2736, ws-056).
|
|
3
|
+
*
|
|
4
|
+
* The aws and k8s rows keep receipts in the cloud they deploy to (an SSM
|
|
5
|
+
* parameter, a ConfigMap). A target with no store of its own, such as a Fly
|
|
6
|
+
* Machine, keeps them next to the release ledger instead: one file per receipt
|
|
7
|
+
* at `<env>/receipts/<name>.receipt` on the lifecycle branch, written through
|
|
8
|
+
* the same plumbing as the release and build ledgers (../lifecycle/git.ts), so
|
|
9
|
+
* a workspace member's receipts land under its own `_members/<member>/` prefix.
|
|
10
|
+
*
|
|
11
|
+
* The receipt is per environment, not per checkout: every checkout that
|
|
12
|
+
* fetches the branch sees which effects have fired there. Writing is local;
|
|
13
|
+
* the ledger push that follows a recorded release (or `chant lifecycle push`)
|
|
14
|
+
* carries it to the remote.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readBlobFromPath, writeBlobToPath } from "../lifecycle/git";
|
|
18
|
+
import type { EffectReceiptRef, ReceiptStore } from "./receipt-store";
|
|
19
|
+
|
|
20
|
+
/** Options for {@link lifecycleReceiptStore}. */
|
|
21
|
+
export interface LifecycleReceiptStoreOptions {
|
|
22
|
+
/** The environment whose ledger directory holds the receipts. Omitted, `CHANT_ENV` answers. */
|
|
23
|
+
environment?: string;
|
|
24
|
+
/** The checkout whose lifecycle branch is written. Defaults to the working directory. */
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** Environment record the `CHANT_ENV` fallback reads. Defaults to `process.env`. */
|
|
27
|
+
env?: Record<string, string | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The file name a receipt is kept under: its name with anything outside `[A-Za-z0-9_.-]` replaced. */
|
|
31
|
+
export function lifecycleReceiptFile(name: string): string {
|
|
32
|
+
return `receipts/${name.replace(/[^a-zA-Z0-9_.-]/g, "_")}.receipt`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A {@link ReceiptStore} over the lifecycle branch. The environment is resolved
|
|
37
|
+
* at first use, so constructing the store reads nothing; with none given and no
|
|
38
|
+
* `CHANT_ENV`, a read or write fails rather than guessing a directory.
|
|
39
|
+
*/
|
|
40
|
+
export function lifecycleReceiptStore(options: LifecycleReceiptStoreOptions = {}): ReceiptStore {
|
|
41
|
+
const environment = (): string => {
|
|
42
|
+
const env = options.environment ?? (options.env ?? process.env).CHANT_ENV;
|
|
43
|
+
if (!env) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"lifecycle receipt store: no environment. Receipts are kept per environment on chant/lifecycle; " +
|
|
46
|
+
"run with --env <name>, set CHANT_ENV, or pass the environment explicitly.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return env;
|
|
50
|
+
};
|
|
51
|
+
const opts = options.cwd ? { cwd: options.cwd } : undefined;
|
|
52
|
+
return {
|
|
53
|
+
async read(receipt: EffectReceiptRef): Promise<string | undefined> {
|
|
54
|
+
const content = await readBlobFromPath(environment(), lifecycleReceiptFile(receipt.name), opts);
|
|
55
|
+
return content === null ? undefined : content.trim();
|
|
56
|
+
},
|
|
57
|
+
async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
|
|
58
|
+
await writeBlobToPath(environment(), lifecycleReceiptFile(receipt.name), `${expectation}\n`, `Receipt ${receipt.name}`, opts);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/op/local-executor.ts
CHANGED
|
@@ -32,6 +32,8 @@ import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
|
32
32
|
import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
|
|
33
33
|
import type { OpRunRecord } from "./runtime";
|
|
34
34
|
import { randomUUID } from "node:crypto";
|
|
35
|
+
import { RunWorkLease, LEASE_LOST, workLeaseProblems, workLeaseNeedsRunItem, type WorkLeaseRunResult } from "./work-lease-run";
|
|
36
|
+
import { WORK_LEASE_STEP_ID } from "./types";
|
|
35
37
|
|
|
36
38
|
export { parseDuration } from "./duration";
|
|
37
39
|
|
|
@@ -110,6 +112,12 @@ export interface OpRunResult {
|
|
|
110
112
|
* document.
|
|
111
113
|
*/
|
|
112
114
|
record: OpRunRecord;
|
|
115
|
+
/**
|
|
116
|
+
* How the run's work lease went, for an Op that declares `workLease`
|
|
117
|
+
* (#2748): the item it claimed, or why it claimed none, whether the lease
|
|
118
|
+
* was released and with which outcome, or why it was lost.
|
|
119
|
+
*/
|
|
120
|
+
workLease?: WorkLeaseRunResult;
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
// ── Errors ────────────────────────────────────────────────────────────────��─
|
|
@@ -165,6 +173,23 @@ class GateStop extends Error {
|
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Internal: a step boundary found the run can't go on under its work lease
|
|
178
|
+
* (#2748). `lost` false is a claim that found nothing to claim: not a
|
|
179
|
+
* failure, the run ends `ok` with the rest skipped. `lost` true is a lease
|
|
180
|
+
* that was lost: the run fails with `lease-lost`.
|
|
181
|
+
*/
|
|
182
|
+
class WorkStop extends Error {
|
|
183
|
+
constructor(
|
|
184
|
+
public readonly records: StepRecord[],
|
|
185
|
+
public readonly phase: string,
|
|
186
|
+
public readonly lost: boolean,
|
|
187
|
+
) {
|
|
188
|
+
super(lost ? LEASE_LOST : "nothing to claim");
|
|
189
|
+
this.name = "WorkStop";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
// ── Helpers ───────────────────────────────────────────────────────────────��─
|
|
169
194
|
|
|
170
195
|
const DEFAULT_PROFILE = "fastIdempotent";
|
|
@@ -391,6 +416,68 @@ interface GateContext {
|
|
|
391
416
|
runId?: string;
|
|
392
417
|
/** Called once per settled step, in production order (#2121) — what `--progress-json` streams from. */
|
|
393
418
|
onRecord?: (record: StepRecord) => void;
|
|
419
|
+
/** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
|
|
420
|
+
work?: RunWorkLease;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The work lease at a step boundary (#2748): claim it when it is due, and
|
|
425
|
+
* say whether the run can go on. The claim's record is pushed onto `sink`.
|
|
426
|
+
* Returns `undefined` to go on, `"unclaimed"` when nothing could be claimed,
|
|
427
|
+
* and `"lost"` when the lease was lost.
|
|
428
|
+
*/
|
|
429
|
+
async function workBoundary(
|
|
430
|
+
phaseName: string,
|
|
431
|
+
ctx: GateContext,
|
|
432
|
+
resultsById: Map<string, unknown>,
|
|
433
|
+
sink: StepRecord[],
|
|
434
|
+
): Promise<"unclaimed" | "lost" | undefined> {
|
|
435
|
+
const work = ctx.work;
|
|
436
|
+
if (!work) return undefined;
|
|
437
|
+
if (work.due(resultsById)) {
|
|
438
|
+
const start = Date.now();
|
|
439
|
+
let claimed: Awaited<ReturnType<RunWorkLease["claim"]>>;
|
|
440
|
+
try {
|
|
441
|
+
claimed = await work.claim(resultsById);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
pushRecord(sink, ctx, {
|
|
444
|
+
phase: phaseName,
|
|
445
|
+
fn: "workLease:claim",
|
|
446
|
+
args: {},
|
|
447
|
+
status: "fail",
|
|
448
|
+
durationMs: Date.now() - start,
|
|
449
|
+
error: errMessage(err),
|
|
450
|
+
});
|
|
451
|
+
throw new PhaseFailure(sink);
|
|
452
|
+
}
|
|
453
|
+
if (claimed.kind === "unclaimed") {
|
|
454
|
+
pushRecord(sink, ctx, {
|
|
455
|
+
phase: phaseName,
|
|
456
|
+
fn: "workLease:claim",
|
|
457
|
+
args: { items: claimed.tried },
|
|
458
|
+
status: "skipped",
|
|
459
|
+
durationMs: Date.now() - start,
|
|
460
|
+
refusal: claimed.refusal,
|
|
461
|
+
outcome: { name: "WorkItem", value: null },
|
|
462
|
+
outcomes: [{ name: "WorkItem", value: null }],
|
|
463
|
+
});
|
|
464
|
+
return "unclaimed";
|
|
465
|
+
}
|
|
466
|
+
resultsById.set(WORK_LEASE_STEP_ID, claimed.lease);
|
|
467
|
+
pushRecord(sink, ctx, {
|
|
468
|
+
phase: phaseName,
|
|
469
|
+
fn: "workLease:claim",
|
|
470
|
+
args: { items: claimed.tried },
|
|
471
|
+
status: "ok",
|
|
472
|
+
durationMs: Date.now() - start,
|
|
473
|
+
outcome: { name: "WorkItem", value: claimed.lease.item },
|
|
474
|
+
outcomes: [
|
|
475
|
+
{ name: "WorkItem", value: claimed.lease.item },
|
|
476
|
+
{ name: "WorkLeaseToken", value: claimed.lease.token },
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return work.lost !== undefined ? "lost" : undefined;
|
|
394
481
|
}
|
|
395
482
|
|
|
396
483
|
/** Collect records and hand each to the caller's progress sink in one move. */
|
|
@@ -666,6 +753,13 @@ async function runPhase(
|
|
|
666
753
|
// read of the ledger, not work, and starting activities that a pending
|
|
667
754
|
// gate is about to strand would defeat the point of stopping at it.
|
|
668
755
|
const gateRecords: StepRecord[] = [];
|
|
756
|
+
const stop = await workBoundary(phase.name, gates, resultsById, gateRecords);
|
|
757
|
+
if (stop) {
|
|
758
|
+
for (const skipped of phase.steps) {
|
|
759
|
+
pushRecord(gateRecords, gates, isGate(skipped) ? skippedRecord(phase.name, gateFn(skipped)) : skippedRecord(phase.name, (skipped as ActivityStep).fn, (skipped as ActivityStep).args));
|
|
760
|
+
}
|
|
761
|
+
throw new WorkStop(gateRecords, phase.name, stop === "lost");
|
|
762
|
+
}
|
|
669
763
|
for (const step of phase.steps.filter(isGate)) {
|
|
670
764
|
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
671
765
|
pushRecord(gateRecords, gates, record);
|
|
@@ -711,6 +805,13 @@ async function runPhase(
|
|
|
711
805
|
|
|
712
806
|
for (let i = 0; i < steps.length; i++) {
|
|
713
807
|
const step = steps[i];
|
|
808
|
+
// The work lease (#2748): claimed here when it is due, and checked before
|
|
809
|
+
// every step, so no step starts once the lease is lost.
|
|
810
|
+
const stop = await workBoundary(phase.name, gates, resultsById, records);
|
|
811
|
+
if (stop) {
|
|
812
|
+
skipRemaining(i);
|
|
813
|
+
throw new WorkStop(records, phase.name, stop === "lost");
|
|
814
|
+
}
|
|
714
815
|
if (isGate(step)) {
|
|
715
816
|
const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
|
|
716
817
|
pushRecord(records, gates, record);
|
|
@@ -789,6 +890,15 @@ export interface RunOpOptions {
|
|
|
789
890
|
* never promoted into a run failure.
|
|
790
891
|
*/
|
|
791
892
|
onLedgerError?: (err: unknown) => void;
|
|
893
|
+
/**
|
|
894
|
+
* For an Op that declares `workLease` (#2748): the item this run is for
|
|
895
|
+
* (`chant run <op> --work <id>`), which takes the place of the Op's own, and
|
|
896
|
+
* who holds the lease (a steward's turn passes `stewardWorkHolder`).
|
|
897
|
+
* Defaults: the Op's item, and this process's holder id.
|
|
898
|
+
*/
|
|
899
|
+
work?: { item?: string; holder?: string };
|
|
900
|
+
/** Called when a work-lease renewal fails without losing the lease; the next beat retries it. */
|
|
901
|
+
onWorkLeaseWarning?: (message: string) => void;
|
|
792
902
|
}
|
|
793
903
|
|
|
794
904
|
/**
|
|
@@ -839,6 +949,68 @@ export async function runOpLocally(
|
|
|
839
949
|
}
|
|
840
950
|
}
|
|
841
951
|
|
|
952
|
+
// The work lease (#2748). An Op that changes the checkout without one, or
|
|
953
|
+
// whose lease names no item and whose run names none, is refused before
|
|
954
|
+
// anything runs.
|
|
955
|
+
const workProblems = workLeaseProblems(config);
|
|
956
|
+
if (workProblems.length > 0) throw new Error(workProblems.join("\n"));
|
|
957
|
+
if (workLeaseNeedsRunItem(config) && !options.work?.item) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`Op "${config.name}" runs under a work item's lease and names no item: pass one with \`chant run ${config.name} --work <id>\``,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const work = config.workLease
|
|
963
|
+
? new RunWorkLease(config, {
|
|
964
|
+
cwd: options.ledger?.cwd ?? options.cwd ?? process.cwd(),
|
|
965
|
+
...(options.work?.holder ? { holder: options.work.holder } : {}),
|
|
966
|
+
...(options.work?.item ? { item: options.work.item } : {}),
|
|
967
|
+
...(options.onWorkLeaseWarning ? { onRenewError: options.onWorkLeaseWarning } : {}),
|
|
968
|
+
})
|
|
969
|
+
: undefined;
|
|
970
|
+
if (work) gates.work = work;
|
|
971
|
+
|
|
972
|
+
// Steps run under a signal that also fires when the work lease is lost, so
|
|
973
|
+
// the step in flight stops (chud's dispatcher stopped its agent the same way).
|
|
974
|
+
let stepSignal = signal;
|
|
975
|
+
if (work) {
|
|
976
|
+
const both = new AbortController();
|
|
977
|
+
const forward = () => both.abort();
|
|
978
|
+
if (signal?.aborted) both.abort();
|
|
979
|
+
else signal?.addEventListener("abort", forward, { once: true });
|
|
980
|
+
work.lostSignal.addEventListener("abort", forward, { once: true });
|
|
981
|
+
stepSignal = both.signal;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** Release (or not) the work lease once the run's outcome is settled. */
|
|
985
|
+
const finishWork = async (status: OpRunResult["status"]): Promise<{ workLease?: WorkLeaseRunResult }> =>
|
|
986
|
+
work ? { workLease: await work.finish(status, resultsById) } : {};
|
|
987
|
+
|
|
988
|
+
/** The record a lost lease leaves, naming why. */
|
|
989
|
+
const lostRecord = (): StepRecord => ({
|
|
990
|
+
phase: UNATTRIBUTED_PHASE,
|
|
991
|
+
fn: "workLease:lost",
|
|
992
|
+
args: {},
|
|
993
|
+
status: "fail",
|
|
994
|
+
durationMs: 0,
|
|
995
|
+
error: `${LEASE_LOST}: ${work?.lost ?? "the lease was lost"}. The run stopped and was not recorded as done.`,
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
const skipLaterPhases = (after: string) => {
|
|
999
|
+
const stoppedAt = config.phases.findIndex((p) => p.name === after);
|
|
1000
|
+
if (stoppedAt < 0) return;
|
|
1001
|
+
for (const phase of config.phases.slice(stoppedAt + 1)) {
|
|
1002
|
+
for (const step of phase.steps) {
|
|
1003
|
+
records.push(
|
|
1004
|
+
isEffect(step)
|
|
1005
|
+
? skippedRecord(phase.name, `effect:${step.receipt.name}`)
|
|
1006
|
+
: isGate(step)
|
|
1007
|
+
? skippedRecord(phase.name, gateFn(step))
|
|
1008
|
+
: skippedRecord(phase.name, step.fn, step.args),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
842
1014
|
const records: StepRecord[] = [];
|
|
843
1015
|
const start = Date.now();
|
|
844
1016
|
const startedAt = options.now ?? new Date(start).toISOString();
|
|
@@ -877,25 +1049,35 @@ export async function runOpLocally(
|
|
|
877
1049
|
try {
|
|
878
1050
|
for (const phase of config.phases) {
|
|
879
1051
|
if (signal?.aborted) throw new PhaseFailure([]);
|
|
880
|
-
records.push(...(await runPhase(phase, activities, profiles, resultsById, gates,
|
|
1052
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, gates, stepSignal)));
|
|
881
1053
|
}
|
|
1054
|
+
// The last renewal before the run is recorded (#2748): a run whose lease
|
|
1055
|
+
// was lost is never recorded as done.
|
|
1056
|
+
if (work && !(await work.fence())) throw new WorkStop([], UNATTRIBUTED_PHASE, true);
|
|
882
1057
|
} catch (err) {
|
|
1058
|
+
// Nothing to claim, or every candidate held (#2748): not a failure. The
|
|
1059
|
+
// rest of the run is skipped and the claim's record says why.
|
|
1060
|
+
if (err instanceof WorkStop && !err.lost) {
|
|
1061
|
+
records.push(...err.records);
|
|
1062
|
+
skipLaterPhases(err.phase);
|
|
1063
|
+
const record = await settle(records, "ok");
|
|
1064
|
+
return {
|
|
1065
|
+
op: config.name,
|
|
1066
|
+
records,
|
|
1067
|
+
totalMs: Date.now() - start,
|
|
1068
|
+
status: "ok",
|
|
1069
|
+
startedAt,
|
|
1070
|
+
record,
|
|
1071
|
+
...(await finishWork("ok")),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
883
1075
|
// A pending gate ends the run where it stands: no later phase, and no
|
|
884
1076
|
// `onFailure` compensation — nothing failed, so there is nothing to undo.
|
|
885
1077
|
if (err instanceof GateStop) {
|
|
886
1078
|
records.push(...err.records);
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
for (const step of phase.steps) {
|
|
890
|
-
records.push(
|
|
891
|
-
isEffect(step)
|
|
892
|
-
? skippedRecord(phase.name, `effect:${step.receipt.name}`)
|
|
893
|
-
: isGate(step)
|
|
894
|
-
? skippedRecord(phase.name, gateFn(step))
|
|
895
|
-
: skippedRecord(phase.name, step.fn, step.args),
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
1079
|
+
skipLaterPhases(err.phase);
|
|
1080
|
+
const record = await settle(records, "gated", err.pending);
|
|
899
1081
|
return {
|
|
900
1082
|
op: config.name,
|
|
901
1083
|
records,
|
|
@@ -905,11 +1087,17 @@ export async function runOpLocally(
|
|
|
905
1087
|
gate: err.pending,
|
|
906
1088
|
...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
|
|
907
1089
|
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
908
|
-
record
|
|
1090
|
+
record,
|
|
1091
|
+
...(await finishWork("gated")),
|
|
909
1092
|
};
|
|
910
1093
|
}
|
|
911
1094
|
|
|
912
|
-
if (err instanceof
|
|
1095
|
+
if (err instanceof WorkStop) {
|
|
1096
|
+
// The lease was lost (#2748): the rest is skipped, and the lost record
|
|
1097
|
+
// below says why.
|
|
1098
|
+
records.push(...err.records);
|
|
1099
|
+
skipLaterPhases(err.phase);
|
|
1100
|
+
} else if (err instanceof PhaseFailure) {
|
|
913
1101
|
records.push(...err.records);
|
|
914
1102
|
} else {
|
|
915
1103
|
// Everything else the phase loop can throw (#2301). `PhaseFailure` and
|
|
@@ -934,12 +1122,18 @@ export async function runOpLocally(
|
|
|
934
1122
|
});
|
|
935
1123
|
}
|
|
936
1124
|
|
|
1125
|
+
// A step that failed because the lease was lost mid-step, or a lease lost
|
|
1126
|
+
// at any boundary: one record names it (#2748).
|
|
1127
|
+
if (work?.lost !== undefined) records.push(lostRecord());
|
|
1128
|
+
|
|
937
1129
|
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
938
1130
|
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
1131
|
+
// It runs whatever the work lease did, so it takes no work boundary.
|
|
1132
|
+
const compensation: GateContext = { ...gates, work: undefined };
|
|
939
1133
|
if (!signal?.aborted) {
|
|
940
1134
|
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
941
1135
|
try {
|
|
942
|
-
records.push(...(await runPhase(phase, activities, profiles, resultsById,
|
|
1136
|
+
records.push(...(await runPhase(phase, activities, profiles, resultsById, compensation, signal)));
|
|
943
1137
|
} catch (compErr) {
|
|
944
1138
|
if (compErr instanceof PhaseFailure || compErr instanceof GateStop) {
|
|
945
1139
|
records.push(...compErr.records);
|
|
@@ -959,6 +1153,7 @@ export async function runOpLocally(
|
|
|
959
1153
|
}
|
|
960
1154
|
}
|
|
961
1155
|
|
|
1156
|
+
const failed = await settle(records, "fail");
|
|
962
1157
|
throw new OpRunFailure(
|
|
963
1158
|
{
|
|
964
1159
|
op: config.name,
|
|
@@ -966,19 +1161,22 @@ export async function runOpLocally(
|
|
|
966
1161
|
totalMs: Date.now() - start,
|
|
967
1162
|
status: "fail",
|
|
968
1163
|
startedAt,
|
|
969
|
-
record:
|
|
1164
|
+
record: failed,
|
|
1165
|
+
...(await finishWork("fail")),
|
|
970
1166
|
},
|
|
971
1167
|
{ cause: err },
|
|
972
1168
|
);
|
|
973
1169
|
}
|
|
974
1170
|
|
|
1171
|
+
const record = await settle(records, "ok");
|
|
975
1172
|
return {
|
|
976
1173
|
op: config.name,
|
|
977
1174
|
records,
|
|
978
1175
|
totalMs: Date.now() - start,
|
|
979
1176
|
status: "ok",
|
|
980
1177
|
startedAt,
|
|
981
|
-
record
|
|
1178
|
+
record,
|
|
1179
|
+
...(await finishWork("ok")),
|
|
982
1180
|
};
|
|
983
1181
|
}
|
|
984
1182
|
|
package/src/op/local-output.ts
CHANGED
|
@@ -55,6 +55,19 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// The work lease (#2748): which item the run held, and how it ended.
|
|
59
|
+
const work = result.workLease;
|
|
60
|
+
if (work?.item) {
|
|
61
|
+
const end = work.lost
|
|
62
|
+
? `lost (${work.lost})`
|
|
63
|
+
: work.released
|
|
64
|
+
? `released ${work.outcome}`
|
|
65
|
+
: "not released; it runs out on its own";
|
|
66
|
+
write(`[work] ${work.item} held by ${work.holder}${work.branch ? ` on ${work.branch}` : ""}: ${end}`);
|
|
67
|
+
} else if (work?.refusal) {
|
|
68
|
+
write(`[work] nothing claimed: ${work.refusal}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const total = `${(result.totalMs / 1000).toFixed(1)}s`;
|
|
59
72
|
if (result.status === "ok") {
|
|
60
73
|
write(`Op "${result.op}" completed in ${total}`);
|
package/src/op/op-ir.ts
CHANGED
|
@@ -86,6 +86,7 @@ import type {
|
|
|
86
86
|
OutcomeAttribute,
|
|
87
87
|
OpConfig,
|
|
88
88
|
OpSchedule,
|
|
89
|
+
OpWorkLease,
|
|
89
90
|
PhaseDefinition,
|
|
90
91
|
StepDefinition,
|
|
91
92
|
ActivityStep,
|
|
@@ -187,6 +188,15 @@ export interface OpIR {
|
|
|
187
188
|
* additive optional key, so it needs no `formatVersion` bump.
|
|
188
189
|
*/
|
|
189
190
|
schedule?: OpSchedule;
|
|
191
|
+
/**
|
|
192
|
+
* The work item the Op runs under (#2748) — `OpConfig.workLease` verbatim,
|
|
193
|
+
* a step-output reference in `item` or `outcome` in the same
|
|
194
|
+
* `{ kind: "step-output-ref", step, path }` shape as in args. Absent when it
|
|
195
|
+
* declares none. A runtime that can't take the lease must not run the Op.
|
|
196
|
+
*/
|
|
197
|
+
workLease?: OpWorkLease;
|
|
198
|
+
/** `OpConfig.changesCheckout` (#2748), present only when true. */
|
|
199
|
+
changesCheckout?: true;
|
|
190
200
|
phases: OpIRPhase[];
|
|
191
201
|
onFailure: OpIRPhase[];
|
|
192
202
|
/** Every activity profile referenced by a step in this Op, keyed by profile name — from the registry passed to {@link buildOpIR}, empty when none was. */
|
|
@@ -350,6 +360,8 @@ export function buildOpIR(
|
|
|
350
360
|
depends: config.depends ?? [],
|
|
351
361
|
labels: config.labels ?? {},
|
|
352
362
|
...(config.schedule ? { schedule: config.schedule } : {}),
|
|
363
|
+
...(config.workLease ? { workLease: config.workLease } : {}),
|
|
364
|
+
...(config.changesCheckout ? { changesCheckout: true as const } : {}),
|
|
353
365
|
phases: config.phases.map((p) => irPhase(p, contractRegistry)),
|
|
354
366
|
onFailure: (config.onFailure ?? []).map((p) => irPhase(p, contractRegistry)),
|
|
355
367
|
activityProfiles: sortedEntries(profiles),
|
|
@@ -428,5 +440,7 @@ export function opConfigFromIR(ir: OpIR): OpConfig {
|
|
|
428
440
|
...(ir.onFailure.length > 0 ? { onFailure: ir.onFailure.map(opPhaseFromIR) } : {}),
|
|
429
441
|
...(Object.keys(ir.labels).length > 0 ? { labels: ir.labels } : {}),
|
|
430
442
|
...(ir.schedule ? { schedule: ir.schedule } : {}),
|
|
443
|
+
...(ir.workLease ? { workLease: ir.workLease } : {}),
|
|
444
|
+
...(ir.changesCheckout ? { changesCheckout: true } : {}),
|
|
431
445
|
};
|
|
432
446
|
}
|