@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/types.ts
CHANGED
|
@@ -42,6 +42,59 @@ export interface OpConfig {
|
|
|
42
42
|
* local one-shot executor ignores it.
|
|
43
43
|
*/
|
|
44
44
|
schedule?: OpSchedule;
|
|
45
|
+
/**
|
|
46
|
+
* The work item this Op runs under (#2748, ws-055). The executor claims the
|
|
47
|
+
* item's work lease (`../lifecycle/work-lease.ts`) before the steps that run
|
|
48
|
+
* under it, renews it as a heartbeat while they run, stops the run when the
|
|
49
|
+
* lease is lost, and gives it back when the run ends. See
|
|
50
|
+
* {@link OpWorkLease} and `./work-lease-run.ts`.
|
|
51
|
+
*/
|
|
52
|
+
workLease?: OpWorkLease;
|
|
53
|
+
/**
|
|
54
|
+
* The Op changes the checkout: it applies a build, or upgrades the
|
|
55
|
+
* workspace (#2748). Such an Op must declare {@link OpConfig.workLease}: the
|
|
56
|
+
* executor refuses to run it otherwise, and `declareSteward` refuses to list
|
|
57
|
+
* it. Its leased steps get a git worktree of their own, on the branch
|
|
58
|
+
* `chant/work/<item>`, so the change never lands in the working tree a
|
|
59
|
+
* coding agent is editing.
|
|
60
|
+
*/
|
|
61
|
+
changesCheckout?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The id under which a run's work lease is published to its steps (#2748):
|
|
66
|
+
* `stepOutput(WORK_LEASE_STEP_ID, "token")`, or `workLeaseOutput("token")`
|
|
67
|
+
* from `./work-lease-run.ts`. No step of an Op with a `workLease` may use it.
|
|
68
|
+
*/
|
|
69
|
+
export const WORK_LEASE_STEP_ID = "workLease";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Which work item an Op runs under, and how (#2748).
|
|
73
|
+
*
|
|
74
|
+
* `item` is an id, a list of candidate ids (the first one nobody holds is
|
|
75
|
+
* claimed, as chud's dispatcher took the first ready contract nobody held), a
|
|
76
|
+
* reference to an earlier step's output that resolves to either, or omitted,
|
|
77
|
+
* in which case the run names it (`chant run <op> --work <id>`). A literal or
|
|
78
|
+
* run-time item is claimed before the first step; a referenced one right
|
|
79
|
+
* after the step that produces it. A run that finds nothing to claim (the
|
|
80
|
+
* reference resolved to nothing, or every candidate is held) ends `ok` with
|
|
81
|
+
* the rest of its steps skipped, and the claim's record says why.
|
|
82
|
+
*/
|
|
83
|
+
export interface OpWorkLease {
|
|
84
|
+
item?: string | string[] | StepOutputRef;
|
|
85
|
+
/**
|
|
86
|
+
* The work kind file whose member owns the lease (#2524 D7), relative to
|
|
87
|
+
* the project directory. Omitted, the lease is in the project's own ledger.
|
|
88
|
+
*/
|
|
89
|
+
kind?: string;
|
|
90
|
+
/** How long a claim lasts unless renewed, as a duration ("10m"). Renewed every third of it. Default 10m. */
|
|
91
|
+
ttl?: string;
|
|
92
|
+
/**
|
|
93
|
+
* How the release records a run that ended `ok`: a reference to a step
|
|
94
|
+
* output holding a short string (`done`, `not_done`, ...). Default `done`;
|
|
95
|
+
* a failed run releases `not_done` and a gated one `gated`.
|
|
96
|
+
*/
|
|
97
|
+
outcome?: StepOutputRef;
|
|
45
98
|
}
|
|
46
99
|
|
|
47
100
|
/**
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules an Op's `workLease` and `changesCheckout` declaration keeps
|
|
3
|
+
* (#2748), with no imports beyond the Op model, so `Op()` and
|
|
4
|
+
* `declareSteward` can check them without loading the lease machinery in
|
|
5
|
+
* `./work-lease-run.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isStepOutputRef } from "./step-output-ref";
|
|
9
|
+
import { parseDuration } from "./duration";
|
|
10
|
+
import { WORK_LEASE_STEP_ID, type OpConfig, type PhaseDefinition } from "./types";
|
|
11
|
+
|
|
12
|
+
/** A work item id a lease can be keyed by: the same pattern as `WORK_ITEM_ID_PATTERN` in `../lifecycle/work-lease.ts`. */
|
|
13
|
+
export const WORK_LEASE_ITEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
14
|
+
|
|
15
|
+
/** The ids of the activity steps in an Op's main phases, in order. */
|
|
16
|
+
function mainStepIds(phases: readonly PhaseDefinition[]): string[] {
|
|
17
|
+
const ids: string[] = [];
|
|
18
|
+
for (const phase of phases) {
|
|
19
|
+
for (const step of phase.steps) if (step.kind === "activity" && step.id) ids.push(step.id);
|
|
20
|
+
}
|
|
21
|
+
return ids;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What is wrong with an Op's `workLease` and `changesCheckout`, or an empty
|
|
26
|
+
* list. The executor refuses to start a run with any of these, and
|
|
27
|
+
* `declareSteward` refuses to list the Op.
|
|
28
|
+
*/
|
|
29
|
+
export function workLeaseProblems(config: Pick<OpConfig, "name" | "phases" | "workLease" | "changesCheckout">): string[] {
|
|
30
|
+
const problems: string[] = [];
|
|
31
|
+
const spec = config.workLease;
|
|
32
|
+
const name = config.name;
|
|
33
|
+
if (config.changesCheckout && !spec) {
|
|
34
|
+
problems.push(
|
|
35
|
+
`Op "${name}" changes the checkout but declares no workLease. A change to the checkout happens under a work item's lease, ` +
|
|
36
|
+
`on a branch of its own: declare workLease on the Op.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!spec) return problems;
|
|
40
|
+
const ids = mainStepIds(config.phases ?? []);
|
|
41
|
+
if (ids.includes(WORK_LEASE_STEP_ID)) {
|
|
42
|
+
problems.push(`Op "${name}": step id "${WORK_LEASE_STEP_ID}" is reserved for the run's work lease; rename the step`);
|
|
43
|
+
}
|
|
44
|
+
const checkId = (id: unknown) => {
|
|
45
|
+
if (typeof id !== "string" || !WORK_LEASE_ITEM_PATTERN.test(id) || id.includes("..")) {
|
|
46
|
+
problems.push(`Op "${name}": workLease.item ${JSON.stringify(id)} can't key a work lease (letters, digits, ".", "_" and "-")`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const item = spec.item;
|
|
50
|
+
if (isStepOutputRef(item)) {
|
|
51
|
+
if (!ids.includes(item.step)) {
|
|
52
|
+
problems.push(`Op "${name}": workLease.item references step "${item.step}", which no activity step in the Op's phases declares`);
|
|
53
|
+
}
|
|
54
|
+
} else if (Array.isArray(item)) {
|
|
55
|
+
if (item.length === 0) problems.push(`Op "${name}": workLease.item is an empty list`);
|
|
56
|
+
item.forEach(checkId);
|
|
57
|
+
} else if (item !== undefined) {
|
|
58
|
+
checkId(item);
|
|
59
|
+
}
|
|
60
|
+
if (spec.ttl !== undefined) {
|
|
61
|
+
try {
|
|
62
|
+
if (parseDuration(spec.ttl) < 3_000) problems.push(`Op "${name}": workLease.ttl "${spec.ttl}" is shorter than 3s`);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
problems.push(`Op "${name}": workLease.ttl: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (spec.outcome !== undefined && !isStepOutputRef(spec.outcome)) {
|
|
68
|
+
problems.push(`Op "${name}": workLease.outcome is a reference to a step's output`);
|
|
69
|
+
}
|
|
70
|
+
if (spec.kind !== undefined && (typeof spec.kind !== "string" || spec.kind.trim() === "")) {
|
|
71
|
+
problems.push(`Op "${name}": workLease.kind names a work kind file`);
|
|
72
|
+
}
|
|
73
|
+
return problems;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a run of this Op has to be told its work item (`--work <id>`). */
|
|
77
|
+
export function workLeaseNeedsRunItem(config: Pick<OpConfig, "workLease">): boolean {
|
|
78
|
+
return config.workLease !== undefined && config.workLease.item === undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2748 — an Op run under a work item's lease: claimed before the steps it
|
|
3
|
+
* covers, renewed while they run, fenced before the run is recorded, released
|
|
4
|
+
* when it ends, and a lost lease stopping the run. An Op that changes the
|
|
5
|
+
* checkout runs its leased steps in a worktree of its own.
|
|
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, StepDefinition } from "./types";
|
|
14
|
+
import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
|
|
15
|
+
import { workLeaseOutput, workLeaseProblems, stewardWorkHolder, WORK_LEASE_STEP_ID } from "./work-lease-run";
|
|
16
|
+
import { declareSteward } from "./steward";
|
|
17
|
+
import { Op } from "./builders";
|
|
18
|
+
import { WORK_LEASE_ITEM_PATTERN } from "./work-lease-decl";
|
|
19
|
+
import { runOperatorRound } from "./operator";
|
|
20
|
+
import { stepOutput, validateStepOutputRefScope } from "./step-output-ref";
|
|
21
|
+
import { claimWorkLease, readLeaseHistory, releaseWorkLease, listWorkLeases, WORK_ITEM_ID_PATTERN } from "../lifecycle/work-lease";
|
|
22
|
+
import { readRunLedger } from "../lifecycle/run-ledger";
|
|
23
|
+
|
|
24
|
+
const PROFILES: Record<string, ActivityProfile> = {
|
|
25
|
+
long: { timeout: "30s" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function git(args: string[], cwd: string): { stdout: string; status: number } {
|
|
29
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
30
|
+
return { stdout: r.stdout ?? "", status: r.status ?? -1 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function initRepo(dir: string): Promise<void> {
|
|
34
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
35
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
36
|
+
git(["config", "user.name", "Test"], dir);
|
|
37
|
+
git(["config", "commit.gpgsign", "false"], dir);
|
|
38
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
39
|
+
writeFileSync(join(dir, "app", "index.ts"), "export const hello = 1;\n");
|
|
40
|
+
git(["add", "app/index.ts"], dir);
|
|
41
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const step = (fn: string, args: Record<string, unknown> = {}, extra: Partial<StepDefinition> = {}): StepDefinition =>
|
|
45
|
+
({ kind: "activity", fn, args, ...extra }) as StepDefinition;
|
|
46
|
+
|
|
47
|
+
function dispatchOp(extra: Partial<OpConfig> = {}, steps: StepDefinition[] = [step("build", { lease: workLeaseOutput() })]): OpConfig {
|
|
48
|
+
return { name: "dispatch", overview: "dispatch fixture", phases: [{ name: "Build", steps }], ...extra };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run(config: OpConfig, activities: Map<string, ActivityFn>, dir: string, work?: { item?: string; holder?: string }): Promise<OpRunResult> {
|
|
52
|
+
try {
|
|
53
|
+
return await runOpLocally(config, activities, PROFILES, undefined, { cwd: dir, ledger: { cwd: dir }, ...(work ? { work } : {}) });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof OpRunFailure) return err.result;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const waitFor = async (check: () => boolean | Promise<boolean>, ms = 15_000): Promise<void> => {
|
|
61
|
+
const until = Date.now() + ms;
|
|
62
|
+
while (!(await check())) {
|
|
63
|
+
if (Date.now() > until) throw new Error("timed out");
|
|
64
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("the declaration", () => {
|
|
69
|
+
test("an Op that changes the checkout without a work lease is refused by the executor and by declareSteward", async () => {
|
|
70
|
+
const config = dispatchOp({ changesCheckout: true }, [step("build")]);
|
|
71
|
+
expect(workLeaseProblems(config)[0]).toMatch(/changes the checkout but declares no workLease/);
|
|
72
|
+
await expect(runOpLocally(config, new Map(), PROFILES)).rejects.toThrow(/declares no workLease/);
|
|
73
|
+
expect(() => declareSteward({ name: "box", ops: [config] })).toThrow(/Steward "box": Op "dispatch" changes the checkout/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a scheduled steward Op must say which item it claims; a bad item, ttl or reserved id is refused", () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
declareSteward({ name: "box", ops: [dispatchOp({ workLease: {}, schedule: { cron: "* * * * *" } })] }),
|
|
79
|
+
).toThrow(/scheduled, but its workLease names no item/);
|
|
80
|
+
expect(declareSteward({ name: "box", ops: [dispatchOp({ workLease: {} })] }).ops).toHaveLength(1);
|
|
81
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item: "../x" } }))[0]).toMatch(/can't key a work lease/);
|
|
82
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1", ttl: "1s" } }))[0]).toMatch(/shorter than 3s/);
|
|
83
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item: stepOutput("nope", "id") } }))[0]).toMatch(/references step "nope"/);
|
|
84
|
+
expect(workLeaseProblems(dispatchOp({ workLease: { item: "W-1" } }, [step("x", {}, { id: WORK_LEASE_STEP_ID })]))[0]).toMatch(/reserved/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("Op() refuses a bad declaration at build time, with the lease's own id pattern", () => {
|
|
88
|
+
expect(() => Op(dispatchOp({ changesCheckout: true }, [step("build")]))).toThrow(/declares no workLease/);
|
|
89
|
+
expect(WORK_LEASE_ITEM_PATTERN.source).toBe(WORK_ITEM_ID_PATTERN.source);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a step may reference the run's work lease; OPS013's scope check accepts it only when the Op declares one", () => {
|
|
93
|
+
const leased = dispatchOp({ workLease: { item: "W-1" } });
|
|
94
|
+
expect(validateStepOutputRefScope(leased)).toEqual([]);
|
|
95
|
+
expect(validateStepOutputRefScope(dispatchOp())[0].message).toMatch(/unknown step id "workLease"/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an Op whose lease leaves the item to the run is refused without --work", async () => {
|
|
99
|
+
await expect(runOpLocally(dispatchOp({ workLease: {} }), new Map(), PROFILES)).rejects.toThrow(/--work <id>/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("a run under a work lease", () => {
|
|
104
|
+
test("claims before its steps, hands them the lease, releases done, and records the item", async () => {
|
|
105
|
+
await withTestDir(async (dir) => {
|
|
106
|
+
await initRepo(dir);
|
|
107
|
+
let seen: Record<string, unknown> | undefined;
|
|
108
|
+
const activities = new Map<string, ActivityFn>([["build", async (args) => { seen = args.lease as Record<string, unknown>; return {}; }]]);
|
|
109
|
+
const result = await run(dispatchOp({ workLease: { item: "W-1" } }), activities, dir, { holder: "box/dispatch@h1" });
|
|
110
|
+
|
|
111
|
+
expect(result.status).toBe("ok");
|
|
112
|
+
expect(seen).toMatchObject({ item: "W-1", holder: "box/dispatch@h1", worktree: null, branch: null, ref: "refs/chant/lease/work/W-1" });
|
|
113
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([["workLease:claim", "ok"], ["build", "ok"]]);
|
|
114
|
+
expect(result.workLease).toMatchObject({ item: "W-1", released: true, outcome: "done", lost: null, token: seen!.token });
|
|
115
|
+
expect(result.record.outcomes).toMatchObject({ WorkItem: "W-1" });
|
|
116
|
+
|
|
117
|
+
const history = (await readLeaseHistory("W-1", { cwd: dir })).records;
|
|
118
|
+
// The claim, the fence's renewal before the run is recorded, and the release.
|
|
119
|
+
expect(history.map((h) => [h.event, h.outcome ?? null])).toEqual([["claim", null], ["renew", null], ["release", "done"]]);
|
|
120
|
+
expect(new Set(history.map((h) => h.token)).size).toBe(1);
|
|
121
|
+
expect(await listWorkLeases({ cwd: dir, memberPrefix: "" })).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a second dispatcher is refused by the lease: it claims nothing, runs nothing, and names the holder", async () => {
|
|
126
|
+
await withTestDir(async (dir) => {
|
|
127
|
+
await initRepo(dir);
|
|
128
|
+
const first = await claimWorkLease("W-1", "other-dispatcher", { cwd: dir, ttlMs: 60_000 });
|
|
129
|
+
expect(first.ok).toBe(true);
|
|
130
|
+
const log: string[] = [];
|
|
131
|
+
const activities = new Map<string, ActivityFn>([["build", async () => { log.push("build"); return {}; }]]);
|
|
132
|
+
const result = await run(dispatchOp({ workLease: { item: "W-1" } }), activities, dir);
|
|
133
|
+
|
|
134
|
+
expect(result.status).toBe("ok");
|
|
135
|
+
expect(log).toEqual([]);
|
|
136
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([["workLease:claim", "skipped"], ["build", "skipped"]]);
|
|
137
|
+
expect(result.records[0].refusal).toMatch(/lease-held: W-1 is held by other-dispatcher/);
|
|
138
|
+
expect(result.workLease).toMatchObject({ item: null, released: false, refusal: expect.stringMatching(/other-dispatcher/) });
|
|
139
|
+
expect((await listWorkLeases({ cwd: dir, memberPrefix: "" }))[0]).toMatchObject({ item: "W-1", holder: "other-dispatcher" });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("candidates are tried in order, and an item picked by an earlier step is claimed right after it", async () => {
|
|
144
|
+
await withTestDir(async (dir) => {
|
|
145
|
+
await initRepo(dir);
|
|
146
|
+
await claimWorkLease("W-1", "other", { cwd: dir, ttlMs: 60_000 });
|
|
147
|
+
const activities = new Map<string, ActivityFn>([
|
|
148
|
+
["pick", async () => ({ ready: ["W-1", "W-2", "W-3"] })],
|
|
149
|
+
["build", async () => ({})],
|
|
150
|
+
]);
|
|
151
|
+
const op = dispatchOp({ workLease: { item: stepOutput("pick", "ready") } }, [
|
|
152
|
+
step("pick", {}, { id: "pick" }),
|
|
153
|
+
step("build", { item: workLeaseOutput("item") }),
|
|
154
|
+
]);
|
|
155
|
+
const result = await run(op, activities, dir);
|
|
156
|
+
expect(result.records.map((r) => r.fn)).toEqual(["pick", "workLease:claim", "build"]);
|
|
157
|
+
expect(result.records[2].args).toEqual({ item: "W-2" });
|
|
158
|
+
expect(result.workLease).toMatchObject({ item: "W-2", released: true });
|
|
159
|
+
|
|
160
|
+
const none = await run(op, new Map<string, ActivityFn>([["pick", async () => ({ ready: [] })], ["build", async () => ({})]]), dir);
|
|
161
|
+
expect(none.status).toBe("ok");
|
|
162
|
+
expect(none.records.map((r) => [r.fn, r.status])).toEqual([["pick", "ok"], ["workLease:claim", "skipped"], ["build", "skipped"]]);
|
|
163
|
+
expect(none.workLease?.refusal).toMatch(/nothing to claim/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("--work takes the place of the Op's item, and a failed run releases not_done", async () => {
|
|
168
|
+
await withTestDir(async (dir) => {
|
|
169
|
+
await initRepo(dir);
|
|
170
|
+
const activities = new Map<string, ActivityFn>([["build", async () => { throw new Error("the check fails"); }]]);
|
|
171
|
+
const result = await run(dispatchOp({ workLease: {} }), activities, dir, { item: "W-9" });
|
|
172
|
+
expect(result.status).toBe("fail");
|
|
173
|
+
expect(result.workLease).toMatchObject({ item: "W-9", released: true, outcome: "not_done" });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the heartbeat renews the lease while a long step runs, keeping its token", async () => {
|
|
178
|
+
await withTestDir(async (dir) => {
|
|
179
|
+
await initRepo(dir);
|
|
180
|
+
let during: { expiresAt: string; token: string } | undefined;
|
|
181
|
+
const activities = new Map<string, ActivityFn>([
|
|
182
|
+
[
|
|
183
|
+
"build",
|
|
184
|
+
async (args) => {
|
|
185
|
+
const lease = args.lease as { item: string; expiresAt: string; token: string };
|
|
186
|
+
const claimedUntil = lease.expiresAt;
|
|
187
|
+
// Renewed every third of 3s: wait for the ref to move past the claim's expiry.
|
|
188
|
+
await waitFor(async () => {
|
|
189
|
+
const [l] = await listWorkLeases({ cwd: dir, memberPrefix: "" });
|
|
190
|
+
return !!l && l.expiresAt > claimedUntil;
|
|
191
|
+
});
|
|
192
|
+
const [l] = await listWorkLeases({ cwd: dir, memberPrefix: "" });
|
|
193
|
+
during = { expiresAt: l.expiresAt, token: l.token };
|
|
194
|
+
return {};
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
]);
|
|
198
|
+
const result = await run(dispatchOp({ workLease: { item: "W-1", ttl: "3s" } }, [step("build", { lease: workLeaseOutput() }, { profile: "long" } as never)]), activities, dir);
|
|
199
|
+
expect(result.status).toBe("ok");
|
|
200
|
+
expect(during?.token).toBe(result.workLease?.token);
|
|
201
|
+
const events = (await readLeaseHistory("W-1", { cwd: dir })).records.map((h) => h.event);
|
|
202
|
+
expect(events[0]).toBe("claim");
|
|
203
|
+
expect(events).toContain("renew");
|
|
204
|
+
expect(events.at(-1)).toBe("release");
|
|
205
|
+
});
|
|
206
|
+
}, 30_000);
|
|
207
|
+
|
|
208
|
+
test("a lease lost mid-step stops the step and the run, which fails lease-lost and releases nothing", async () => {
|
|
209
|
+
await withTestDir(async (dir) => {
|
|
210
|
+
await initRepo(dir);
|
|
211
|
+
const log: string[] = [];
|
|
212
|
+
const activities = new Map<string, ActivityFn>([
|
|
213
|
+
[
|
|
214
|
+
"build",
|
|
215
|
+
async (args, signal) => {
|
|
216
|
+
const lease = args.lease as { item: string; holder: string; token: string };
|
|
217
|
+
// The lease runs out from under this run and another worker takes it.
|
|
218
|
+
await releaseWorkLease(lease.item, lease.holder, { cwd: dir, token: lease.token });
|
|
219
|
+
expect((await claimWorkLease(lease.item, "thief", { cwd: dir, ttlMs: 60_000 })).ok).toBe(true);
|
|
220
|
+
await new Promise<void>((resolve, reject) => {
|
|
221
|
+
const t = setTimeout(() => reject(new Error("never stopped")), 20_000);
|
|
222
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); log.push("stopped"); reject(new Error("aborted")); }, { once: true });
|
|
223
|
+
});
|
|
224
|
+
return {};
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
["after", async () => { log.push("after"); return {}; }],
|
|
228
|
+
]);
|
|
229
|
+
const op = dispatchOp({ workLease: { item: "W-1", ttl: "3s" } }, [
|
|
230
|
+
step("build", { lease: workLeaseOutput() }, { profile: "long" } as never),
|
|
231
|
+
step("after"),
|
|
232
|
+
]);
|
|
233
|
+
const result = await run(op, activities, dir);
|
|
234
|
+
|
|
235
|
+
expect(log).toEqual(["stopped"]);
|
|
236
|
+
expect(result.status).toBe("fail");
|
|
237
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([
|
|
238
|
+
["workLease:claim", "ok"],
|
|
239
|
+
["build", "fail"],
|
|
240
|
+
["after", "skipped"],
|
|
241
|
+
["workLease:lost", "fail"],
|
|
242
|
+
]);
|
|
243
|
+
expect(result.records.at(-1)?.error).toMatch(/^lease-lost: W-1 is held by thief/);
|
|
244
|
+
expect(result.workLease).toMatchObject({ released: false, lost: expect.stringMatching(/thief/) });
|
|
245
|
+
// The thief still holds it: a lost lease isn't this run's to give back.
|
|
246
|
+
expect((await listWorkLeases({ cwd: dir, memberPrefix: "" }))[0]).toMatchObject({ holder: "thief", state: "active" });
|
|
247
|
+
expect((await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)?.status).toBe("fail");
|
|
248
|
+
});
|
|
249
|
+
}, 30_000);
|
|
250
|
+
|
|
251
|
+
test("a run whose lease was lost before it is recorded is never recorded as done", async () => {
|
|
252
|
+
await withTestDir(async (dir) => {
|
|
253
|
+
await initRepo(dir);
|
|
254
|
+
const activities = new Map<string, ActivityFn>([
|
|
255
|
+
[
|
|
256
|
+
"build",
|
|
257
|
+
async (args) => {
|
|
258
|
+
const lease = args.lease as { item: string; holder: string; token: string };
|
|
259
|
+
await releaseWorkLease(lease.item, lease.holder, { cwd: dir, token: lease.token });
|
|
260
|
+
await claimWorkLease(lease.item, "thief", { cwd: dir, ttlMs: 60_000 });
|
|
261
|
+
return {};
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
]);
|
|
265
|
+
const result = await run(dispatchOp({ workLease: { item: "W-1" } }), activities, dir);
|
|
266
|
+
expect(result.status).toBe("fail");
|
|
267
|
+
expect(result.records.map((r) => r.fn)).toEqual(["workLease:claim", "build", "workLease:lost"]);
|
|
268
|
+
expect(result.workLease).toMatchObject({ released: false });
|
|
269
|
+
expect((await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)?.status).toBe("fail");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("an Op that changes the checkout", () => {
|
|
275
|
+
test("applies a build on chant/work/<item> in a worktree of its own, and the coding agent's edits and index lock are untouched", async () => {
|
|
276
|
+
await withTestDir(async (dir) => {
|
|
277
|
+
await initRepo(dir);
|
|
278
|
+
const head = git(["rev-parse", "HEAD"], dir).stdout.trim();
|
|
279
|
+
const edited = "export const hello = 2; // the coding agent's uncommitted edit\n";
|
|
280
|
+
writeFileSync(join(dir, "app", "index.ts"), edited);
|
|
281
|
+
const lock = join(dir, ".git", "index.lock");
|
|
282
|
+
writeFileSync(lock, "");
|
|
283
|
+
|
|
284
|
+
let worktree = "";
|
|
285
|
+
const activities = new Map<string, ActivityFn>([
|
|
286
|
+
[
|
|
287
|
+
"applyBuild",
|
|
288
|
+
async (args) => {
|
|
289
|
+
worktree = String(args.worktree);
|
|
290
|
+
// The build the builder pushed, applied and committed in the worktree.
|
|
291
|
+
writeFileSync(join(worktree, "app", "index.ts"), "export const hello = 3; // the build\n");
|
|
292
|
+
writeFileSync(join(worktree, "app", "feature.ts"), "export const feature = true;\n");
|
|
293
|
+
expect(git(["add", "-A"], worktree).status).toBe(0);
|
|
294
|
+
expect(git(["commit", "-q", "-m", "apply W-7"], worktree).status).toBe(0);
|
|
295
|
+
return {};
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
]);
|
|
299
|
+
const steward = declareSteward({
|
|
300
|
+
name: "box",
|
|
301
|
+
ops: [
|
|
302
|
+
dispatchOp({ workLease: { item: "W-7" }, changesCheckout: true, schedule: { cron: "* * * * *" } }, [
|
|
303
|
+
step("applyBuild", { worktree: workLeaseOutput("worktree"), branch: workLeaseOutput("branch") }),
|
|
304
|
+
]),
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
const events = await runOperatorRound({ cwd: dir, holder: "h1", steward, activities, profiles: PROFILES, now: () => new Date(2026, 8, 25, 10, 1) });
|
|
308
|
+
expect(events.map((e) => e.kind)).toEqual(["ticked"]);
|
|
309
|
+
const result = (events[0] as { result: OpRunResult }).result;
|
|
310
|
+
expect(result.workLease).toMatchObject({ item: "W-7", branch: "chant/work/W-7", released: true, outcome: "done" });
|
|
311
|
+
expect(result.workLease?.holder).toBe(stewardWorkHolder("box", "dispatch", "h1"));
|
|
312
|
+
|
|
313
|
+
// The worktree sat under the git directory and is gone; the branch keeps the build.
|
|
314
|
+
expect(worktree.startsWith(join(dir, ".git"))).toBe(true);
|
|
315
|
+
expect(existsSync(worktree)).toBe(false);
|
|
316
|
+
expect(git(["show", "chant/work/W-7:app/feature.ts"], dir).stdout).toBe("export const feature = true;\n");
|
|
317
|
+
|
|
318
|
+
// The coding agent's checkout: its edit, its lock, HEAD and the index, as they were.
|
|
319
|
+
expect(readFileSync(join(dir, "app", "index.ts"), "utf-8")).toBe(edited);
|
|
320
|
+
expect(existsSync(lock)).toBe(true);
|
|
321
|
+
rmSync(lock);
|
|
322
|
+
expect(git(["rev-parse", "HEAD"], dir).stdout.trim()).toBe(head);
|
|
323
|
+
expect(git(["status", "--porcelain"], dir).stdout).toBe(" M app/index.ts\n");
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|