@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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Op run under a work item's lease (#2748, the Steward's gap 4 in #2731).
|
|
3
|
+
*
|
|
4
|
+
* An Op that declares `workLease` (`./types.ts`) runs its steps under the work
|
|
5
|
+
* lease of one work item (`../lifecycle/work-lease.ts`, ws-055). The local
|
|
6
|
+
* executor (`./local-executor.ts`) drives the {@link RunWorkLease} here:
|
|
7
|
+
*
|
|
8
|
+
* - Claim. Before the first step when the item is a literal or named by the
|
|
9
|
+
* run (`chant run <op> --work <id>`); right after the producing step when
|
|
10
|
+
* the item is a reference to a step's output. A list of candidates is tried
|
|
11
|
+
* in order and the first one nobody holds is taken. When nothing can be
|
|
12
|
+
* claimed the run ends `ok` with its remaining steps skipped, and the claim's
|
|
13
|
+
* record carries the refusal, which names who holds the item.
|
|
14
|
+
* - Heartbeat. While the steps run, the lease is renewed every third of its
|
|
15
|
+
* time to live, keeping its fencing token. A renewal the lease refuses (it
|
|
16
|
+
* ran out and someone else claimed it, or it was released) means the lease
|
|
17
|
+
* is lost: the in-flight step's abort signal fires, no further step starts,
|
|
18
|
+
* and the run fails with `lease-lost`. A renewal that fails for any other
|
|
19
|
+
* reason (the remote didn't answer) is retried at the next beat; if that
|
|
20
|
+
* goes on past the expiry, the next renewal is refused and the lease is lost
|
|
21
|
+
* then. These are the semantics of chud's dispatcher
|
|
22
|
+
* (packages/runtime/src/dispatch.mjs), which this replaces.
|
|
23
|
+
* - Fence. Before the run's record is written, one last renewal checks that
|
|
24
|
+
* the lease is still this run's, with the same token. Refused, the run is
|
|
25
|
+
* recorded as failed with `lease-lost`, never as done.
|
|
26
|
+
* - Release. When the run ends, the lease is given back with the run's
|
|
27
|
+
* outcome (`done`, `not_done`, `gated`, or the Op's own `outcome`), which
|
|
28
|
+
* lands on `_leases/<id>.jsonl`. A lost lease is not released: it isn't
|
|
29
|
+
* this run's to give back.
|
|
30
|
+
*
|
|
31
|
+
* Steps read the lease through {@link workLeaseOutput}: its item, holder,
|
|
32
|
+
* token and expiry, and for an Op that changes the checkout, the worktree and
|
|
33
|
+
* branch it works in. An activity that writes somewhere a token can fence
|
|
34
|
+
* (another ledger, a remote) passes the token along.
|
|
35
|
+
*
|
|
36
|
+
* ## Changing the checkout
|
|
37
|
+
*
|
|
38
|
+
* An Op with `changesCheckout` (applying a build, `chant workspace upgrade`)
|
|
39
|
+
* must declare `workLease`, and its leased steps get a git worktree of their
|
|
40
|
+
* own: `chant/work/<item>` checked out under the repository's git directory,
|
|
41
|
+
* from the checkout's HEAD, or from the branch as an earlier run left it. The
|
|
42
|
+
* worktree is removed when the run ends and the branch keeps whatever the
|
|
43
|
+
* steps committed, for the runner to deliver. The checkout a coding agent is
|
|
44
|
+
* editing, its index and its uncommitted files, are never touched.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { existsSync, rmSync } from "node:fs";
|
|
48
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
49
|
+
import { getRuntime } from "../runtime-adapter";
|
|
50
|
+
import {
|
|
51
|
+
claimWorkLease,
|
|
52
|
+
DEFAULT_WORK_LEASE_TTL_MS,
|
|
53
|
+
releaseWorkLease,
|
|
54
|
+
renewWorkLease,
|
|
55
|
+
workLeaseRef,
|
|
56
|
+
WORK_ITEM_ID_PATTERN,
|
|
57
|
+
type WorkLease,
|
|
58
|
+
} from "../lifecycle/work-lease";
|
|
59
|
+
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
60
|
+
import { currentHolderId } from "../lifecycle/lease";
|
|
61
|
+
import { parseDuration } from "./duration";
|
|
62
|
+
import { isStepOutputRef, stepOutput, type StepOutputRef } from "./step-output-ref";
|
|
63
|
+
import { WORK_LEASE_STEP_ID, type OpConfig } from "./types";
|
|
64
|
+
|
|
65
|
+
export { WORK_LEASE_STEP_ID } from "./types";
|
|
66
|
+
export { workLeaseProblems, workLeaseNeedsRunItem } from "./work-lease-decl";
|
|
67
|
+
|
|
68
|
+
/** Why a run under a work lease failed when the lease was lost: chud's reason, kept. */
|
|
69
|
+
export const LEASE_LOST = "lease-lost";
|
|
70
|
+
|
|
71
|
+
/** The branch prefix an Op that changes the checkout works on. */
|
|
72
|
+
export const WORK_BRANCH_PREFIX = "chant/work/";
|
|
73
|
+
|
|
74
|
+
/** What a run's steps read about its work lease, via {@link workLeaseOutput}. */
|
|
75
|
+
export interface WorkLeaseOutput {
|
|
76
|
+
item: string;
|
|
77
|
+
holder: string;
|
|
78
|
+
/** The fencing token: the same for the whole run unless the lease is lost. */
|
|
79
|
+
token: string;
|
|
80
|
+
acquiredAt: string;
|
|
81
|
+
expiresAt: string;
|
|
82
|
+
/** The lease ref. */
|
|
83
|
+
ref: string;
|
|
84
|
+
/** For an Op that changes the checkout: the worktree its steps work in. Otherwise null. */
|
|
85
|
+
worktree: string | null;
|
|
86
|
+
/** For an Op that changes the checkout: the worktree's branch. Otherwise null. */
|
|
87
|
+
branch: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How a run's work lease ended, on `OpRunResult.workLease`. */
|
|
91
|
+
export interface WorkLeaseRunResult {
|
|
92
|
+
/** The item claimed, or null when the run claimed nothing. */
|
|
93
|
+
item: string | null;
|
|
94
|
+
holder: string;
|
|
95
|
+
token: string | null;
|
|
96
|
+
/** The branch an Op that changes the checkout worked on, or null. */
|
|
97
|
+
branch: string | null;
|
|
98
|
+
/** Whether the lease was given back. False when it was lost, or never claimed. */
|
|
99
|
+
released: boolean;
|
|
100
|
+
/** The outcome the release recorded, or null when there was no release. */
|
|
101
|
+
outcome: string | null;
|
|
102
|
+
/** Why the lease was lost, or null when it wasn't. */
|
|
103
|
+
lost: string | null;
|
|
104
|
+
/** Why nothing was claimed, or null when something was. */
|
|
105
|
+
refusal: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A reference to the run's work lease, for a step's args:
|
|
110
|
+
* `workLeaseOutput("token")`, `workLeaseOutput("worktree")`, or the whole
|
|
111
|
+
* {@link WorkLeaseOutput} with no path.
|
|
112
|
+
*/
|
|
113
|
+
export function workLeaseOutput(path?: keyof WorkLeaseOutput): StepOutputRef {
|
|
114
|
+
return stepOutput(WORK_LEASE_STEP_ID, path);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The holder a steward's turn claims work leases as:
|
|
119
|
+
* `<steward>/<op>@<process>`. `chant workspace status --json` reads it back
|
|
120
|
+
* to show the lease a steward's current turn holds beside its Ops.
|
|
121
|
+
*/
|
|
122
|
+
export function stewardWorkHolder(steward: string, op: string, process: string = currentHolderId()): string {
|
|
123
|
+
return `${steward}/${op}@${process}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolvePath(value: unknown, path?: string): unknown {
|
|
127
|
+
if (!path) return value;
|
|
128
|
+
return path.split(".").reduce<unknown>((acc, key) => (acc == null ? acc : (acc as Record<string, unknown>)[key]), value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function git(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
132
|
+
const out = await getRuntime().spawn(["git", ...args], { cwd });
|
|
133
|
+
return { ok: out.exitCode === 0, stdout: out.stdout.trim(), stderr: out.stderr.trim() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What a claim attempt came to, for the executor to record. */
|
|
137
|
+
export type WorkClaimOutcome =
|
|
138
|
+
| { kind: "claimed"; lease: WorkLeaseOutput; tried: string[] }
|
|
139
|
+
/** Nothing to claim, or every candidate is held. `refusal` says which. */
|
|
140
|
+
| { kind: "unclaimed"; refusal: string; tried: string[] };
|
|
141
|
+
|
|
142
|
+
export interface RunWorkLeaseOptions {
|
|
143
|
+
/** The project directory: where the run's ledger is, and what `workLease.kind` is relative to. */
|
|
144
|
+
cwd: string;
|
|
145
|
+
holder?: string;
|
|
146
|
+
/** The item the run names (`--work`), which takes the place of the Op's own. */
|
|
147
|
+
item?: string;
|
|
148
|
+
/** Called with a renewal's error that didn't lose the lease, which the next beat retries. */
|
|
149
|
+
onRenewError?: (message: string) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** One run's hold on one work item's lease. Created by the executor for an Op with `workLease`. */
|
|
153
|
+
export class RunWorkLease {
|
|
154
|
+
readonly holder: string;
|
|
155
|
+
readonly ttlMs: number;
|
|
156
|
+
/** Aborts when the lease is lost, so the step in flight stops. */
|
|
157
|
+
readonly lostSignal: AbortSignal;
|
|
158
|
+
/** Set once the claim was made or given up on. */
|
|
159
|
+
attempted = false;
|
|
160
|
+
lease: WorkLeaseOutput | undefined;
|
|
161
|
+
refusal: string | undefined;
|
|
162
|
+
lost: string | undefined;
|
|
163
|
+
|
|
164
|
+
private readonly spec: NonNullable<OpConfig["workLease"]>;
|
|
165
|
+
private readonly abort = new AbortController();
|
|
166
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
167
|
+
private beat: Promise<void> | undefined;
|
|
168
|
+
|
|
169
|
+
constructor(
|
|
170
|
+
private readonly config: Pick<OpConfig, "name" | "workLease" | "changesCheckout">,
|
|
171
|
+
private readonly opts: RunWorkLeaseOptions,
|
|
172
|
+
) {
|
|
173
|
+
if (!config.workLease) throw new Error(`Op "${config.name}" declares no workLease`);
|
|
174
|
+
this.spec = config.workLease;
|
|
175
|
+
this.holder = opts.holder ?? currentHolderId();
|
|
176
|
+
this.ttlMs = this.spec.ttl ? parseDuration(this.spec.ttl) : DEFAULT_WORK_LEASE_TTL_MS;
|
|
177
|
+
this.lostSignal = this.abort.signal;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The directory whose ledger holds the lease: the kind file's, or the project's. */
|
|
181
|
+
private get ledgerCwd(): string {
|
|
182
|
+
const kind = this.spec.kind;
|
|
183
|
+
if (!kind) return this.opts.cwd;
|
|
184
|
+
const file = isAbsolute(kind) ? kind : resolve(this.opts.cwd, kind);
|
|
185
|
+
return resolve(file, "..");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether the claim is due at a step boundary, given the results so far. */
|
|
189
|
+
due(resultsById: ReadonlyMap<string, unknown>): boolean {
|
|
190
|
+
if (this.attempted) return false;
|
|
191
|
+
if (this.opts.item !== undefined) return true;
|
|
192
|
+
const item = this.spec.item;
|
|
193
|
+
return !isStepOutputRef(item) || resultsById.has(item.step);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The candidate ids, in order. Empty when the reference resolved to nothing. */
|
|
197
|
+
private candidates(resultsById: ReadonlyMap<string, unknown>): string[] {
|
|
198
|
+
if (this.opts.item !== undefined) return [this.opts.item];
|
|
199
|
+
const item = this.spec.item;
|
|
200
|
+
const value = isStepOutputRef(item) ? resolvePath(resultsById.get(item.step), item.path) : item;
|
|
201
|
+
if (value === undefined || value === null || value === "") return [];
|
|
202
|
+
const list = Array.isArray(value) ? value : [value];
|
|
203
|
+
return list.filter((v) => v !== undefined && v !== null && v !== "").map((v) => {
|
|
204
|
+
if (typeof v !== "string" || !WORK_ITEM_ID_PATTERN.test(v) || v.includes("..")) {
|
|
205
|
+
throw new Error(`Op "${this.config.name}": workLease.item resolved to ${JSON.stringify(v)}, which can't key a work lease`);
|
|
206
|
+
}
|
|
207
|
+
return v;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Claim the first candidate nobody holds. On a claim, the heartbeat starts,
|
|
213
|
+
* and for an Op that changes the checkout its worktree is made. Throws when
|
|
214
|
+
* the claim could not be made for a reason other than someone holding it
|
|
215
|
+
* (git failed): the run fails, as for any failing step.
|
|
216
|
+
*/
|
|
217
|
+
async claim(resultsById: ReadonlyMap<string, unknown>): Promise<WorkClaimOutcome> {
|
|
218
|
+
this.attempted = true;
|
|
219
|
+
const tried = this.candidates(resultsById);
|
|
220
|
+
if (tried.length === 0) {
|
|
221
|
+
this.refusal = "nothing to claim: the work item resolved to no id";
|
|
222
|
+
return { kind: "unclaimed", refusal: this.refusal, tried };
|
|
223
|
+
}
|
|
224
|
+
const refused: string[] = [];
|
|
225
|
+
for (const id of tried) {
|
|
226
|
+
const result = await claimWorkLease(id, this.holder, { cwd: this.ledgerCwd, ttlMs: this.ttlMs, note: `op ${this.config.name}` });
|
|
227
|
+
if (!result.ok) {
|
|
228
|
+
refused.push(`${result.reason}: ${result.message}`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const { prefix, members } = await resolveMemberLedger(this.ledgerCwd);
|
|
232
|
+
const lease: WorkLeaseOutput = {
|
|
233
|
+
...pick(result.lease),
|
|
234
|
+
ref: workLeaseRef(id, prefix),
|
|
235
|
+
worktree: null,
|
|
236
|
+
branch: null,
|
|
237
|
+
};
|
|
238
|
+
this.lease = lease;
|
|
239
|
+
if (this.config.changesCheckout) {
|
|
240
|
+
try {
|
|
241
|
+
const made = await this.makeWorktree(id, members);
|
|
242
|
+
lease.worktree = made.worktree;
|
|
243
|
+
lease.branch = made.branch;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
await releaseWorkLease(id, this.holder, { cwd: this.ledgerCwd, token: lease.token, outcome: "not_done", note: "the worktree could not be made" }).catch(() => undefined);
|
|
246
|
+
this.lease = undefined;
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.startHeartbeat();
|
|
251
|
+
return { kind: "claimed", lease, tried };
|
|
252
|
+
}
|
|
253
|
+
this.refusal = refused.join("; ");
|
|
254
|
+
return { kind: "unclaimed", refusal: this.refusal, tried };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A worktree for `id` on `chant/work/[<member>/]<id>`, under the git
|
|
259
|
+
* directory so the checkout's own `git status` never lists it. Starts from
|
|
260
|
+
* the branch when an earlier run left it, from HEAD otherwise.
|
|
261
|
+
*/
|
|
262
|
+
private async makeWorktree(id: string, members: readonly string[]): Promise<{ worktree: string; branch: string }> {
|
|
263
|
+
const cwd = this.opts.cwd;
|
|
264
|
+
const common = await git(["rev-parse", "--git-common-dir"], cwd);
|
|
265
|
+
if (!common.ok) throw new Error(`Op "${this.config.name}" changes the checkout, but ${cwd} is not a git checkout: ${common.stderr}`);
|
|
266
|
+
const commonDir = isAbsolute(common.stdout) ? common.stdout : resolve(cwd, common.stdout);
|
|
267
|
+
const scope = members.join("/");
|
|
268
|
+
const branch = `${WORK_BRANCH_PREFIX}${scope ? `${scope}/` : ""}${id}`;
|
|
269
|
+
const worktree = join(commonDir, "chant-work", ...members, id);
|
|
270
|
+
if (existsSync(worktree)) {
|
|
271
|
+
await git(["worktree", "remove", "--force", worktree], cwd);
|
|
272
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
await git(["worktree", "prune"], cwd);
|
|
275
|
+
const exists = (await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd)).ok;
|
|
276
|
+
const add = exists
|
|
277
|
+
? await git(["worktree", "add", "--quiet", worktree, branch], cwd)
|
|
278
|
+
: await git(["worktree", "add", "--quiet", "-b", branch, worktree, "HEAD"], cwd);
|
|
279
|
+
if (!add.ok) throw new Error(`Op "${this.config.name}": could not make the worktree for ${id} on ${branch}: ${add.stderr}`);
|
|
280
|
+
return { worktree, branch };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private lose(why: string): void {
|
|
284
|
+
if (this.lost !== undefined) return;
|
|
285
|
+
this.lost = why;
|
|
286
|
+
this.stopHeartbeat();
|
|
287
|
+
this.abort.abort(new Error(`${LEASE_LOST}: ${why}`));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One renewal. Refused, the lease is lost; failed otherwise, the next beat retries. */
|
|
291
|
+
private async renew(): Promise<void> {
|
|
292
|
+
const lease = this.lease;
|
|
293
|
+
if (!lease || this.lost !== undefined) return;
|
|
294
|
+
try {
|
|
295
|
+
const result = await renewWorkLease(lease.item, this.holder, { cwd: this.ledgerCwd, ttlMs: this.ttlMs, token: lease.token });
|
|
296
|
+
if (result.ok) {
|
|
297
|
+
lease.expiresAt = result.lease.expiresAt;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// The remote refusing the push is the lease held elsewhere only when it
|
|
301
|
+
// says who holds it; unreachable, the next beat tries again.
|
|
302
|
+
if (result.reason === "lease-push-rejected" && !result.heldBy) {
|
|
303
|
+
this.opts.onRenewError?.(result.message);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (result.reason === "lease-race") {
|
|
307
|
+
this.opts.onRenewError?.(result.message);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.lose(result.message);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
this.opts.onRenewError?.(err instanceof Error ? err.message : String(err));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private startHeartbeat(): void {
|
|
317
|
+
const every = Math.max(1_000, Math.floor(this.ttlMs / 3));
|
|
318
|
+
this.timer = setInterval(() => {
|
|
319
|
+
if (this.beat) return;
|
|
320
|
+
this.beat = this.renew().finally(() => {
|
|
321
|
+
this.beat = undefined;
|
|
322
|
+
});
|
|
323
|
+
}, every);
|
|
324
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private stopHeartbeat(): void {
|
|
328
|
+
if (this.timer) clearInterval(this.timer);
|
|
329
|
+
this.timer = undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The last renewal before the run's record is written, after the heartbeat
|
|
334
|
+
* stops. Returns whether the lease is still this run's.
|
|
335
|
+
*/
|
|
336
|
+
async fence(): Promise<boolean> {
|
|
337
|
+
if (!this.lease) return this.lost === undefined;
|
|
338
|
+
this.stopHeartbeat();
|
|
339
|
+
await this.beat;
|
|
340
|
+
if (this.lost !== undefined) return false;
|
|
341
|
+
const lease = this.lease;
|
|
342
|
+
try {
|
|
343
|
+
const result = await renewWorkLease(lease.item, this.holder, { cwd: this.ledgerCwd, ttlMs: this.ttlMs, token: lease.token });
|
|
344
|
+
if (result.ok) lease.expiresAt = result.lease.expiresAt;
|
|
345
|
+
else this.lose(result.message);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
this.lose(`the lease could not be renewed before the run was recorded: ${err instanceof Error ? err.message : String(err)}`);
|
|
348
|
+
}
|
|
349
|
+
return this.lost === undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* End the run's hold: stop the heartbeat, remove the worktree (the branch
|
|
354
|
+
* keeps what was committed), and give the lease back with the outcome
|
|
355
|
+
* unless it was lost.
|
|
356
|
+
*/
|
|
357
|
+
async finish(status: "ok" | "fail" | "gated", resultsById: ReadonlyMap<string, unknown>): Promise<WorkLeaseRunResult> {
|
|
358
|
+
this.stopHeartbeat();
|
|
359
|
+
await this.beat;
|
|
360
|
+
const lease = this.lease;
|
|
361
|
+
const base: WorkLeaseRunResult = {
|
|
362
|
+
item: lease?.item ?? null,
|
|
363
|
+
holder: this.holder,
|
|
364
|
+
token: lease?.token ?? null,
|
|
365
|
+
branch: lease?.branch ?? null,
|
|
366
|
+
released: false,
|
|
367
|
+
outcome: null,
|
|
368
|
+
lost: this.lost ?? null,
|
|
369
|
+
refusal: this.refusal ?? null,
|
|
370
|
+
};
|
|
371
|
+
if (!lease) return base;
|
|
372
|
+
if (lease.worktree) {
|
|
373
|
+
await git(["worktree", "remove", "--force", lease.worktree], this.opts.cwd).catch(() => undefined);
|
|
374
|
+
rmSync(lease.worktree, { recursive: true, force: true });
|
|
375
|
+
await git(["worktree", "prune"], this.opts.cwd).catch(() => undefined);
|
|
376
|
+
}
|
|
377
|
+
if (this.lost !== undefined) return base;
|
|
378
|
+
let outcome = status === "ok" ? "done" : status === "gated" ? "gated" : "not_done";
|
|
379
|
+
if (status === "ok" && this.spec.outcome && isStepOutputRef(this.spec.outcome)) {
|
|
380
|
+
const declared = resolvePath(resultsById.get(this.spec.outcome.step), this.spec.outcome.path);
|
|
381
|
+
if (typeof declared === "string" && declared.trim() !== "") outcome = declared.trim();
|
|
382
|
+
}
|
|
383
|
+
const released = await releaseWorkLease(lease.item, this.holder, {
|
|
384
|
+
cwd: this.ledgerCwd,
|
|
385
|
+
token: lease.token,
|
|
386
|
+
outcome,
|
|
387
|
+
note: `op ${this.config.name}`,
|
|
388
|
+
}).catch(() => undefined);
|
|
389
|
+
return { ...base, released: released?.ok === true, outcome: released?.ok ? outcome : null };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function pick(lease: WorkLease): Omit<WorkLeaseOutput, "ref" | "worktree" | "branch"> {
|
|
394
|
+
return { item: lease.item, holder: lease.holder, token: lease.token, acquiredAt: lease.acquiredAt, expiresAt: lease.expiresAt };
|
|
395
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box isolation (#2727): hosts in the declaration and a host, slot, ports,
|
|
3
|
+
* state and cookies in a member's box block, the values derived from a box's
|
|
4
|
+
* identity, `chant workspace check`'s WSP123 and WSP124, and the resolved
|
|
5
|
+
* values in `chant workspace status --json`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
13
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
+
import { boxCollisions, boxLiterals, resolveBoxes, stateRootLiteral, statePathLiteral } from "./box-isolation";
|
|
15
|
+
import { runDeclarationChecks, WORKSPACE_CHECKS } from "./checks";
|
|
16
|
+
import { DEFAULT_STATE_ROOT, parseDeclaration, WorkspaceReadError, type Declaration } from "./declaration";
|
|
17
|
+
import { formatStatus, workspaceStatus } from "./status";
|
|
18
|
+
import statusSchema from "./status.schema.json";
|
|
19
|
+
|
|
20
|
+
const FILE = "chant.workspace.json";
|
|
21
|
+
|
|
22
|
+
const scratch: string[] = [];
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const local = { name: "local", ports: { from: 7100, to: 7999, perBox: 20 } };
|
|
28
|
+
|
|
29
|
+
/** A box member, with the ports, hud state and cookies chaff's spec/box.ts and kernel/door.mjs hard-code today. */
|
|
30
|
+
const chaffBox = (name: string, slot: number, extra: Record<string, unknown> = {}) => ({
|
|
31
|
+
name,
|
|
32
|
+
dir: `boxes/${name}`,
|
|
33
|
+
kind: "other",
|
|
34
|
+
because: "a planted box",
|
|
35
|
+
box: {
|
|
36
|
+
host: "local",
|
|
37
|
+
slot,
|
|
38
|
+
ports: { app: 0, door: 1, proxy: 2, inject: 3, ticker: 4, behold: 5, surface: 6, historian: 7 },
|
|
39
|
+
state: { HUD_IDENTITY_PATH: "hud/identity.json", HUD_DB_PATH: "hud/events.db", CHAFF_HUD_IDENTITY: "hud/identity.json" },
|
|
40
|
+
cookies: ["hud_session", "chaff_door"],
|
|
41
|
+
...extra,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
type BoxMember = ReturnType<typeof chaffBox>;
|
|
45
|
+
|
|
46
|
+
const declaration = (members: BoxMember[], hosts: unknown[] = [local]) => ({ name: "lobby", schema: 1, members, hosts });
|
|
47
|
+
|
|
48
|
+
/** A committed git repository holding the declaration, and a file in each member's directory. */
|
|
49
|
+
function repo(value: ReturnType<typeof declaration>): string {
|
|
50
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-box-isolation-")));
|
|
51
|
+
scratch.push(root);
|
|
52
|
+
const files: Record<string, string> = { [FILE]: JSON.stringify(value, null, 2) };
|
|
53
|
+
for (const m of value.members) files[`${m.dir}/README.md`] = `# ${m.name}\n`;
|
|
54
|
+
for (const [path, text] of Object.entries(files)) {
|
|
55
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
56
|
+
writeFileSync(join(root, path), text);
|
|
57
|
+
}
|
|
58
|
+
const git = (args: string[]) =>
|
|
59
|
+
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "ignore" });
|
|
60
|
+
git(["init", "-q", "-b", "main"]);
|
|
61
|
+
git(["add", "-A"]);
|
|
62
|
+
git(["commit", "-q", "-m", "init"]);
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The box findings, less WSP009, which every member of kind other gets. */
|
|
67
|
+
async function findings(root: string) {
|
|
68
|
+
const report = await runDeclarationChecks(root);
|
|
69
|
+
return { ...report, diagnostics: report.diagnostics.filter((d) => d.ruleId !== "WSP009") };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parse(value: unknown, reader = "0.91.0"): Declaration {
|
|
73
|
+
return parseDeclaration(JSON.stringify(value, null, 2), FILE, reader);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function refusal(value: unknown): WorkspaceReadError {
|
|
77
|
+
try {
|
|
78
|
+
parse(value);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof WorkspaceReadError) return err;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
throw new Error("expected the declaration to be refused");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("hosts, and a box's isolation in its block (#2727)", () => {
|
|
87
|
+
test("are read, with the host's state root null when it is the default and no isolation without a host", () => {
|
|
88
|
+
const d = parse({ ...declaration([chaffBox("fern", 0)]), members: [chaffBox("fern", 0), { name: "plain", dir: "plain", kind: "other", because: "x", box: { capabilities: [] } }] });
|
|
89
|
+
expect(d.hosts).toEqual([{ name: "local", ports: { from: 7100, to: 7999, perBox: 20 }, stateRoot: null, pointer: "/hosts/0" }]);
|
|
90
|
+
expect(d.members[0].box?.isolation).toMatchObject({ host: "local", slot: 0, cookies: ["hud_session", "chaff_door"] });
|
|
91
|
+
expect(d.members[1].box?.isolation).toBeNull();
|
|
92
|
+
expect(parse({ name: "plain", schema: 1, members: [] }).hosts).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a block with only a host, or only isolation, still reads: capabilities are optional", () => {
|
|
96
|
+
const d = parse(declaration([{ ...chaffBox("fern", 0), box: { host: "local", slot: 0 } } as BoxMember]));
|
|
97
|
+
expect(d.members[0].box).toMatchObject({ capabilities: [], isolation: { host: "local", slot: 0, ports: {}, state: {}, cookies: [] } });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the rules the schema can't say are read errors, each at its place", () => {
|
|
101
|
+
const cases: [unknown, string, RegExp][] = [
|
|
102
|
+
[declaration([chaffBox("fern", 0)], [local, local]), "name", /host name "local" is already used/],
|
|
103
|
+
[declaration([chaffBox("fern", 0, { host: "cloud" })]), "host", /names the host "cloud", which hosts does not declare; declared hosts: local/],
|
|
104
|
+
[declaration([chaffBox("fern", 45)]), "slot", /slot 45 on host local, which needs ports 8000 to 8019, past the end of the host's range \(7999\); the range holds 45 slots/],
|
|
105
|
+
[declaration([chaffBox("fern", 0, { ports: { app: 20 } })]), "app", /gives port app offset 20, and host local gives each box 20 ports \(offsets 0 to 19\)/],
|
|
106
|
+
[declaration([], [{ name: "local", ports: { from: 8000, to: 7000, perBox: 1 } }]), "ports", /starts at 8000, after its end 7000/],
|
|
107
|
+
];
|
|
108
|
+
for (const [value, key, message] of cases) {
|
|
109
|
+
const err = refusal(value);
|
|
110
|
+
expect(err.code).toBe("declaration-invalid");
|
|
111
|
+
expect(err.message).toMatch(message);
|
|
112
|
+
expect(JSON.stringify(value, null, 2).split("\n")[err.location!.line - 1]).toContain(`"${key}"`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the schema refuses an unknown field, a bad cookie name, a host without a slot and ports without a host", () => {
|
|
117
|
+
for (const bad of [
|
|
118
|
+
declaration([chaffBox("fern", 0, { stateDir: "/tmp/x" })]),
|
|
119
|
+
declaration([chaffBox("fern", 0, { cookies: ["hud session"] })]),
|
|
120
|
+
declaration([{ ...chaffBox("fern", 0), box: { host: "local" } } as BoxMember]),
|
|
121
|
+
declaration([{ ...chaffBox("fern", 0), box: { ports: { app: 0 } } } as BoxMember]),
|
|
122
|
+
declaration([], [{ name: "local", ports: { from: 0, to: 10, perBox: 1 } }]),
|
|
123
|
+
]) {
|
|
124
|
+
expect(refusal(bad).code).toBe("declaration-invalid");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("the values derived from a box's identity", () => {
|
|
130
|
+
test("ports from the slot's block, state under <stateRoot>/<name>, cookies suffixed with the name", () => {
|
|
131
|
+
const [fern, moss] = resolveBoxes(parse(declaration([chaffBox("fern", 0), chaffBox("moss", 1)]))).map((b) => b.isolation);
|
|
132
|
+
expect(fern).toEqual({
|
|
133
|
+
host: "local",
|
|
134
|
+
slot: 0,
|
|
135
|
+
portRange: { from: 7100, to: 7119 },
|
|
136
|
+
ports: { app: 7100, door: 7101, proxy: 7102, inject: 7103, ticker: 7104, behold: 7105, surface: 7106, historian: 7107 },
|
|
137
|
+
stateDir: `${DEFAULT_STATE_ROOT}/fern`,
|
|
138
|
+
state: {
|
|
139
|
+
HUD_IDENTITY_PATH: "${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json",
|
|
140
|
+
HUD_DB_PATH: "${XDG_STATE_HOME}/chant/boxes/fern/hud/events.db",
|
|
141
|
+
CHAFF_HUD_IDENTITY: "${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json",
|
|
142
|
+
},
|
|
143
|
+
cookies: { hud_session: "hud_session_fern", chaff_door: "chaff_door_fern" },
|
|
144
|
+
});
|
|
145
|
+
expect(moss.portRange).toEqual({ from: 7120, to: 7139 });
|
|
146
|
+
expect(moss.ports.door).toBe(7121);
|
|
147
|
+
expect(moss.state.HUD_IDENTITY_PATH).toBe("${XDG_STATE_HOME}/chant/boxes/moss/hud/identity.json");
|
|
148
|
+
expect(moss.cookies.hud_session).toBe("hud_session_moss");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("are the same for the same identity, whatever other boxes are declared or in what order", () => {
|
|
152
|
+
const alone = resolveBoxes(parse(declaration([chaffBox("moss", 3)])))[0].isolation;
|
|
153
|
+
const among = resolveBoxes(parse(declaration([chaffBox("zinnia", 7), chaffBox("moss", 3), chaffBox("fern", 0)])))[1].isolation;
|
|
154
|
+
expect(among).toEqual(alone);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a host's own state root is used, trailing slash or not", () => {
|
|
158
|
+
const [b] = resolveBoxes(parse(declaration([chaffBox("fern", 0)], [{ ...local, stateRoot: "${BOX_STATE}/" }])));
|
|
159
|
+
expect(b.isolation.stateDir).toBe("${BOX_STATE}/fern");
|
|
160
|
+
expect(b.isolation.state.HUD_DB_PATH).toBe("${BOX_STATE}/fern/hud/events.db");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("literal machine paths", () => {
|
|
165
|
+
test("a state root starts with an environment reference other than $HOME", () => {
|
|
166
|
+
expect(stateRootLiteral("${XDG_STATE_HOME}/chant/boxes")).toBeUndefined();
|
|
167
|
+
expect(stateRootLiteral("$XDG_STATE_HOME")).toBeUndefined();
|
|
168
|
+
expect(stateRootLiteral("${HOME}/alecraso/hud-live")).toMatch(/\$HOME/);
|
|
169
|
+
expect(stateRootLiteral("$HOME/.local/state")).toMatch(/\$HOME/);
|
|
170
|
+
expect(stateRootLiteral("/Users/alex/state")).toMatch(/environment reference/);
|
|
171
|
+
expect(stateRootLiteral("~/state")).toMatch(/environment reference/);
|
|
172
|
+
expect(stateRootLiteral("${XDG_STATE_HOME}/../x")).toMatch(/\.\./);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a state entry is relative to the box's state directory and stays in it", () => {
|
|
176
|
+
expect(statePathLiteral("hud/identity.json")).toBeUndefined();
|
|
177
|
+
expect(statePathLiteral("/Users/alex/.local/state/hud/identity.json")).toMatch(/absolute/);
|
|
178
|
+
expect(statePathLiteral("${HOME}/.local/state/hud/box-chaff/identity.json")).toMatch(/environment reference/);
|
|
179
|
+
expect(statePathLiteral("~/hud")).toMatch(/home/);
|
|
180
|
+
expect(statePathLiteral("../moss/hud/identity.json")).toMatch(/\.\./);
|
|
181
|
+
expect(statePathLiteral("C:\\state")).toMatch(/Windows/);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("chant workspace check: WSP123 and WSP124", () => {
|
|
186
|
+
test("are fixed errors in the catalog, with the names the issue gives them", () => {
|
|
187
|
+
const byId = Object.fromEntries(WORKSPACE_CHECKS.map((c) => [c.id, c]));
|
|
188
|
+
expect([byId.WSP123.name, byId.WSP123.severity, byId.WSP123.configurable]).toEqual(["box-isolation-collision", "error", false]);
|
|
189
|
+
expect([byId.WSP124.name, byId.WSP124.severity, byId.WSP124.configurable]).toEqual(["box-isolation-literal", "error", false]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("two boxes with distinct names and slots pass, and status --json shows their distinct values", async () => {
|
|
193
|
+
const root = repo(declaration([chaffBox("fern", 0), chaffBox("moss", 1)]));
|
|
194
|
+
const report = await findings(root);
|
|
195
|
+
expect(report.diagnostics).toEqual([]);
|
|
196
|
+
expect(report.ok).toBe(true);
|
|
197
|
+
|
|
198
|
+
const doc = await workspaceStatus({ cwd: root, env: "local" });
|
|
199
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
200
|
+
const validate = new Ajv2020({ strict: true, allErrors: true }).compile(statusSchema);
|
|
201
|
+
expect(validate(doc), JSON.stringify(validate.errors)).toBe(true);
|
|
202
|
+
const [fern, moss] = doc.members.map((m) => m.box!.isolation!);
|
|
203
|
+
for (const what of ["ports", "state", "cookies"] as const) {
|
|
204
|
+
const a = new Set<unknown>(Object.values(fern[what]));
|
|
205
|
+
expect(Object.values(moss[what]).some((v) => a.has(v)), what).toBe(false);
|
|
206
|
+
}
|
|
207
|
+
expect(doc.members[0].box).toMatchObject({ capabilities: [], isolation: { ports: { door: 7101 } } });
|
|
208
|
+
expect(formatStatus(doc)).toContain("fern local 0 7100-7119 ${XDG_STATE_HOME}/chant/boxes/fern");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("two boxes on one host with the same port range and state root collide", async () => {
|
|
212
|
+
const report = await findings(repo(declaration([chaffBox("fern", 2), chaffBox("moss", 2)])));
|
|
213
|
+
expect(report.ok).toBe(false);
|
|
214
|
+
const found = report.diagnostics.filter((d) => d.ruleId === "WSP123");
|
|
215
|
+
// One finding per shared port; state and cookies stay apart, since the names differ.
|
|
216
|
+
expect(found).toHaveLength(8);
|
|
217
|
+
expect(found[0]).toMatchObject({ severity: "error", entity: "moss", code: "box-isolation-collision" });
|
|
218
|
+
expect(found[0].message).toBe(
|
|
219
|
+
"box-isolation-collision: fern.app and moss.app on host local resolve to the same port 7140; give each box on a host its own slot",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("the same slot on two hosts is no collision: hosts share nothing", () => {
|
|
224
|
+
const hosts = [local, { ...local, name: "tailnet" }];
|
|
225
|
+
expect(boxCollisions(parse(declaration([chaffBox("fern", 0), chaffBox("moss", 0, { host: "tailnet" })], hosts)))).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("two ports in one box at one offset collide; two state entries naming one file do not", () => {
|
|
229
|
+
const d = parse(declaration([chaffBox("fern", 0, { ports: { app: 0, door: 0 } })]));
|
|
230
|
+
expect(boxCollisions(d).map((c) => [c.what, c.value, c.holders.map((h) => `${h.box}.${h.name}`)])).toEqual([["port", "7100", ["fern.app", "fern.door"]]]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a state entry spelled into another box's directory collides with it, and is a literal", () => {
|
|
234
|
+
const d = parse(declaration([chaffBox("fern", 0), chaffBox("moss", 1, { state: { HUD_IDENTITY_PATH: "./../fern/hud/identity.json" } })]));
|
|
235
|
+
expect(boxLiterals(d).map((l) => l.pointer)).toEqual(["/members/1/box/state/HUD_IDENTITY_PATH"]);
|
|
236
|
+
const state = boxCollisions(d).filter((c) => c.what === "state");
|
|
237
|
+
expect(state.map((c) => [c.value, c.holders.map((h) => `${h.box}.${h.name}`)])).toEqual([
|
|
238
|
+
["${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json", ["fern.HUD_IDENTITY_PATH", "moss.HUD_IDENTITY_PATH"]],
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a hard-coded machine path in a host or a box is WSP124", async () => {
|
|
243
|
+
const hosts = [{ ...local, stateRoot: "${HOME}/alecraso/hud-live" }];
|
|
244
|
+
const report = await findings(repo(declaration([chaffBox("chaff", 0, { state: { HUD_IDENTITY_PATH: "${HOME}/.local/state/hud/box-chaff/identity.json" } })], hosts)));
|
|
245
|
+
expect(report.diagnostics.map((d) => [d.ruleId, d.entity ?? null, d.code])).toEqual([
|
|
246
|
+
["WSP124", "chaff", "box-isolation-literal"],
|
|
247
|
+
["WSP124", null, "box-isolation-literal"],
|
|
248
|
+
]);
|
|
249
|
+
expect(report.diagnostics[1].message).toMatch(/^box-isolation-literal: host local's stateRoot "\$\{HOME\}\/alecraso\/hud-live" starts at \$HOME/);
|
|
250
|
+
expect(report.diagnostics[0].message).toMatch(
|
|
251
|
+
/^box-isolation-literal: member chaff's box gives state HUD_IDENTITY_PATH the path "\$\{HOME\}\/.local\/state\/hud\/box-chaff\/identity.json", which starts with an environment reference/,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a fixed box check can't be turned off", async () => {
|
|
256
|
+
const root = repo({ ...declaration([chaffBox("fern", 0), chaffBox("moss", 0)]), checks: { WSP123: "off" } } as ReturnType<typeof declaration>);
|
|
257
|
+
const ids = (await findings(root)).diagnostics.map((d) => d.ruleId);
|
|
258
|
+
expect(ids).toContain("WSP011");
|
|
259
|
+
expect(ids).toContain("WSP123");
|
|
260
|
+
});
|
|
261
|
+
});
|