@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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2732 — the work lease: the operator lease under `work/<id>`, with a
|
|
3
|
+
* `_leases/<id>.jsonl` history, coordinating workers in one clone and across
|
|
4
|
+
* clones through a bare remote.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
claimWorkLease,
|
|
13
|
+
listWorkLeases,
|
|
14
|
+
readLeaseHistory,
|
|
15
|
+
releaseWorkLease,
|
|
16
|
+
renewWorkLease,
|
|
17
|
+
workLeaseRef,
|
|
18
|
+
leaseHistoryPath,
|
|
19
|
+
} from "./work-lease";
|
|
20
|
+
import { readRefSha } from "./git";
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = [];
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function tmp(prefix: string): string {
|
|
28
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
29
|
+
dirs.push(d);
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function git(cwd: string, ...args: string[]): string {
|
|
34
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configure(dir: string): void {
|
|
38
|
+
git(dir, "config", "user.email", "test@chant.dev");
|
|
39
|
+
git(dir, "config", "user.name", "Test");
|
|
40
|
+
git(dir, "config", "commit.gpgsign", "false");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function initRepo(): string {
|
|
44
|
+
const dir = tmp("chant-work-lease-");
|
|
45
|
+
git(dir, "init", "-q", "-b", "main");
|
|
46
|
+
configure(dir);
|
|
47
|
+
writeFileSync(join(dir, "README.md"), "# t\n");
|
|
48
|
+
git(dir, "add", "README.md");
|
|
49
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const at = (iso: string) => () => new Date(iso);
|
|
54
|
+
|
|
55
|
+
describe("a work lease in one clone", () => {
|
|
56
|
+
test("a claim takes a free item, and a second claim is refused with the holder named, the same holder's included", async () => {
|
|
57
|
+
const cwd = initRepo();
|
|
58
|
+
const first = await claimWorkLease("W-001", "alice", { cwd, ttlMs: 60_000 });
|
|
59
|
+
expect(first.ok).toBe(true);
|
|
60
|
+
if (!first.ok) return;
|
|
61
|
+
expect(first.lease).toMatchObject({ item: "W-001", holder: "alice" });
|
|
62
|
+
expect(await readRefSha(workLeaseRef("W-001"), { cwd })).not.toBeNull();
|
|
63
|
+
|
|
64
|
+
const second = await claimWorkLease("W-001", "bob", { cwd });
|
|
65
|
+
expect(second).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "alice", token: first.lease.token } });
|
|
66
|
+
if (!second.ok) expect(second.message).toContain("alice");
|
|
67
|
+
|
|
68
|
+
const again = await claimWorkLease("W-001", "alice", { cwd });
|
|
69
|
+
expect(again).toMatchObject({ ok: false, reason: "lease-held" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a renew moves the expiry and keeps the token; an expired lease can be claimed, with a new token", async () => {
|
|
73
|
+
const cwd = initRepo();
|
|
74
|
+
const claim = await claimWorkLease("W-002", "alice", { cwd, ttlMs: 60_000, now: at("2026-09-25T10:00:00.000Z") });
|
|
75
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
76
|
+
expect(claim.lease.expiresAt).toBe("2026-09-25T10:01:00.000Z");
|
|
77
|
+
|
|
78
|
+
const renew = await renewWorkLease("W-002", "alice", { cwd, ttlMs: 60_000, token: claim.lease.token, now: at("2026-09-25T10:00:30.000Z") });
|
|
79
|
+
if (!renew.ok) throw new Error(renew.message);
|
|
80
|
+
expect(renew.lease.token).toBe(claim.lease.token);
|
|
81
|
+
expect(renew.lease.acquiredAt).toBe(claim.lease.acquiredAt);
|
|
82
|
+
expect(renew.lease.expiresAt).toBe("2026-09-25T10:01:30.000Z");
|
|
83
|
+
|
|
84
|
+
// Another holder can't renew it, and a stale token is refused.
|
|
85
|
+
expect(await renewWorkLease("W-002", "bob", { cwd, now: at("2026-09-25T10:00:40.000Z") })).toMatchObject({ ok: false, reason: "lease-held" });
|
|
86
|
+
expect(await renewWorkLease("W-002", "alice", { cwd, token: "stale", now: at("2026-09-25T10:00:40.000Z") })).toMatchObject({ ok: false, reason: "lease-token-mismatch" });
|
|
87
|
+
|
|
88
|
+
// Past its expiry: renew is refused, and a claim by anyone mints a new token.
|
|
89
|
+
const late = at("2026-09-25T10:02:00.000Z");
|
|
90
|
+
expect(await renewWorkLease("W-002", "alice", { cwd, now: late })).toMatchObject({ ok: false, reason: "lease-not-held" });
|
|
91
|
+
const reclaim = await claimWorkLease("W-002", "bob", { cwd, ttlMs: 60_000, now: late });
|
|
92
|
+
if (!reclaim.ok) throw new Error(reclaim.message);
|
|
93
|
+
expect(reclaim.lease.holder).toBe("bob");
|
|
94
|
+
expect(reclaim.lease.token).not.toBe(claim.lease.token);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("release: the holder gives a live lease back; someone else can't, but anyone closes out an expired one", async () => {
|
|
98
|
+
const cwd = initRepo();
|
|
99
|
+
const t0 = "2026-09-25T10:00:00.000Z";
|
|
100
|
+
const claim = await claimWorkLease("W-003", "alice", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
101
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
102
|
+
expect(await releaseWorkLease("W-003", "bob", { cwd, now: at(t0) })).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "alice" } });
|
|
103
|
+
const released = await releaseWorkLease("W-003", "alice", { cwd, outcome: "done", now: at(t0) });
|
|
104
|
+
expect(released.ok).toBe(true);
|
|
105
|
+
expect(await readRefSha(workLeaseRef("W-003"), { cwd })).toBeNull();
|
|
106
|
+
expect(await releaseWorkLease("W-003", "alice", { cwd })).toMatchObject({ ok: false, reason: "lease-not-held" });
|
|
107
|
+
|
|
108
|
+
const again = await claimWorkLease("W-003", "carol", { cwd, ttlMs: 1_000, now: at(t0) });
|
|
109
|
+
if (!again.ok) throw new Error(again.message);
|
|
110
|
+
const closed = await releaseWorkLease("W-003", "dispatcher", { cwd, outcome: "expired", now: at("2026-09-25T11:00:00.000Z") });
|
|
111
|
+
expect(closed).toMatchObject({ ok: true, lease: { holder: "carol" } });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the history has one line per claim, renew and release, on chant/lifecycle", async () => {
|
|
115
|
+
const cwd = initRepo();
|
|
116
|
+
const claim = await claimWorkLease("W-004", "alice", { cwd, note: "starting" });
|
|
117
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
118
|
+
expect(claim.history.path).toBe(leaseHistoryPath("W-004"));
|
|
119
|
+
expect(claim.history.pushed).toBe(false);
|
|
120
|
+
await renewWorkLease("W-004", "alice", { cwd });
|
|
121
|
+
await renewWorkLease("W-004", "alice", { cwd });
|
|
122
|
+
await releaseWorkLease("W-004", "alice", { cwd, outcome: "done" });
|
|
123
|
+
|
|
124
|
+
const { records, malformed } = await readLeaseHistory("W-004", { cwd });
|
|
125
|
+
expect(malformed).toBe(0);
|
|
126
|
+
expect(records.map((r) => r.event)).toEqual(["claim", "renew", "renew", "release"]);
|
|
127
|
+
expect(new Set(records.map((r) => r.token)).size).toBe(1);
|
|
128
|
+
expect(records[0]).toMatchObject({ version: 1, item: "W-004", holder: "alice", by: "alice", note: "starting" });
|
|
129
|
+
expect(records[3]).toMatchObject({ outcome: "done" });
|
|
130
|
+
expect(git(cwd, "show", `chant/lifecycle:_leases/W-004.jsonl`).split("\n")).toHaveLength(4);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("listWorkLeases reports active and expired leases and leaves released ones out", async () => {
|
|
134
|
+
const cwd = initRepo();
|
|
135
|
+
const t0 = "2026-09-25T10:00:00.000Z";
|
|
136
|
+
await claimWorkLease("W-010", "alice", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
137
|
+
await claimWorkLease("W-011", "bob", { cwd, ttlMs: 1_000, now: at(t0) });
|
|
138
|
+
await claimWorkLease("W-012", "carol", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
139
|
+
await releaseWorkLease("W-012", "carol", { cwd, now: at(t0) });
|
|
140
|
+
const leases = await listWorkLeases({ cwd, memberPrefix: "", now: new Date("2026-09-25T10:00:30.000Z") });
|
|
141
|
+
expect(leases.map((l) => [l.item, l.holder, l.state])).toEqual([
|
|
142
|
+
["W-010", "alice", "active"],
|
|
143
|
+
["W-011", "bob", "expired"],
|
|
144
|
+
]);
|
|
145
|
+
expect(leases[0].ref).toBe("refs/chant/lease/work/W-010");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("an id that can't be one ref segment is refused", async () => {
|
|
149
|
+
const cwd = initRepo();
|
|
150
|
+
await expect(claimWorkLease("../x", "alice", { cwd })).rejects.toThrow(/can't key a work lease/);
|
|
151
|
+
await expect(claimWorkLease("a/b", "alice", { cwd })).rejects.toThrow(/can't key a work lease/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a workspace member's leases and history sit under _members/<member>/ (D7)", async () => {
|
|
155
|
+
const cwd = initRepo();
|
|
156
|
+
writeFileSync(join(cwd, "chant.workspace.json"), JSON.stringify({ name: "acme", schema: 1, members: [{ name: "api", dir: "api", kind: "other", because: "t" }] }));
|
|
157
|
+
execFileSync("mkdir", ["-p", join(cwd, "api")]);
|
|
158
|
+
const member = join(cwd, "api");
|
|
159
|
+
const claim = await claimWorkLease("W-020", "alice", { cwd: member });
|
|
160
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
161
|
+
expect(await readRefSha("refs/chant/lease/_members/api/work/W-020", { cwd })).not.toBeNull();
|
|
162
|
+
expect(await readRefSha("refs/chant/lease/work/W-020", { cwd })).toBeNull();
|
|
163
|
+
expect(claim.history.path).toBe("_members/api/_leases/W-020.jsonl");
|
|
164
|
+
expect(git(cwd, "show", "chant/lifecycle:_members/api/_leases/W-020.jsonl")).toContain('"event":"claim"');
|
|
165
|
+
expect((await listWorkLeases({ cwd, memberPrefix: "_members/api/" })).map((l) => l.item)).toEqual(["W-020"]);
|
|
166
|
+
expect(await listWorkLeases({ cwd, memberPrefix: "" })).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("a work lease across clones, through a bare remote", () => {
|
|
171
|
+
function setup(): { remote: string; a: string; b: string } {
|
|
172
|
+
const root = tmp("chant-work-lease-remote-");
|
|
173
|
+
const remote = join(root, "remote.git");
|
|
174
|
+
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
175
|
+
const a = join(root, "a");
|
|
176
|
+
git(root, "clone", "-q", remote, a);
|
|
177
|
+
configure(a);
|
|
178
|
+
writeFileSync(join(a, "README.md"), "# t\n");
|
|
179
|
+
git(a, "add", "README.md");
|
|
180
|
+
git(a, "commit", "-q", "-m", "init");
|
|
181
|
+
git(a, "push", "-q", "origin", "main");
|
|
182
|
+
const b = join(root, "b");
|
|
183
|
+
git(root, "clone", "-q", remote, b);
|
|
184
|
+
configure(b);
|
|
185
|
+
return { remote, a, b };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
test("A claims; B is refused with A named; A renews and releases on the remote; then B claims and A is refused", async () => {
|
|
189
|
+
const { remote, a, b } = setup();
|
|
190
|
+
const claim = await claimWorkLease("W-001", "worker-a", { cwd: a, ttlMs: 60_000 });
|
|
191
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
192
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-001"))).toBe(await readRefSha(workLeaseRef("W-001"), { cwd: a }));
|
|
193
|
+
|
|
194
|
+
const refused = await claimWorkLease("W-001", "worker-b", { cwd: b });
|
|
195
|
+
expect(refused).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "worker-a", token: claim.lease.token } });
|
|
196
|
+
|
|
197
|
+
// A renew reaches the remote: the push expects the value A last pushed, not "absent".
|
|
198
|
+
const renew = await renewWorkLease("W-001", "worker-a", { cwd: a, ttlMs: 120_000 });
|
|
199
|
+
if (!renew.ok) throw new Error(renew.message);
|
|
200
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-001"))).toBe(await readRefSha(workLeaseRef("W-001"), { cwd: a }));
|
|
201
|
+
const seen = await claimWorkLease("W-001", "worker-b", { cwd: b });
|
|
202
|
+
expect(seen).toMatchObject({ ok: false, heldBy: { expiresAt: renew.lease.expiresAt } });
|
|
203
|
+
// B's read-only listing now shows the remote's lease as last fetched.
|
|
204
|
+
expect((await listWorkLeases({ cwd: b, memberPrefix: "" })).map((l) => l.holder)).toEqual(["worker-a"]);
|
|
205
|
+
|
|
206
|
+
const released = await releaseWorkLease("W-001", "worker-a", { cwd: a, outcome: "done" });
|
|
207
|
+
expect(released.ok).toBe(true);
|
|
208
|
+
expect(() => git(remote, "rev-parse", "--verify", workLeaseRef("W-001"))).toThrow();
|
|
209
|
+
|
|
210
|
+
const taken = await claimWorkLease("W-001", "worker-b", { cwd: b, ttlMs: 60_000 });
|
|
211
|
+
if (!taken.ok) throw new Error(taken.message);
|
|
212
|
+
expect(taken.lease.token).not.toBe(claim.lease.token);
|
|
213
|
+
expect(await claimWorkLease("W-001", "worker-a", { cwd: a })).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "worker-b" } });
|
|
214
|
+
|
|
215
|
+
// Both clones' histories reached the remote's chant/lifecycle.
|
|
216
|
+
expect(claim.history.pushed).toBe(true);
|
|
217
|
+
const lines = git(remote, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n").map((l) => JSON.parse(l).event);
|
|
218
|
+
expect(lines).toEqual(["claim", "renew", "release", "claim"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a claim the remote refuses is undone locally: two clones can't both hold one item", async () => {
|
|
222
|
+
const { remote, a, b } = setup();
|
|
223
|
+
// B fetches from an empty remote, so it can't see A's claim, but pushes to the real one.
|
|
224
|
+
const blind = join(tmp("chant-work-lease-blind-"), "blind.git");
|
|
225
|
+
git(a, "init", "-q", "--bare", "-b", "main", blind);
|
|
226
|
+
git(b, "remote", "set-url", "origin", blind);
|
|
227
|
+
git(b, "remote", "set-url", "--push", "origin", remote);
|
|
228
|
+
|
|
229
|
+
const claim = await claimWorkLease("W-005", "worker-a", { cwd: a });
|
|
230
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
231
|
+
const lost = await claimWorkLease("W-005", "worker-b", { cwd: b });
|
|
232
|
+
expect(lost).toMatchObject({ ok: false, reason: "lease-push-rejected" });
|
|
233
|
+
expect(await readRefSha(workLeaseRef("W-005"), { cwd: b })).toBeNull();
|
|
234
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-005"))).toBe(await readRefSha(workLeaseRef("W-005"), { cwd: a }));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work lease (#2732, ws-055 option a): who is working on which work item,
|
|
3
|
+
* as a lease beside the operator lease.
|
|
4
|
+
*
|
|
5
|
+
* A work item's lease is the operator lease (./lease.ts) under the key
|
|
6
|
+
* `work/<id>`, so its ref is `refs/chant/lease/work/<id>`, or
|
|
7
|
+
* `refs/chant/lease/_members/<member>/work/<id>` in a workspace member's
|
|
8
|
+
* ledger (#2524 D7). It keeps every property the operator lease has: a claim
|
|
9
|
+
* is one compare-and-set of that ref, the record carries a fencing token that
|
|
10
|
+
* changes only when the lease changes hands, it expires after its time to
|
|
11
|
+
* live, and it is pushed to and fetched from the project's remote. Unlike the
|
|
12
|
+
* operator's, a work lease counts only once the remote has taken it
|
|
13
|
+
* (`requirePush`), so two workers in separate clones cannot both hold one
|
|
14
|
+
* item.
|
|
15
|
+
*
|
|
16
|
+
* The ref holds only the live lease. Every claim, renew and release also
|
|
17
|
+
* appends a line to `_leases/<id>.jsonl` on `chant/lifecycle`, the same
|
|
18
|
+
* append-only shape as `_gates/<op>.jsonl` (./gate-ledger.ts), so the history
|
|
19
|
+
* of an item's leases is a ledger like the others.
|
|
20
|
+
*
|
|
21
|
+
* The lease is coordination, not a record of work: it never touches the
|
|
22
|
+
* working branch, so it stays out of a release's nothing-to-ship test and out
|
|
23
|
+
* of the write-scope checks (ws-055, and chud's own leases.mjs before it).
|
|
24
|
+
*
|
|
25
|
+
* Which ledger an item's lease lives in is decided by the caller through
|
|
26
|
+
* `cwd`: `chant workspace work` passes the directory of the work kind file,
|
|
27
|
+
* so the member owning the kind owns its items' leases wherever the command
|
|
28
|
+
* runs from.
|
|
29
|
+
*/
|
|
30
|
+
import { sortedJsonReplacer } from "../utils";
|
|
31
|
+
import { getRuntime } from "../runtime-adapter";
|
|
32
|
+
import {
|
|
33
|
+
acquireLease,
|
|
34
|
+
leaseRef,
|
|
35
|
+
LEASE_REF_PREFIX,
|
|
36
|
+
LEASE_REMOTE_TRACKING_PREFIX,
|
|
37
|
+
readLease,
|
|
38
|
+
releaseLease,
|
|
39
|
+
type LeaseRecord,
|
|
40
|
+
} from "./lease";
|
|
41
|
+
import {
|
|
42
|
+
fetchLifecycleStatus,
|
|
43
|
+
pushLifecycle,
|
|
44
|
+
readBlobBySha,
|
|
45
|
+
readBlobFromPath,
|
|
46
|
+
readPathSha,
|
|
47
|
+
RefCASConflictError,
|
|
48
|
+
writeBlobToPath,
|
|
49
|
+
} from "./git";
|
|
50
|
+
import { dirname } from "node:path";
|
|
51
|
+
import type { ReasonCode } from "../workspace/reason-codes";
|
|
52
|
+
import { resolveMemberLedger } from "./member-ledger";
|
|
53
|
+
|
|
54
|
+
/** The key prefix of a work lease under the operator lease's ref namespace. */
|
|
55
|
+
export const WORK_LEASE_KEY_PREFIX = "work/";
|
|
56
|
+
|
|
57
|
+
/** The directory on `chant/lifecycle` that holds lease histories, under a member's prefix when it has one. */
|
|
58
|
+
export const LEASES_DIR = "_leases";
|
|
59
|
+
|
|
60
|
+
/** How long a claim lasts unless renewed: chud's default, which its dispatcher renews at a third of. */
|
|
61
|
+
export const DEFAULT_WORK_LEASE_TTL_MS = 10 * 60_000;
|
|
62
|
+
|
|
63
|
+
/** A work item id a lease can be keyed by: one ref path segment and one file name. */
|
|
64
|
+
export const WORK_ITEM_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
65
|
+
|
|
66
|
+
/** How long a claim waits out another process's lock on the ref before calling it stale. */
|
|
67
|
+
const LOCK_WAIT_MS = 2_000;
|
|
68
|
+
const APPEND_RETRY_ATTEMPTS = 5;
|
|
69
|
+
|
|
70
|
+
/** The lease key of work item `id`. */
|
|
71
|
+
export function workLeaseKey(id: string): string {
|
|
72
|
+
return `${WORK_LEASE_KEY_PREFIX}${id}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The lease ref of work item `id` in the ledger with `memberPrefix`. */
|
|
76
|
+
export function workLeaseRef(id: string, memberPrefix = ""): string {
|
|
77
|
+
return leaseRef(workLeaseKey(id), memberPrefix);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The path of work item `id`'s lease history on `chant/lifecycle`. */
|
|
81
|
+
export function leaseHistoryPath(id: string, memberPrefix = ""): string {
|
|
82
|
+
return `${memberPrefix}${LEASES_DIR}/${id}.jsonl`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One work item's lease, as the read contract reports it. */
|
|
86
|
+
export interface WorkLease {
|
|
87
|
+
/** The work item's id. */
|
|
88
|
+
item: string;
|
|
89
|
+
holder: string;
|
|
90
|
+
/** The fencing token: new on every claim, kept by every renew. */
|
|
91
|
+
token: string;
|
|
92
|
+
acquiredAt: string;
|
|
93
|
+
expiresAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A lease with whether it is still live at the time it was read. */
|
|
97
|
+
export interface WorkLeaseState extends WorkLease {
|
|
98
|
+
state: "active" | "expired";
|
|
99
|
+
/** The lease ref. */
|
|
100
|
+
ref: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One line of `_leases/<id>.jsonl`. */
|
|
104
|
+
export interface LeaseHistoryRecord {
|
|
105
|
+
version: 1;
|
|
106
|
+
event: "claim" | "renew" | "release";
|
|
107
|
+
item: string;
|
|
108
|
+
/** The lease's holder. */
|
|
109
|
+
holder: string;
|
|
110
|
+
/** Who wrote this line: the holder, or, for the release of an expired lease, whoever closed it out. */
|
|
111
|
+
by: string;
|
|
112
|
+
token: string;
|
|
113
|
+
acquiredAt: string;
|
|
114
|
+
/** The lease's expiry after this event; a release keeps the expiry it had. */
|
|
115
|
+
expiresAt: string;
|
|
116
|
+
/** When the event happened. */
|
|
117
|
+
timestamp: string;
|
|
118
|
+
/** How the work ended, on a release. Free text such as done, not_done or skipped. */
|
|
119
|
+
outcome?: string;
|
|
120
|
+
note?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Why a claim, renew or release was refused. Closed: a new code is a contract change. */
|
|
124
|
+
export const WORK_LEASE_REFUSALS = [
|
|
125
|
+
/** Someone holds the lease live: another worker, or, for a claim, the same one. */
|
|
126
|
+
"lease-held",
|
|
127
|
+
/** Nobody holds it live: it expired, was released or was never claimed. */
|
|
128
|
+
"lease-not-held",
|
|
129
|
+
/** The live lease has another token than the one given. */
|
|
130
|
+
"lease-token-mismatch",
|
|
131
|
+
/** Another writer changed the lease between the read and the write. */
|
|
132
|
+
"lease-race",
|
|
133
|
+
/** The remote refused the push: another clone got there first, or it could not be reached. */
|
|
134
|
+
"lease-push-rejected",
|
|
135
|
+
] as const satisfies readonly ReasonCode[];
|
|
136
|
+
export type WorkLeaseRefusal = (typeof WORK_LEASE_REFUSALS)[number];
|
|
137
|
+
|
|
138
|
+
/** Where the history line went. */
|
|
139
|
+
export interface LeaseHistoryWrite {
|
|
140
|
+
path: string;
|
|
141
|
+
commit: string;
|
|
142
|
+
/** Whether `chant/lifecycle` reached the remote; false with no remote too. */
|
|
143
|
+
pushed: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type WorkLeaseResult =
|
|
147
|
+
| { ok: true; lease: WorkLease; history: LeaseHistoryWrite }
|
|
148
|
+
| { ok: false; reason: WorkLeaseRefusal; message: string; heldBy?: WorkLease };
|
|
149
|
+
|
|
150
|
+
export interface WorkLeaseOptions {
|
|
151
|
+
/** A directory in the repository, inside the member whose ledger holds the lease. */
|
|
152
|
+
cwd: string;
|
|
153
|
+
now?: () => Date;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toWorkLease(item: string, r: LeaseRecord): WorkLease {
|
|
157
|
+
return { item, holder: r.holder, token: r.token, acquiredAt: r.acquiredAt, expiresAt: r.expiresAt };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function checkId(id: string): void {
|
|
161
|
+
if (!WORK_ITEM_ID_PATTERN.test(id) || id.includes("..")) {
|
|
162
|
+
throw new Error(`${JSON.stringify(id)} can't key a work lease: use letters, digits, ".", "_" and "-", starting with a letter or digit`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const REASONS: Record<string, WorkLeaseRefusal> = {
|
|
167
|
+
held: "lease-held",
|
|
168
|
+
"not-held": "lease-not-held",
|
|
169
|
+
"token-mismatch": "lease-token-mismatch",
|
|
170
|
+
race: "lease-race",
|
|
171
|
+
"push-rejected": "lease-push-rejected",
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function refusalMessage(id: string, reason: WorkLeaseRefusal, heldBy: WorkLease | undefined): string {
|
|
175
|
+
const by = heldBy ? `${heldBy.holder} until ${heldBy.expiresAt}` : undefined;
|
|
176
|
+
switch (reason) {
|
|
177
|
+
case "lease-held":
|
|
178
|
+
return `${id} is held by ${by}`;
|
|
179
|
+
case "lease-not-held":
|
|
180
|
+
return `${id} is not held${heldBy ? ` by the caller: ${by}` : ""}; claim it again`;
|
|
181
|
+
case "lease-token-mismatch":
|
|
182
|
+
return `${id} is held with another token (${heldBy?.token}) by ${by}`;
|
|
183
|
+
case "lease-race":
|
|
184
|
+
return `${id} changed while it was being written${by ? `: it is held by ${by}` : ""}`;
|
|
185
|
+
case "lease-push-rejected":
|
|
186
|
+
return `the remote refused the lease on ${id}${by ? `: it is held there by ${by}` : ", or could not be reached"}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Claim work item `id` for `holder` for `ttlMs`. Refused with `lease-held`,
|
|
192
|
+
* naming the holder, when anyone holds it live, `holder` included; an expired
|
|
193
|
+
* lease does not refuse a claim, and the new claim gets a new token.
|
|
194
|
+
*/
|
|
195
|
+
export async function claimWorkLease(
|
|
196
|
+
id: string,
|
|
197
|
+
holder: string,
|
|
198
|
+
opts: WorkLeaseOptions & { ttlMs?: number; note?: string },
|
|
199
|
+
): Promise<WorkLeaseResult> {
|
|
200
|
+
checkId(id);
|
|
201
|
+
const result = await acquireLease(workLeaseKey(id), holder, {
|
|
202
|
+
cwd: opts.cwd,
|
|
203
|
+
ttlMs: opts.ttlMs ?? DEFAULT_WORK_LEASE_TTL_MS,
|
|
204
|
+
now: opts.now,
|
|
205
|
+
mode: "claim",
|
|
206
|
+
requirePush: true,
|
|
207
|
+
lockWaitMs: LOCK_WAIT_MS,
|
|
208
|
+
});
|
|
209
|
+
return finish(id, "claim", holder, result, opts);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Move the expiry of `holder`'s live lease on `id` to `ttlMs` from now,
|
|
214
|
+
* keeping its token. With `token`, the live lease must carry it. Refused with
|
|
215
|
+
* `lease-not-held` once the lease has expired or was released: the holder
|
|
216
|
+
* claims it again, and gets a new token.
|
|
217
|
+
*/
|
|
218
|
+
export async function renewWorkLease(
|
|
219
|
+
id: string,
|
|
220
|
+
holder: string,
|
|
221
|
+
opts: WorkLeaseOptions & { ttlMs?: number; token?: string; note?: string },
|
|
222
|
+
): Promise<WorkLeaseResult> {
|
|
223
|
+
checkId(id);
|
|
224
|
+
const result = await acquireLease(workLeaseKey(id), holder, {
|
|
225
|
+
cwd: opts.cwd,
|
|
226
|
+
ttlMs: opts.ttlMs ?? DEFAULT_WORK_LEASE_TTL_MS,
|
|
227
|
+
now: opts.now,
|
|
228
|
+
mode: "renew",
|
|
229
|
+
token: opts.token,
|
|
230
|
+
requirePush: true,
|
|
231
|
+
lockWaitMs: LOCK_WAIT_MS,
|
|
232
|
+
});
|
|
233
|
+
return finish(id, "renew", holder, result, opts);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function finish(
|
|
237
|
+
id: string,
|
|
238
|
+
event: "claim" | "renew",
|
|
239
|
+
holder: string,
|
|
240
|
+
result: Awaited<ReturnType<typeof acquireLease>>,
|
|
241
|
+
opts: WorkLeaseOptions & { note?: string },
|
|
242
|
+
): Promise<WorkLeaseResult> {
|
|
243
|
+
if (!result.acquired || !result.lease) {
|
|
244
|
+
const reason = REASONS[result.reason ?? "race"];
|
|
245
|
+
const heldBy = result.heldBy ? toWorkLease(id, result.heldBy) : undefined;
|
|
246
|
+
return { ok: false, reason, message: refusalMessage(id, reason, heldBy), ...(heldBy ? { heldBy } : {}) };
|
|
247
|
+
}
|
|
248
|
+
const lease = toWorkLease(id, result.lease);
|
|
249
|
+
const now = opts.now?.() ?? new Date();
|
|
250
|
+
const history = await appendLeaseHistory(
|
|
251
|
+
{ version: 1, event, item: id, holder, by: holder, token: lease.token, acquiredAt: lease.acquiredAt, expiresAt: lease.expiresAt, timestamp: now.toISOString(), ...(opts.note ? { note: opts.note } : {}) },
|
|
252
|
+
opts,
|
|
253
|
+
);
|
|
254
|
+
return { ok: true, lease, history };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Give the lease on `id` back. The holder releases a live lease (with
|
|
259
|
+
* `token`, only that one); anyone may release an expired one, which is how an
|
|
260
|
+
* abandoned claim is closed out. `outcome` says how the work ended. Refused
|
|
261
|
+
* with `lease-not-held` when there is no lease, and `lease-held` when someone
|
|
262
|
+
* else holds it live.
|
|
263
|
+
*/
|
|
264
|
+
export async function releaseWorkLease(
|
|
265
|
+
id: string,
|
|
266
|
+
by: string,
|
|
267
|
+
opts: WorkLeaseOptions & { token?: string; outcome?: string; note?: string },
|
|
268
|
+
): Promise<WorkLeaseResult> {
|
|
269
|
+
checkId(id);
|
|
270
|
+
const key = workLeaseKey(id);
|
|
271
|
+
const now = opts.now?.() ?? new Date();
|
|
272
|
+
const { record } = await readLease(key, { cwd: opts.cwd });
|
|
273
|
+
if (!record) return { ok: false, reason: "lease-not-held", message: refusalMessage(id, "lease-not-held", undefined) };
|
|
274
|
+
const current = toWorkLease(id, record);
|
|
275
|
+
const live = new Date(record.expiresAt).getTime() > now.getTime();
|
|
276
|
+
if (live && record.holder !== by) return { ok: false, reason: "lease-held", message: refusalMessage(id, "lease-held", current), heldBy: current };
|
|
277
|
+
if (opts.token !== undefined && record.token !== opts.token) {
|
|
278
|
+
return { ok: false, reason: "lease-token-mismatch", message: refusalMessage(id, "lease-token-mismatch", current), heldBy: current };
|
|
279
|
+
}
|
|
280
|
+
if (!(await releaseLease(key, record.holder, record.token, { cwd: opts.cwd }))) {
|
|
281
|
+
const after = (await readLease(key, { cwd: opts.cwd })).record;
|
|
282
|
+
const heldBy = after ? toWorkLease(id, after) : undefined;
|
|
283
|
+
return { ok: false, reason: "lease-race", message: refusalMessage(id, "lease-race", heldBy), ...(heldBy ? { heldBy } : {}) };
|
|
284
|
+
}
|
|
285
|
+
const history = await appendLeaseHistory(
|
|
286
|
+
{
|
|
287
|
+
version: 1,
|
|
288
|
+
event: "release",
|
|
289
|
+
item: id,
|
|
290
|
+
holder: record.holder,
|
|
291
|
+
by,
|
|
292
|
+
token: record.token,
|
|
293
|
+
acquiredAt: record.acquiredAt,
|
|
294
|
+
expiresAt: record.expiresAt,
|
|
295
|
+
timestamp: now.toISOString(),
|
|
296
|
+
...(opts.outcome ? { outcome: opts.outcome } : {}),
|
|
297
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
298
|
+
},
|
|
299
|
+
opts,
|
|
300
|
+
);
|
|
301
|
+
return { ok: true, lease: current, history };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Append one line to `_leases/<id>.jsonl`, with the same read-then-CAS retry
|
|
306
|
+
* the gate ledger's appends use, then push `chant/lifecycle`. The branch is
|
|
307
|
+
* fetched first when it fast-forwards, and the push is best-effort: the lease
|
|
308
|
+
* ref is what coordinates, and a history that did not reach the remote is
|
|
309
|
+
* reported as `pushed: false` rather than failing a lease that was taken.
|
|
310
|
+
*/
|
|
311
|
+
async function appendLeaseHistory(record: LeaseHistoryRecord, opts: { cwd: string }): Promise<LeaseHistoryWrite> {
|
|
312
|
+
await fetchLifecycleStatus(opts).catch(() => undefined);
|
|
313
|
+
const file = `${record.item}.jsonl`;
|
|
314
|
+
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
315
|
+
let commit: string | undefined;
|
|
316
|
+
let lastErr: unknown;
|
|
317
|
+
for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS && commit === undefined; attempt++) {
|
|
318
|
+
try {
|
|
319
|
+
const priorSha = await readPathSha(LEASES_DIR, file, opts);
|
|
320
|
+
const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
|
|
321
|
+
const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
|
|
322
|
+
commit = await writeBlobToPath(LEASES_DIR, file, content, `Work lease ${record.event}: ${record.item} (${record.holder})`, {
|
|
323
|
+
...opts,
|
|
324
|
+
expectPriorPathSha: priorSha,
|
|
325
|
+
});
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
328
|
+
lastErr = err;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (commit === undefined) throw lastErr;
|
|
332
|
+
const pushed = await pushLifecycle(opts).catch(() => false);
|
|
333
|
+
const { prefix } = await resolveMemberLedger(opts.cwd);
|
|
334
|
+
return { path: leaseHistoryPath(record.item, prefix), commit, pushed };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Parse the text of a lease history file, oldest first. Malformed lines are counted and skipped. */
|
|
338
|
+
export function parseLeaseHistory(content: string): { records: LeaseHistoryRecord[]; malformed: number } {
|
|
339
|
+
const records: LeaseHistoryRecord[] = [];
|
|
340
|
+
let malformed = 0;
|
|
341
|
+
for (const line of content.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
342
|
+
try {
|
|
343
|
+
const r = JSON.parse(line) as Partial<LeaseHistoryRecord>;
|
|
344
|
+
const ok =
|
|
345
|
+
r.version === 1 &&
|
|
346
|
+
(r.event === "claim" || r.event === "renew" || r.event === "release") &&
|
|
347
|
+
[r.item, r.holder, r.by, r.token, r.acquiredAt, r.expiresAt, r.timestamp].every((v) => typeof v === "string");
|
|
348
|
+
if (ok) records.push(r as LeaseHistoryRecord);
|
|
349
|
+
else malformed++;
|
|
350
|
+
} catch {
|
|
351
|
+
malformed++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { records, malformed };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Read work item `id`'s lease history in the ledger of the project at `opts.cwd`. Empty when it has none. */
|
|
358
|
+
export async function readLeaseHistory(id: string, opts: { cwd: string }): Promise<{ records: LeaseHistoryRecord[]; malformed: number }> {
|
|
359
|
+
checkId(id);
|
|
360
|
+
const content = await readBlobFromPath(LEASES_DIR, `${id}.jsonl`, opts);
|
|
361
|
+
return content ? parseLeaseHistory(content) : { records: [], malformed: 0 };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseRecord(raw: string): LeaseRecord | undefined {
|
|
365
|
+
try {
|
|
366
|
+
const v = JSON.parse(raw) as Partial<LeaseRecord>;
|
|
367
|
+
return typeof v.holder === "string" && typeof v.token === "string" && typeof v.acquiredAt === "string" && typeof v.expiresAt === "string"
|
|
368
|
+
? (v as LeaseRecord)
|
|
369
|
+
: undefined;
|
|
370
|
+
} catch {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Every work lease in the ledger with `memberPrefix`, read from the local
|
|
377
|
+
* refs only: the lease refs this clone wrote, and the remote's as last
|
|
378
|
+
* fetched (`refs/chant/lease-remote/`), the fresher of the two per item, as
|
|
379
|
+
* `readLease` picks. It never fetches, so a read-only report stays off the
|
|
380
|
+
* network; a claim or renew fetches before it decides. Released leases have
|
|
381
|
+
* no ref and are not listed; expired ones are, with `state: "expired"`.
|
|
382
|
+
* Sorted by item.
|
|
383
|
+
*/
|
|
384
|
+
export async function listWorkLeases(opts: { cwd: string; memberPrefix: string; now?: Date }): Promise<WorkLeaseState[]> {
|
|
385
|
+
const rt = getRuntime();
|
|
386
|
+
const now = (opts.now ?? new Date()).getTime();
|
|
387
|
+
const local = `${LEASE_REF_PREFIX}${opts.memberPrefix}${WORK_LEASE_KEY_PREFIX}`;
|
|
388
|
+
const remote = `${LEASE_REMOTE_TRACKING_PREFIX}${opts.memberPrefix}${WORK_LEASE_KEY_PREFIX}`;
|
|
389
|
+
const out = await rt.spawn(["git", "for-each-ref", "--format=%(refname) %(objectname)", local, remote], { cwd: opts.cwd });
|
|
390
|
+
if (out.exitCode !== 0) return [];
|
|
391
|
+
const best = new Map<string, LeaseRecord>();
|
|
392
|
+
for (const line of out.stdout.split("\n").filter(Boolean)) {
|
|
393
|
+
const [ref, sha] = line.split(" ");
|
|
394
|
+
const id = ref.startsWith(local) ? ref.slice(local.length) : ref.slice(remote.length);
|
|
395
|
+
if (!WORK_ITEM_ID_PATTERN.test(id)) continue;
|
|
396
|
+
const record = parseRecord((await readBlobBySha(sha, opts)) ?? "");
|
|
397
|
+
if (!record) continue;
|
|
398
|
+
const prior = best.get(id);
|
|
399
|
+
const key = (r: LeaseRecord) => `${r.acquiredAt} ${r.expiresAt}`;
|
|
400
|
+
if (!prior || key(record) > key(prior) || (key(record) === key(prior) && ref.startsWith(local))) best.set(id, record);
|
|
401
|
+
}
|
|
402
|
+
return [...best.entries()]
|
|
403
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
404
|
+
.map(([id, r]) => ({
|
|
405
|
+
...toWorkLease(id, r),
|
|
406
|
+
state: new Date(r.expiresAt).getTime() > now ? ("active" as const) : ("expired" as const),
|
|
407
|
+
ref: workLeaseRef(id, opts.memberPrefix),
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The active lease of each work item of the kind at `kindFile`, by item id,
|
|
413
|
+
* for `chant workspace records --json`. Read from the local refs of the
|
|
414
|
+
* ledger owning the kind file's directory, without fetching. Empty when
|
|
415
|
+
* nothing can be read.
|
|
416
|
+
*/
|
|
417
|
+
export async function activeWorkLeases(kindFile: string, now = new Date()): Promise<Map<string, Omit<WorkLease, "item">>> {
|
|
418
|
+
const cwd = dirname(kindFile);
|
|
419
|
+
try {
|
|
420
|
+
const { prefix } = await resolveMemberLedger(cwd);
|
|
421
|
+
const leases = await listWorkLeases({ cwd, memberPrefix: prefix, now });
|
|
422
|
+
return new Map(leases.filter((l) => l.state === "active").map((l) => [l.item, { holder: l.holder, token: l.token, acquiredAt: l.acquiredAt, expiresAt: l.expiresAt }]));
|
|
423
|
+
} catch {
|
|
424
|
+
return new Map();
|
|
425
|
+
}
|
|
426
|
+
}
|
package/src/op/builders.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OpResource } from "./resource";
|
|
2
|
+
import { workLeaseProblems } from "./work-lease-decl";
|
|
2
3
|
import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
|
|
3
4
|
import { isEffectReceipt, type EffectReceiptDeclaration } from "../effect-receipt";
|
|
4
5
|
import { receiptCheckInput } from "./receipt-store";
|
|
@@ -51,6 +52,10 @@ export function Op(config: OpConfig): InstanceType<typeof OpResource> {
|
|
|
51
52
|
if (config.schedule && !isValidCronExpression(config.schedule.cron)) {
|
|
52
53
|
throw new Error(`Op "${config.name}": ${cronSyntaxMessage(config.schedule.cron)}`);
|
|
53
54
|
}
|
|
55
|
+
// #2748: a work lease declared badly, or a change to the checkout with none,
|
|
56
|
+
// fails on `chant build` rather than on the first run.
|
|
57
|
+
const leaseProblems = workLeaseProblems(config);
|
|
58
|
+
if (leaseProblems.length > 0) throw new Error(leaseProblems.join("\n"));
|
|
54
59
|
return new OpResource(config as unknown as Record<string, unknown>);
|
|
55
60
|
}
|
|
56
61
|
|