@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/operator.ts
CHANGED
|
@@ -39,6 +39,9 @@ import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
|
39
39
|
import { discoverOps, type DiscoveredOp } from "./discover";
|
|
40
40
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
|
|
41
41
|
import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
42
|
+
import { runEnvOf } from "../lifecycle/run-ledger";
|
|
43
|
+
import { stewardLeaseName, type StewardDeclaration } from "./steward";
|
|
44
|
+
import { stewardWorkHolder } from "./work-lease-run";
|
|
42
45
|
import { StaleLockError } from "../lifecycle/git";
|
|
43
46
|
import { cronMatches, cronDueBetween } from "./cron";
|
|
44
47
|
import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS, type WakeReason } from "./change-signal";
|
|
@@ -94,7 +97,14 @@ export type OperatorTickEvent =
|
|
|
94
97
|
* back off silently forever instead of surfacing the fix. One op's lease
|
|
95
98
|
* error never aborts the round for every other op.
|
|
96
99
|
*/
|
|
97
|
-
| { kind: "lease-error"; op: string; env: string; error: string }
|
|
100
|
+
| { kind: "lease-error"; op: string; env: string; error: string }
|
|
101
|
+
/**
|
|
102
|
+
* A local steward's round (#2731) found the steward's own lease held by
|
|
103
|
+
* another live holder: another `chant operator --steward` for the same
|
|
104
|
+
* steward. Nothing ticked. `op` is the steward's lease name and `env` is
|
|
105
|
+
* `-`, so a log line keyed on either still reads.
|
|
106
|
+
*/
|
|
107
|
+
| { kind: "steward-busy"; op: string; env: string; steward: string; heldBy?: string };
|
|
98
108
|
|
|
99
109
|
export interface OperatorRoundOptions {
|
|
100
110
|
cwd?: string;
|
|
@@ -119,6 +129,30 @@ export interface OperatorRoundOptions {
|
|
|
119
129
|
* round makes up exactly one tick rather than a backlog.
|
|
120
130
|
*/
|
|
121
131
|
scheduleState?: Map<string, Date>;
|
|
132
|
+
/**
|
|
133
|
+
* Run a local steward's Ops instead of every discovered ConvergeOp (#2731).
|
|
134
|
+
* Only its scheduled Ops tick, each on its own cron, because that is what
|
|
135
|
+
* the same steward does on Fountain: an Op with no schedule runs when
|
|
136
|
+
* someone asks for it. The round first takes (or renews) the steward's own
|
|
137
|
+
* lease, and ticks nothing when another holder has it.
|
|
138
|
+
*/
|
|
139
|
+
steward?: StewardDeclaration;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Take or renew a local steward's own lease (#2731). */
|
|
143
|
+
export async function acquireStewardLease(
|
|
144
|
+
steward: string,
|
|
145
|
+
holder: string,
|
|
146
|
+
opts: { cwd?: string; ttlMs?: number; now?: () => Date } = {},
|
|
147
|
+
): Promise<AcquireLeaseResult> {
|
|
148
|
+
return acquireLease(stewardLeaseName(steward), holder, { ...opts, ttlMs: opts.ttlMs ?? DEFAULT_LEASE_TTL_MS });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The Ops one round considers: a steward's scheduled ones, or every ConvergeOp. */
|
|
152
|
+
async function roundOps(opts: OperatorRoundOptions): Promise<DiscoveredOp["config"][]> {
|
|
153
|
+
if (opts.steward) return opts.steward.ops.filter((op) => op.schedule !== undefined);
|
|
154
|
+
const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
|
|
155
|
+
return ops.map((d) => d.config);
|
|
122
156
|
}
|
|
123
157
|
|
|
124
158
|
/**
|
|
@@ -129,11 +163,42 @@ export interface OperatorRoundOptions {
|
|
|
129
163
|
*/
|
|
130
164
|
export async function runOperatorRound(opts: OperatorRoundOptions): Promise<OperatorTickEvent[]> {
|
|
131
165
|
const holder = opts.holder ?? currentHolderId();
|
|
132
|
-
const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
|
|
133
166
|
const events: OperatorTickEvent[] = [];
|
|
167
|
+
const steward = opts.steward;
|
|
168
|
+
const leaseOpts = { cwd: opts.cwd, ttlMs: opts.leaseTtlMs, now: opts.now };
|
|
169
|
+
|
|
170
|
+
// A local steward is one writer (#2731): its round runs only while it holds
|
|
171
|
+
// its own lease, renewed here and again after every tick, so a turn longer
|
|
172
|
+
// than the lease's TTL still keeps it.
|
|
173
|
+
const holdSteward = async (): Promise<boolean> => {
|
|
174
|
+
if (!steward) return true;
|
|
175
|
+
let acquired: AcquireLeaseResult;
|
|
176
|
+
try {
|
|
177
|
+
acquired = await acquireStewardLease(steward.name, holder, leaseOpts);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
events.push({
|
|
180
|
+
kind: "lease-error",
|
|
181
|
+
op: stewardLeaseName(steward.name),
|
|
182
|
+
env: "-",
|
|
183
|
+
error: err instanceof Error ? err.message : String(err),
|
|
184
|
+
});
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (acquired.acquired) return true;
|
|
188
|
+
events.push({
|
|
189
|
+
kind: "steward-busy",
|
|
190
|
+
op: stewardLeaseName(steward.name),
|
|
191
|
+
env: "-",
|
|
192
|
+
steward: steward.name,
|
|
193
|
+
...(acquired.heldBy?.holder ? { heldBy: acquired.heldBy.holder } : {}),
|
|
194
|
+
});
|
|
195
|
+
return false;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (!(await holdSteward())) return events;
|
|
134
199
|
|
|
135
|
-
for (const
|
|
136
|
-
const env = envOf(config) ?? "unknown";
|
|
200
|
+
for (const config of await roundOps(opts)) {
|
|
201
|
+
const env = steward ? runEnvOf(config) : (envOf(config) ?? "unknown");
|
|
137
202
|
|
|
138
203
|
// An Op that declares its own cadence (#2120) is ticked on that cron
|
|
139
204
|
// rather than on every round. Level-triggered: the question is whether a
|
|
@@ -180,6 +245,9 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
180
245
|
try {
|
|
181
246
|
const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal, {
|
|
182
247
|
ledger: { cwd: opts.cwd },
|
|
248
|
+
// A steward's turn claims work leases as `<steward>/<op>@<holder>`
|
|
249
|
+
// (#2748), which is how `workspace status` finds the lease it holds.
|
|
250
|
+
...(steward ? { work: { holder: stewardWorkHolder(steward.name, config.name, holder) } } : {}),
|
|
183
251
|
// #2301: without a sink, `settle` catches a failed ledger append and
|
|
184
252
|
// drops it. That is the failure this tick can least afford to lose —
|
|
185
253
|
// the message below points the reader at the ledger record, which is
|
|
@@ -210,6 +278,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
210
278
|
: String(err);
|
|
211
279
|
events.push({ kind: "tick-failed", op: config.name, env, error: message });
|
|
212
280
|
}
|
|
281
|
+
if (!(await holdSteward())) break;
|
|
213
282
|
}
|
|
214
283
|
|
|
215
284
|
return events;
|
|
@@ -430,5 +499,7 @@ export function formatRoundLine(event: OperatorTickEvent): string {
|
|
|
430
499
|
return `operator: ${event.op}@${event.env} fenced=1(lease lost mid-tick — ledger record still written)`;
|
|
431
500
|
case "lease-error":
|
|
432
501
|
return `operator: ${event.op}@${event.env} error=1(lease acquire failed — ${event.error})`;
|
|
502
|
+
case "steward-busy":
|
|
503
|
+
return `operator: steward ${event.steward} skipped=1(steward-lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
|
|
433
504
|
}
|
|
434
505
|
}
|
package/src/op/runtime.ts
CHANGED
|
@@ -156,6 +156,12 @@ export interface OpRunStartOptions {
|
|
|
156
156
|
progress?: (record: StepRecord) => void;
|
|
157
157
|
/** Aborts in-flight work (Ctrl-C). */
|
|
158
158
|
signal?: AbortSignal;
|
|
159
|
+
/**
|
|
160
|
+
* For an Op that declares `workLease` (#2748): the work item this run is
|
|
161
|
+
* for (`--work <id>`) and who holds its lease (`--holder`). Only the local
|
|
162
|
+
* runtime takes it; a hosted run's lease is taken where the run executes.
|
|
163
|
+
*/
|
|
164
|
+
work?: { item?: string; holder?: string };
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
/**
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -166,6 +166,9 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
166
166
|
`appended to the run ledger: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
167
167
|
),
|
|
168
168
|
...(startOpts.progress ? { onRecord: startOpts.progress } : {}),
|
|
169
|
+
...(startOpts.work ? { work: startOpts.work } : {}),
|
|
170
|
+
onWorkLeaseWarning: (message) =>
|
|
171
|
+
process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
|
|
169
172
|
},
|
|
170
173
|
);
|
|
171
174
|
const status = statusFrom(op.name, runId, startedAt, result);
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
|
|
88
88
|
import { z } from "zod";
|
|
89
89
|
import type { ActivityStep, OpConfig, PhaseDefinition } from "./types";
|
|
90
|
+
import { WORK_LEASE_STEP_ID } from "./types";
|
|
90
91
|
import { pathExistsInSchema, schemaAtPath, primitiveKindOf, type ActivityContract, type ActivityContractIssue } from "./activity-contract";
|
|
91
92
|
|
|
92
93
|
const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
|
|
@@ -308,7 +309,7 @@ function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>
|
|
|
308
309
|
* the same parallel phase).
|
|
309
310
|
*/
|
|
310
311
|
export function validateStepOutputRefScope(
|
|
311
|
-
config: Pick<OpConfig, "name" | "phases" | "onFailure"
|
|
312
|
+
config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
|
|
312
313
|
): ActivityContractIssue[] {
|
|
313
314
|
const issues: ActivityContractIssue[] = [];
|
|
314
315
|
|
|
@@ -339,6 +340,9 @@ export function validateStepOutputRefScope(
|
|
|
339
340
|
for (const consumer of locations) {
|
|
340
341
|
const refs = collectStepOutputRefs(consumer.step.args);
|
|
341
342
|
for (const ref of refs) {
|
|
343
|
+
// The run's work lease (#2748) is published under a reserved id once it
|
|
344
|
+
// is claimed; an Op that declares one may reference it from any step.
|
|
345
|
+
if (config.workLease && ref.step === WORK_LEASE_STEP_ID) continue;
|
|
342
346
|
if (duplicateIds.has(ref.step)) {
|
|
343
347
|
issues.push({
|
|
344
348
|
opName: config.name,
|
|
@@ -395,7 +399,7 @@ export function validateStepOutputRefScope(
|
|
|
395
399
|
* schema (an empty `path` — the whole return value — is always valid).
|
|
396
400
|
*/
|
|
397
401
|
export function validateStepOutputRefs(
|
|
398
|
-
config: Pick<OpConfig, "name" | "phases" | "onFailure"
|
|
402
|
+
config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
|
|
399
403
|
contracts: ReadonlyMap<string, ActivityContract>,
|
|
400
404
|
): ActivityContractIssue[] {
|
|
401
405
|
const issues = validateStepOutputRefScope(config);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A steward's declaration and its local form (#2731): `declareSteward`'s
|
|
3
|
+
* refusals, discovery beside the Ops, a local round that runs the steward's
|
|
4
|
+
* scheduled Ops under its own lease, and the rule that a steward turn and a
|
|
5
|
+
* coding agent share one checkout without colliding.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
13
|
+
import type { OpConfig } from "./types";
|
|
14
|
+
import { declareSteward, isStewardDeclaration, pickSteward, stewardFormFor, stewardLeaseName } from "./steward";
|
|
15
|
+
import { discoverOps, discoverStewards } from "./discover";
|
|
16
|
+
import { runOperatorRound, acquireStewardLease, formatRoundLine } from "./operator";
|
|
17
|
+
import { readLease } from "../lifecycle/lease";
|
|
18
|
+
import { readRunLedger } from "../lifecycle/run-ledger";
|
|
19
|
+
|
|
20
|
+
const PROFILES: Record<string, ActivityProfile> = {};
|
|
21
|
+
|
|
22
|
+
function op(name: string, cron?: string, fn = "fakeTurn"): OpConfig {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
overview: `${name} fixture`,
|
|
26
|
+
phases: [{ name: "Run", steps: [{ kind: "activity", fn, args: {} }] }],
|
|
27
|
+
...(cron ? { schedule: { cron, overlap: "skip" as const } } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(args: string[], cwd: string): { stdout: string; status: number } {
|
|
32
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
33
|
+
return { stdout: r.stdout ?? "", status: r.status ?? -1 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function initRepo(dir: string): Promise<void> {
|
|
37
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
38
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
39
|
+
git(["config", "user.name", "Test"], dir);
|
|
40
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
41
|
+
writeFileSync(join(dir, "app", "index.ts"), "export const hello = 1;\n");
|
|
42
|
+
git(["add", "app/index.ts"], dir);
|
|
43
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An `*.op.ts` file exporting a steward as a literal: the shape `declareSteward` returns, no imports. */
|
|
47
|
+
function writeStewardFile(dir: string, file: string, name: string, ops: OpConfig[], form: unknown = { default: "local", environments: {} }): void {
|
|
48
|
+
mkdirSync(join(dir, "ops"), { recursive: true });
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(dir, "ops", file),
|
|
51
|
+
`export const steward = ${JSON.stringify({ kind: "Chant::Steward", name, ops, form })};\n`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function turns(log: string[]): Map<string, ActivityFn> {
|
|
56
|
+
return new Map<string, ActivityFn>([
|
|
57
|
+
["fakeTurn", async () => { log.push("turn"); return { ok: true }; }],
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("declareSteward", () => {
|
|
62
|
+
test("normalises the form, local by default", () => {
|
|
63
|
+
const s = declareSteward({ name: "box-steward", ops: [op("box-converge", "* * * * *")] });
|
|
64
|
+
expect(isStewardDeclaration(s)).toBe(true);
|
|
65
|
+
expect(s.form).toEqual({ default: "local", environments: {} });
|
|
66
|
+
expect(stewardFormFor(s)).toBe("local");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("chooses the form per environment", () => {
|
|
70
|
+
const s = declareSteward({
|
|
71
|
+
name: "box-steward",
|
|
72
|
+
ops: [],
|
|
73
|
+
form: { default: "local", environments: { "fountain-k3d": "fountain" } },
|
|
74
|
+
});
|
|
75
|
+
expect(stewardFormFor(s, "minimal")).toBe("local");
|
|
76
|
+
expect(stewardFormFor(s, "fountain-k3d")).toBe("fountain");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("reads an Op declaration through its props", () => {
|
|
80
|
+
const s = declareSteward({ name: "box-steward", ops: [{ props: op("box-release") }] });
|
|
81
|
+
expect(s.ops.map((o) => o.name)).toEqual(["box-release"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a brokered steward names its capabilities and holds no vault (#2726)", () => {
|
|
85
|
+
const s = declareSteward({ name: "a", ops: [], capabilities: ["fountain", "inference", "fountain"] });
|
|
86
|
+
expect(s.capabilities).toEqual(["fountain", "inference"]);
|
|
87
|
+
expect(s.vault).toBeNull();
|
|
88
|
+
expect(declareSteward({ name: "b", ops: [], vault: "creds" }).vault).toBe("creds");
|
|
89
|
+
expect(() => declareSteward({ name: "c", ops: [], capabilities: ["fountain"], vault: "creds" })).toThrow(/holds no credential/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("refuses what would make two writers or a promise it can't keep", () => {
|
|
93
|
+
expect(() => declareSteward({ name: "a", ops: [op("x"), op("x")] })).toThrow(/listed twice/);
|
|
94
|
+
expect(() => declareSteward({ name: "a", ops: [{ ...op("x"), schedule: { cron: "* * * * *", overlap: "buffer" as never } }] })).toThrow(/overlap "buffer"/);
|
|
95
|
+
expect(() => declareSteward({ name: "a", ops: [op("x", "not a cron")] })).toThrow(/Steward "a": op "x"/);
|
|
96
|
+
expect(() => declareSteward({ name: "../a", ops: [] })).toThrow(/steward's name/);
|
|
97
|
+
expect(() => declareSteward({ name: "a", ops: [], form: "cloud" as never })).toThrow(/"local" or "fountain"/);
|
|
98
|
+
expect(() => declareSteward({ name: "a", ops: [], form: { default: "local", environments: { "a/b": "fountain" } } })).toThrow(/can't be an environment/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("discoverStewards", () => {
|
|
103
|
+
test("finds a steward in an *.op.ts file, and discoverOps doesn't call that file an error", async () => {
|
|
104
|
+
await withTestDir(async (dir) => {
|
|
105
|
+
await initRepo(dir);
|
|
106
|
+
writeStewardFile(dir, "steward.op.ts", "box-steward", [op("box-converge", "* * * * *")]);
|
|
107
|
+
const { stewards, errors } = await discoverStewards({ cwd: dir });
|
|
108
|
+
expect(errors).toEqual([]);
|
|
109
|
+
expect([...stewards.keys()]).toEqual(["box-steward"]);
|
|
110
|
+
expect(stewards.get("box-steward")!.exportName).toBe("steward");
|
|
111
|
+
expect((await discoverOps({ cwd: dir })).errors).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("refuses one Op listed by two stewards", async () => {
|
|
116
|
+
await withTestDir(async (dir) => {
|
|
117
|
+
await initRepo(dir);
|
|
118
|
+
writeStewardFile(dir, "a.op.ts", "first", [op("box-converge", "* * * * *")]);
|
|
119
|
+
writeStewardFile(dir, "b.op.ts", "second", [op("box-converge", "* * * * *")]);
|
|
120
|
+
const { stewards, errors, conflicts } = await discoverStewards({ cwd: dir });
|
|
121
|
+
expect([...stewards.keys()]).toEqual(["first"]);
|
|
122
|
+
expect(errors).toEqual([]);
|
|
123
|
+
expect(conflicts).toHaveLength(1);
|
|
124
|
+
expect(conflicts[0]).toMatch(/steward "second" lists op "box-converge", which steward "first" already runs/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("pickSteward takes the only steward, or the named one, and refuses otherwise", () => {
|
|
129
|
+
const one = new Map([["box-steward", { declaration: declareSteward({ name: "box-steward", ops: [] }) }]]);
|
|
130
|
+
expect(pickSteward(one, "")).toMatchObject({ name: "box-steward" });
|
|
131
|
+
expect(pickSteward(one, "other")).toMatch(/No steward "other"/);
|
|
132
|
+
expect(pickSteward(new Map(), "")).toMatch(/No steward is declared/);
|
|
133
|
+
const two = new Map([...one, ["b", { declaration: declareSteward({ name: "b", ops: [] }) }]]);
|
|
134
|
+
expect(pickSteward(two, "")).toMatch(/2 stewards are declared/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("a local steward round", () => {
|
|
139
|
+
test("runs only the scheduled Ops, each on its cron, and records each run", async () => {
|
|
140
|
+
await withTestDir(async (dir) => {
|
|
141
|
+
await initRepo(dir);
|
|
142
|
+
const steward = declareSteward({
|
|
143
|
+
name: "box-steward",
|
|
144
|
+
ops: [op("box-converge", "* * * * *"), op("box-dispatch", "*/5 * * * *"), op("box-release")],
|
|
145
|
+
});
|
|
146
|
+
const log: string[] = [];
|
|
147
|
+
// 10:01 fires every minute, not every five.
|
|
148
|
+
const now = () => new Date(2026, 8, 25, 10, 1, 0);
|
|
149
|
+
const events = await runOperatorRound({ cwd: dir, holder: "h1", steward, activities: turns(log), profiles: PROFILES, now });
|
|
150
|
+
expect(events.map((e) => [e.kind, e.op])).toEqual([
|
|
151
|
+
["ticked", "box-converge"],
|
|
152
|
+
["skipped-not-due", "box-dispatch"],
|
|
153
|
+
]);
|
|
154
|
+
// box-release has no schedule: it runs when asked, never on a round.
|
|
155
|
+
expect(log).toEqual(["turn"]);
|
|
156
|
+
expect((await readRunLedger("local", "box-converge", { cwd: dir })).records).toHaveLength(1);
|
|
157
|
+
expect((await readLease(stewardLeaseName("box-steward"), { cwd: dir })).record?.holder).toBe("h1");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a second holder of the same steward ticks nothing", async () => {
|
|
162
|
+
await withTestDir(async (dir) => {
|
|
163
|
+
await initRepo(dir);
|
|
164
|
+
const steward = declareSteward({ name: "box-steward", ops: [op("box-converge", "* * * * *")] });
|
|
165
|
+
expect((await acquireStewardLease("box-steward", "h1", { cwd: dir })).acquired).toBe(true);
|
|
166
|
+
const log: string[] = [];
|
|
167
|
+
const events = await runOperatorRound({ cwd: dir, holder: "h2", steward, activities: turns(log), profiles: PROFILES });
|
|
168
|
+
expect(events).toEqual([{ kind: "steward-busy", op: "_stewards/box-steward", env: "-", steward: "box-steward", heldBy: "h1" }]);
|
|
169
|
+
expect(formatRoundLine(events[0])).toBe("operator: steward box-steward skipped=1(steward-lease-held:h1)");
|
|
170
|
+
expect(log).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a coding agent edits app/ and holds the index lock during a steward turn, and neither collides", async () => {
|
|
175
|
+
await withTestDir(async (dir) => {
|
|
176
|
+
await initRepo(dir);
|
|
177
|
+
const head = git(["rev-parse", "HEAD"], dir).stdout.trim();
|
|
178
|
+
const steward = declareSteward({ name: "box-steward", ops: [op("box-converge", "* * * * *", "agentEditsMidTurn")] });
|
|
179
|
+
const edited = "export const hello = 2; // the coding agent's edit\n";
|
|
180
|
+
const lock = join(dir, ".git", "index.lock");
|
|
181
|
+
const activities = new Map<string, ActivityFn>([
|
|
182
|
+
[
|
|
183
|
+
"agentEditsMidTurn",
|
|
184
|
+
async () => {
|
|
185
|
+
// The coding agent, mid-turn: an edit to the app, and a commit in
|
|
186
|
+
// progress that holds git's index lock.
|
|
187
|
+
writeFileSync(join(dir, "app", "index.ts"), edited);
|
|
188
|
+
writeFileSync(lock, "");
|
|
189
|
+
return { ok: true };
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const events = await runOperatorRound({ cwd: dir, holder: "h1", steward, activities, profiles: PROFILES });
|
|
195
|
+
expect(events.map((e) => e.kind)).toEqual(["ticked"]);
|
|
196
|
+
|
|
197
|
+
// The steward's writes landed where chant writes: the run ledger and the lease refs.
|
|
198
|
+
expect((await readRunLedger("local", "box-converge", { cwd: dir })).records).toHaveLength(1);
|
|
199
|
+
expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], dir).status).toBe(0);
|
|
200
|
+
|
|
201
|
+
// And none of them touched the checkout: the agent's edit is intact, its
|
|
202
|
+
// lock is still its own, and HEAD and the index are where they were.
|
|
203
|
+
expect(readFileSync(join(dir, "app", "index.ts"), "utf-8")).toBe(edited);
|
|
204
|
+
expect(existsSync(lock)).toBe(true);
|
|
205
|
+
rmSync(lock); // the agent's commit finishes; git status needs the lock gone
|
|
206
|
+
|
|
207
|
+
expect(git(["rev-parse", "HEAD"], dir).stdout.trim()).toBe(head);
|
|
208
|
+
expect(git(["status", "--porcelain"], dir).stdout).toBe(" M app/index.ts\n");
|
|
209
|
+
expect(git(["diff", "--cached", "--name-only"], dir).stdout).toBe("");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A steward's declaration (#2731): the one writer for an environment, as data
|
|
3
|
+
* core can read without any lexicon loaded.
|
|
4
|
+
*
|
|
5
|
+
* A steward runs a fixed list of Ops, each on its own `schedule` when it has
|
|
6
|
+
* one, one Op at a time. Where it runs is its form:
|
|
7
|
+
*
|
|
8
|
+
* - `fountain`: the fountain lexicon's `Steward` composite, an `acp` Agent on
|
|
9
|
+
* a persistent sandbox bound to a Teammate, with a Fountain `Schedule` per
|
|
10
|
+
* scheduled Op. A turn is one `chant run <op>` on the teammate's thread.
|
|
11
|
+
* - `local`: `chant operator --steward <name>` in the box itself, for a box
|
|
12
|
+
* with no Fountain behind it (a docker compose box, a Sprites box). The same
|
|
13
|
+
* Ops on the same crons, each run under its operator lease, and the steward
|
|
14
|
+
* itself under a lease of its own so a second local operator is refused.
|
|
15
|
+
*
|
|
16
|
+
* One declaration covers both. `form` is either one of the two, or a default
|
|
17
|
+
* with per-environment exceptions, so a box whose `minimal` environment has no
|
|
18
|
+
* Fountain and whose `fountain-k3d` one does still has exactly one steward:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* // ops/steward.op.ts
|
|
22
|
+
* export const steward = declareSteward({
|
|
23
|
+
* name: "box-steward",
|
|
24
|
+
* ops: [converge, dispatch, release, upgrade],
|
|
25
|
+
* form: { default: "local", environments: { "fountain-k3d": "fountain" } },
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* The fountain composite builds this same declaration from its own options and
|
|
30
|
+
* returns it as `declaration`, so a project that declares its steward there
|
|
31
|
+
* exports that object from an `*.op.ts` file instead of calling this function.
|
|
32
|
+
*
|
|
33
|
+
* ## Discovery
|
|
34
|
+
*
|
|
35
|
+
* A steward is discovered from the same `*.op.ts` files Ops are
|
|
36
|
+
* (`./discover.ts`), because it is chant's own run-time declaration and sits
|
|
37
|
+
* beside the Ops it runs. It is plain data with a `kind` marker, not a
|
|
38
|
+
* Declarable, so `chant build` never serializes it and a copy of core linked
|
|
39
|
+
* twice still recognises it.
|
|
40
|
+
*
|
|
41
|
+
* ## What a steward writes
|
|
42
|
+
*
|
|
43
|
+
* A steward's writes are chant writes: run, converge and gate records on the
|
|
44
|
+
* `chant/lifecycle` branch and lease refs under `refs/chant/lease/`, all made
|
|
45
|
+
* with git plumbing (`../lifecycle/git.ts`: `hash-object`, `mktree`,
|
|
46
|
+
* `commit-tree`, `update-ref`). None of them reads or writes the checkout's
|
|
47
|
+
* index or working tree, so a coding agent editing the app in the same
|
|
48
|
+
* checkout, and holding git's index lock while it commits, never collides with
|
|
49
|
+
* a steward turn. The app's files are the coding agent's; the steward does not
|
|
50
|
+
* edit them.
|
|
51
|
+
*
|
|
52
|
+
* An Op that has to change the checkout (applying a build, a workspace
|
|
53
|
+
* upgrade) says so with `changesCheckout` and runs under a work item's lease
|
|
54
|
+
* (`workLease`, #2748): its leased steps get a worktree of their own on
|
|
55
|
+
* `chant/work/<item>`. `declareSteward` refuses such an Op without the lease,
|
|
56
|
+
* and a scheduled Op whose lease leaves the item to the run.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import type { OpConfig } from "./types";
|
|
60
|
+
import { isValidCronExpression, cronSyntaxMessage } from "./cron";
|
|
61
|
+
import { workLeaseNeedsRunItem, workLeaseProblems } from "./work-lease-decl";
|
|
62
|
+
|
|
63
|
+
/** The entity marker a steward declaration carries. */
|
|
64
|
+
export const STEWARD_KIND = "Chant::Steward";
|
|
65
|
+
|
|
66
|
+
/** Where a steward runs. */
|
|
67
|
+
export const STEWARD_FORMS = ["local", "fountain"] as const;
|
|
68
|
+
export type StewardForm = (typeof STEWARD_FORMS)[number];
|
|
69
|
+
|
|
70
|
+
/** The form a declaration asks for, as given: one form, or a default with per-environment exceptions. */
|
|
71
|
+
export type StewardFormSpec = StewardForm | { default: StewardForm; environments?: Record<string, StewardForm> };
|
|
72
|
+
|
|
73
|
+
/** The environment a form is chosen for when none is named: the same default a run ledger uses. */
|
|
74
|
+
export const DEFAULT_STEWARD_ENV = "local";
|
|
75
|
+
|
|
76
|
+
/** Names a steward's lease ref and a ledger path can hold. */
|
|
77
|
+
export const STEWARD_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* An Op a steward runs, however the project holds it: the `Op()` declaration
|
|
81
|
+
* (config behind `.props`), or the bare config.
|
|
82
|
+
*/
|
|
83
|
+
export type StewardOpInput = OpConfig | { props: unknown };
|
|
84
|
+
|
|
85
|
+
export interface StewardDeclarationConfig {
|
|
86
|
+
/** The steward's name. On Fountain, the Agent's and the Teammate's. */
|
|
87
|
+
name: string;
|
|
88
|
+
/** The Ops it runs. A scheduled one runs on its cron; any other runs when asked. */
|
|
89
|
+
ops: StewardOpInput[];
|
|
90
|
+
/** Where it runs. Default `local`. */
|
|
91
|
+
form?: StewardFormSpec;
|
|
92
|
+
/**
|
|
93
|
+
* The box capabilities its Ops reach through the box's broker (#2726), by
|
|
94
|
+
* the names the member's `box` block in the workspace declaration gives
|
|
95
|
+
* them, such as `fountain` or `inference`. A steward that names any holds no
|
|
96
|
+
* credential of its own, so it takes no `vault`.
|
|
97
|
+
*/
|
|
98
|
+
capabilities?: string[];
|
|
99
|
+
/** The vault the steward holds on Fountain, by name, for a steward that isn't behind a broker. */
|
|
100
|
+
vault?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A steward's declaration, normalised. Plain data: see the module doc. */
|
|
104
|
+
export interface StewardDeclaration {
|
|
105
|
+
readonly kind: typeof STEWARD_KIND;
|
|
106
|
+
readonly name: string;
|
|
107
|
+
readonly ops: readonly OpConfig[];
|
|
108
|
+
readonly form: { readonly default: StewardForm; readonly environments: Readonly<Record<string, StewardForm>> };
|
|
109
|
+
/** Brokered box capabilities its Ops use (#2726). Empty when it names none. */
|
|
110
|
+
readonly capabilities: readonly string[];
|
|
111
|
+
/** The vault it holds, by name, or null. Never set together with `capabilities`. */
|
|
112
|
+
readonly vault: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The config inside an op, whether it arrived as a declaration or as itself. */
|
|
116
|
+
export function stewardOpConfig(op: StewardOpInput): OpConfig {
|
|
117
|
+
const declared = (op as { props?: unknown }).props;
|
|
118
|
+
return (declared && typeof declared === "object" ? declared : op) as OpConfig;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkForm(steward: string, form: unknown, where: string): StewardForm {
|
|
122
|
+
if (!STEWARD_FORMS.includes(form as StewardForm)) {
|
|
123
|
+
throw new Error(`Steward "${steward}": ${where} is ${JSON.stringify(form)}; a steward's form is "local" or "fountain"`);
|
|
124
|
+
}
|
|
125
|
+
return form as StewardForm;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Normalise a form spec, refusing an unknown form or an environment name no ledger could hold. */
|
|
129
|
+
export function normaliseStewardForm(steward: string, spec: StewardFormSpec | undefined): StewardDeclaration["form"] {
|
|
130
|
+
if (spec === undefined) return { default: "local", environments: {} };
|
|
131
|
+
if (typeof spec === "string") return { default: checkForm(steward, spec, "form"), environments: {} };
|
|
132
|
+
const environments: Record<string, StewardForm> = {};
|
|
133
|
+
for (const [env, form] of Object.entries(spec.environments ?? {})) {
|
|
134
|
+
if (!STEWARD_NAME_PATTERN.test(env) || env.includes("..")) {
|
|
135
|
+
throw new Error(`Steward "${steward}": form.environments names ${JSON.stringify(env)}, which can't be an environment`);
|
|
136
|
+
}
|
|
137
|
+
environments[env] = checkForm(steward, form, `form.environments[${JSON.stringify(env)}]`);
|
|
138
|
+
}
|
|
139
|
+
return { default: checkForm(steward, spec.default, "form.default"), environments };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Declare a steward. Refuses what would make it more than one writer or a
|
|
144
|
+
* promise it can't keep: an Op listed twice, a schedule whose overlap isn't
|
|
145
|
+
* `skip` (a fire while a turn runs is dropped in both forms), a cron that
|
|
146
|
+
* doesn't parse, and a name a lease ref can't hold.
|
|
147
|
+
*/
|
|
148
|
+
export function declareSteward(config: StewardDeclarationConfig): StewardDeclaration {
|
|
149
|
+
const name = config.name;
|
|
150
|
+
if (!STEWARD_NAME_PATTERN.test(name) || name.includes("..")) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Steward ${JSON.stringify(name)}: a steward's name is letters, digits, ".", "_" and "-", starting with a letter or digit`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const ops = config.ops.map(stewardOpConfig);
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
for (const op of ops) {
|
|
158
|
+
if (!op || typeof op.name !== "string" || !Array.isArray(op.phases)) {
|
|
159
|
+
throw new Error(`Steward "${name}": every entry in ops must be an Op`);
|
|
160
|
+
}
|
|
161
|
+
if (seen.has(op.name)) throw new Error(`Steward "${name}": op "${op.name}" is listed twice`);
|
|
162
|
+
seen.add(op.name);
|
|
163
|
+
// An Op that changes the checkout runs under a work item's lease, on a
|
|
164
|
+
// branch of its own (#2748): one that doesn't declare the lease, or
|
|
165
|
+
// declares it badly, is refused here as the executor would refuse it.
|
|
166
|
+
const leaseProblems = workLeaseProblems(op);
|
|
167
|
+
if (leaseProblems.length > 0) throw new Error(`Steward "${name}": ${leaseProblems.join("; ")}`);
|
|
168
|
+
if (op.schedule && workLeaseNeedsRunItem(op)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Steward "${name}": op "${op.name}" is scheduled, but its workLease names no item, and a scheduled run can't be given one. ` +
|
|
171
|
+
`Name the item, candidates, or the step whose output picks it.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const schedule = op.schedule;
|
|
175
|
+
if (!schedule) continue;
|
|
176
|
+
if (!isValidCronExpression(schedule.cron)) {
|
|
177
|
+
throw new Error(`Steward "${name}": op "${op.name}": ${cronSyntaxMessage(schedule.cron)}`);
|
|
178
|
+
}
|
|
179
|
+
if (schedule.overlap !== undefined && schedule.overlap !== "skip") {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Steward "${name}": op "${op.name}" schedules overlap "${schedule.overlap}". ` +
|
|
182
|
+
`A fire while the steward is busy is dropped, so "skip" is the only policy a steward can honour.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const capabilities = [...new Set(config.capabilities ?? [])];
|
|
187
|
+
for (const c of capabilities) {
|
|
188
|
+
if (typeof c !== "string" || c.trim() === "") throw new Error(`Steward "${name}": a capability is named by a non-empty string`);
|
|
189
|
+
}
|
|
190
|
+
const vault = config.vault ?? null;
|
|
191
|
+
if (vault !== null && capabilities.length > 0) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Steward "${name}": it reaches ${capabilities.join(", ")} through the box's broker and also holds the vault "${vault}". ` +
|
|
194
|
+
`A brokered steward holds no credential of its own: drop the vault, or name no capabilities.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
kind: STEWARD_KIND,
|
|
199
|
+
name,
|
|
200
|
+
ops: Object.freeze([...ops]),
|
|
201
|
+
form: normaliseStewardForm(name, config.form),
|
|
202
|
+
capabilities: Object.freeze(capabilities),
|
|
203
|
+
vault,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Is this value a steward declaration? Duck-typed, like `isOpEntity`. */
|
|
208
|
+
export function isStewardDeclaration(value: unknown): value is StewardDeclaration {
|
|
209
|
+
if (!value || typeof value !== "object") return false;
|
|
210
|
+
const v = value as Partial<StewardDeclaration>;
|
|
211
|
+
return (
|
|
212
|
+
v.kind === STEWARD_KIND &&
|
|
213
|
+
typeof v.name === "string" &&
|
|
214
|
+
Array.isArray(v.ops) &&
|
|
215
|
+
!!v.form &&
|
|
216
|
+
typeof v.form === "object" &&
|
|
217
|
+
typeof v.form.default === "string"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The form a steward takes in `env` (`local` when none is named). */
|
|
222
|
+
export function stewardFormFor(steward: StewardDeclaration, env: string = DEFAULT_STEWARD_ENV): StewardForm {
|
|
223
|
+
return steward.form.environments[env] ?? steward.form.default;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The lease a local steward holds for as long as it runs, so a second
|
|
228
|
+
* `chant operator --steward` for the same steward is refused. It sits under
|
|
229
|
+
* `refs/chant/lease/_stewards/`, beside the per-Op leases, which a leading
|
|
230
|
+
* `_` keeps out of any Op's name.
|
|
231
|
+
*/
|
|
232
|
+
export function stewardLeaseName(steward: string): string {
|
|
233
|
+
return `_stewards/${steward}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Pick the steward `--steward [<name>]` names, or the project's only one.
|
|
238
|
+
* Returns the steward, or the message to refuse with.
|
|
239
|
+
*/
|
|
240
|
+
export function pickSteward(
|
|
241
|
+
stewards: Map<string, { declaration: StewardDeclaration }>,
|
|
242
|
+
name: string,
|
|
243
|
+
): StewardDeclaration | string {
|
|
244
|
+
if (name) {
|
|
245
|
+
const found = stewards.get(name);
|
|
246
|
+
if (found) return found.declaration;
|
|
247
|
+
const known = [...stewards.keys()].sort();
|
|
248
|
+
return `No steward "${name}" is declared` + (known.length ? ` (declared: ${known.join(", ")})` : " (no *.op.ts file exports one)");
|
|
249
|
+
}
|
|
250
|
+
if (stewards.size === 1) return [...stewards.values()][0].declaration;
|
|
251
|
+
if (stewards.size === 0) return "No steward is declared: export declareSteward({...}) (or the fountain Steward's declaration) from an *.op.ts file";
|
|
252
|
+
return `${stewards.size} stewards are declared (${[...stewards.keys()].sort().join(", ")}); name one with --steward <name>`;
|
|
253
|
+
}
|