@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/workspace/status.ts
CHANGED
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
* The JSON also lists each member's gates, read from its gate ledger on the
|
|
25
25
|
* same branch (#2674, `status-gates.ts`): the state of each, the approvals
|
|
26
26
|
* that count, and the `chant approve` line that answers it. The text view
|
|
27
|
-
* doesn't show them.
|
|
27
|
+
* doesn't show them. So does each member's box block (#2726): the
|
|
28
|
+
* capabilities it reaches through a broker, with the broker and the scope a
|
|
29
|
+
* broker enforces, and, when the block names a host, the box's ports, state
|
|
30
|
+
* paths and cookie names resolved from its identity (#2727,
|
|
31
|
+
* `box-isolation.ts`), so a runtime reads them instead of choosing its own.
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { execFileSync } from "node:child_process";
|
|
@@ -35,10 +39,14 @@ import type { CommandContext } from "../cli/registry";
|
|
|
35
39
|
import { GATES_DIR } from "../lifecycle/gate-ledger";
|
|
36
40
|
import { readPathSha } from "../lifecycle/git";
|
|
37
41
|
import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
|
|
42
|
+
import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
|
|
43
|
+
import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
|
|
38
44
|
import { findWorkspaceRoot } from "../project-root";
|
|
45
|
+
import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
|
|
39
46
|
import { readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type Member } from "./declaration";
|
|
40
47
|
import type { ReasonCode } from "./reason-codes";
|
|
41
48
|
import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
|
|
49
|
+
import { STEWARD_REASON_CODES, readMemberStewards, type StatusSteward, type StewardReasonCode } from "./status-stewards";
|
|
42
50
|
import { gitTop, workingTree } from "./tree";
|
|
43
51
|
import { handToRootChant } from "./which-chant";
|
|
44
52
|
|
|
@@ -71,6 +79,9 @@ export type StatusReasonCode = (typeof STATUS_REASON_CODES)[number];
|
|
|
71
79
|
/** Why a member's gates can't be listed (#2674). Closed, like {@link STATUS_REASON_CODES}. */
|
|
72
80
|
export const STATUS_GATE_REASON_CODES = GATE_REASON_CODES;
|
|
73
81
|
|
|
82
|
+
/** Why a member's stewards can't be fully listed (#2731). Closed, like {@link STATUS_REASON_CODES}. */
|
|
83
|
+
export const STATUS_STEWARD_REASON_CODES = STEWARD_REASON_CODES;
|
|
84
|
+
|
|
74
85
|
/**
|
|
75
86
|
* Why the status couldn't be read at all. The declaration's own codes, except
|
|
76
87
|
* the two that only `--at` returns, and one for the environment name.
|
|
@@ -104,6 +115,15 @@ export interface StatusRelease {
|
|
|
104
115
|
actor: string;
|
|
105
116
|
/** Read flags, such as `legacy-digest` (#2514). */
|
|
106
117
|
flags: string[];
|
|
118
|
+
/**
|
|
119
|
+
* The release plan `digest` names, read from `_plans/<digest>.json` on
|
|
120
|
+
* chant/lifecycle (ws-055, #2733) — the work items and evidence this
|
|
121
|
+
* release shipped, without the runner's own files. Null when this
|
|
122
|
+
* checkout has no plan stored under that digest: an ordinary component
|
|
123
|
+
* release (most releases carry no plan at all), or a plan this checkout
|
|
124
|
+
* hasn't fetched yet.
|
|
125
|
+
*/
|
|
126
|
+
plan: ReleasePlan | null;
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
export interface StatusLedger {
|
|
@@ -145,6 +165,29 @@ export interface StatusMember {
|
|
|
145
165
|
gateLedger: StatusGateLedger;
|
|
146
166
|
/** Each gate in the member's gate ledger, one per environment asked for, sorted by component then gate. */
|
|
147
167
|
gates: StatusGate[];
|
|
168
|
+
/** The member's box block as declared, or null when it declares none (#2726). A broker reads the scopes it enforces here. */
|
|
169
|
+
box: StatusBox | null;
|
|
170
|
+
/** The stewards the member declares (#2731), with their form in `env`, their Ops and each Op's last run. Sorted by name. */
|
|
171
|
+
stewards: StatusSteward[];
|
|
172
|
+
/** Why a steward or a last run may be missing from `stewards`. */
|
|
173
|
+
stewardReasons: { code: StewardReasonCode; message: string }[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A box's brokered capabilities, from the declaration (#2726). */
|
|
177
|
+
export interface StatusBox {
|
|
178
|
+
capabilities: { name: string; broker: string | null; scope: string[] }[];
|
|
179
|
+
/** The box's ports, state paths and cookie names, resolved from its identity, or null when the block declares no host (#2727). */
|
|
180
|
+
isolation: ResolvedIsolation | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One work lease (#2732): a work item's lease ref in the ledger of the member
|
|
185
|
+
* that owns its work kind, read from the local refs without fetching.
|
|
186
|
+
*/
|
|
187
|
+
export interface StatusLease extends Omit<WorkLeaseState, "ref"> {
|
|
188
|
+
/** The member whose ledger holds the lease, or null for the flat ledger (the root member, or a kind no member owns). */
|
|
189
|
+
member: string | null;
|
|
190
|
+
ref: string;
|
|
148
191
|
}
|
|
149
192
|
|
|
150
193
|
export type StatusDocument =
|
|
@@ -157,6 +200,8 @@ export type StatusDocument =
|
|
|
157
200
|
lifecycle: { ref: string; commit: string | null };
|
|
158
201
|
workspace: { name: string; root: string; file: string };
|
|
159
202
|
members: StatusMember[];
|
|
203
|
+
/** Every work lease in the workspace's ledgers, active and expired, by member then item (#2732). A released lease has no ref and is not listed. */
|
|
204
|
+
leases: StatusLease[];
|
|
160
205
|
summary: { members: number; released: number; unreadable: number; differing: number | null };
|
|
161
206
|
}
|
|
162
207
|
| {
|
|
@@ -179,6 +224,8 @@ export interface StatusQuery {
|
|
|
179
224
|
readGates?: GateLedgerReader;
|
|
180
225
|
/** The instant a gate's expiry is measured against; now by default. */
|
|
181
226
|
now?: string;
|
|
227
|
+
/** Reads one member's stewards; the declaration and ledger reader unless a test swaps it. */
|
|
228
|
+
readStewards?: typeof readMemberStewards;
|
|
182
229
|
}
|
|
183
230
|
|
|
184
231
|
class StatusError extends Error {
|
|
@@ -240,8 +287,16 @@ async function readEnvironment(member: Member, env: string, cwd: string, read: L
|
|
|
240
287
|
reason: { code: "ledger-unreadable", message: `${ledger.path}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` },
|
|
241
288
|
};
|
|
242
289
|
}
|
|
243
|
-
|
|
244
|
-
|
|
290
|
+
// A member's plans live beside its other lifecycle stores (#2524 D7,
|
|
291
|
+
// #2538): _members/<member>/_plans/ for the "members" layout, the flat
|
|
292
|
+
// top-level _plans/ for "flat" — the same split ledger.path already
|
|
293
|
+
// resolved above for releases.jsonl, mirrored here since readReleasePlan
|
|
294
|
+
// reads through one process for every member without changing cwd per
|
|
295
|
+
// member (see plan-ledger.ts's readReleasePlan doc).
|
|
296
|
+
const planPrefix = ledger.layout === "members" ? `${MEMBERS_DIR}/${member.name}/` : "";
|
|
297
|
+
const latest = [...latestPerComponent(records).values()].sort((a, b) => (a.component < b.component ? -1 : a.component > b.component ? 1 : 0));
|
|
298
|
+
const releases: StatusRelease[] = await Promise.all(
|
|
299
|
+
latest.map(async (r) => ({
|
|
245
300
|
component: r.component,
|
|
246
301
|
digest: r.digest,
|
|
247
302
|
gitSha: r.gitSha,
|
|
@@ -250,8 +305,9 @@ async function readEnvironment(member: Member, env: string, cwd: string, read: L
|
|
|
250
305
|
timestamp: r.timestamp,
|
|
251
306
|
actor: r.actor,
|
|
252
307
|
flags: [...(r.flags ?? [])],
|
|
253
|
-
|
|
254
|
-
|
|
308
|
+
plan: await readReleasePlan(r.digest, { cwd, prefix: planPrefix }),
|
|
309
|
+
})),
|
|
310
|
+
);
|
|
255
311
|
return {
|
|
256
312
|
env,
|
|
257
313
|
ledger: { ...ledger, shared: false },
|
|
@@ -303,12 +359,14 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
303
359
|
const commit = lifecycleTip(found.dir);
|
|
304
360
|
const now = query.now ?? new Date().toISOString();
|
|
305
361
|
|
|
362
|
+
const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
|
|
306
363
|
const members: StatusMember[] = [];
|
|
307
364
|
for (const m of declaration.members) {
|
|
308
365
|
const environments: StatusEnvironment[] = [];
|
|
309
366
|
for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read));
|
|
310
367
|
const own = await hasMemberLedger(m, found.dir);
|
|
311
368
|
const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates);
|
|
369
|
+
const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
|
|
312
370
|
members.push({
|
|
313
371
|
name: m.name,
|
|
314
372
|
dir: m.dir,
|
|
@@ -318,6 +376,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
318
376
|
readable: environments.every((e) => e.reason === null),
|
|
319
377
|
gateLedger: gates.ledger,
|
|
320
378
|
gates: gates.gates,
|
|
379
|
+
box:
|
|
380
|
+
m.box === null
|
|
381
|
+
? null
|
|
382
|
+
: {
|
|
383
|
+
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
|
|
384
|
+
isolation: isolation.get(m.name) ?? null,
|
|
385
|
+
},
|
|
386
|
+
stewards: stewards.stewards,
|
|
387
|
+
stewardReasons: stewards.reasons,
|
|
321
388
|
});
|
|
322
389
|
}
|
|
323
390
|
// Several members read from one flat ledger see the same records; say so.
|
|
@@ -327,6 +394,8 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
327
394
|
const flatGates = members.filter((m) => m.gateLedger.layout === "flat").length;
|
|
328
395
|
for (const m of members) m.gateLedger.shared = m.gateLedger.layout === "flat" && flatGates > 1;
|
|
329
396
|
|
|
397
|
+
const leases = await readWorkspaceLeases(declaration.members, found.dir, new Date(now));
|
|
398
|
+
|
|
330
399
|
return {
|
|
331
400
|
...head,
|
|
332
401
|
env: query.env,
|
|
@@ -334,6 +403,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
334
403
|
lifecycle: { ref: LIFECYCLE_REF, commit },
|
|
335
404
|
workspace: { name: declaration.name, root: rootDir === "" ? "." : rootDir, file: declaration.file },
|
|
336
405
|
members,
|
|
406
|
+
leases,
|
|
337
407
|
summary: {
|
|
338
408
|
members: members.length,
|
|
339
409
|
released: members.filter((m) => m.environments[0].releases.length > 0).length,
|
|
@@ -351,6 +421,21 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
351
421
|
}
|
|
352
422
|
}
|
|
353
423
|
|
|
424
|
+
/**
|
|
425
|
+
* The work leases of the flat ledger and of each member's (#2732). Leases are
|
|
426
|
+
* env-independent, so the environment asked for doesn't narrow them. Never
|
|
427
|
+
* fetches: the remote's leases are the ones last fetched.
|
|
428
|
+
*/
|
|
429
|
+
async function readWorkspaceLeases(members: Member[], cwd: string, now: Date): Promise<StatusLease[]> {
|
|
430
|
+
const ledgers: { member: string | null; prefix: string }[] = [{ member: null, prefix: "" }];
|
|
431
|
+
for (const m of members) if (m.dir !== ".") ledgers.push({ member: m.name, prefix: `${MEMBERS_DIR}/${m.name}/` });
|
|
432
|
+
const out: StatusLease[] = [];
|
|
433
|
+
for (const l of ledgers) {
|
|
434
|
+
for (const lease of await listWorkLeases({ cwd, memberPrefix: l.prefix, now })) out.push({ ...lease, member: l.member });
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
354
439
|
export async function runWorkspaceStatus(ctx: CommandContext): Promise<number> {
|
|
355
440
|
const { args } = ctx;
|
|
356
441
|
const env = args.extraPositional;
|
|
@@ -434,6 +519,22 @@ export function formatStatus(doc: Extract<StatusDocument, { members: unknown }>)
|
|
|
434
519
|
for (const n of notes.get(i) ?? []) lines.push(n);
|
|
435
520
|
});
|
|
436
521
|
}
|
|
522
|
+
if (doc.leases.length > 0) {
|
|
523
|
+
lines.push("");
|
|
524
|
+
const rows: string[][] = [["WORK ITEM", "HOLDER", "EXPIRES", "STATE"]];
|
|
525
|
+
for (const l of doc.leases) rows.push([l.member ? `${l.member}/${l.item}` : l.item, l.holder, l.expiresAt, l.state]);
|
|
526
|
+
lines.push(...table(rows));
|
|
527
|
+
}
|
|
528
|
+
const boxes = doc.members.filter((m) => m.box?.isolation);
|
|
529
|
+
if (boxes.length > 0) {
|
|
530
|
+
lines.push("");
|
|
531
|
+
const rows: string[][] = [["BOX", "HOST", "SLOT", "PORTS", "STATE"]];
|
|
532
|
+
for (const m of boxes) {
|
|
533
|
+
const i = m.box!.isolation!;
|
|
534
|
+
rows.push([m.name, i.host, String(i.slot), `${i.portRange.from}-${i.portRange.to}`, i.stateDir]);
|
|
535
|
+
}
|
|
536
|
+
lines.push(...table(rows));
|
|
537
|
+
}
|
|
437
538
|
const s = doc.summary;
|
|
438
539
|
lines.push("");
|
|
439
540
|
lines.push(
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2732 — `chant workspace work claim|renew|release`, run as the CLI: two
|
|
3
|
+
* processes racing for one work item, the lease in `records --json` and
|
|
4
|
+
* `workspace status --json`, and both over MCP.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { McpServer } from "../cli/mcp/server";
|
|
13
|
+
import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
|
|
14
|
+
import recordsSchema from "./records.schema.json";
|
|
15
|
+
import statusSchema from "./status.schema.json";
|
|
16
|
+
import workSchema from "./work-lease.schema.json";
|
|
17
|
+
|
|
18
|
+
const REF = join(REPO, "reference-workspace");
|
|
19
|
+
const MAIN = join(REPO, "packages/core/src/cli/main.ts");
|
|
20
|
+
const LOADER = pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href;
|
|
21
|
+
const CHANT = [process.execPath, "--import", LOADER, MAIN];
|
|
22
|
+
const WORK = "work/work.kind.mjs";
|
|
23
|
+
|
|
24
|
+
function work(id: string, fields: Record<string, unknown> = {}): string {
|
|
25
|
+
const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-25", source: { kind: "workspace", member: "app" }, supersedes: [], ...fields };
|
|
26
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Run {
|
|
30
|
+
code: number | null;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function chant(cwd: string, args: string[]): Promise<Run> {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const child = spawn(CHANT[0], [...CHANT.slice(1), ...args], { cwd, env: { ...process.env, NO_COLOR: "1", TSX_DISABLE_CACHE: "1" } });
|
|
38
|
+
let stdout = "";
|
|
39
|
+
let stderr = "";
|
|
40
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
41
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
42
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const json = (r: Run) => {
|
|
47
|
+
const doc = JSON.parse(r.stdout);
|
|
48
|
+
if ("item" in doc || "error" in doc) contract(workSchema).expectValid(doc);
|
|
49
|
+
return doc;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let root: string;
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
root = repo({
|
|
55
|
+
"chant.workspace.json": JSON.stringify(
|
|
56
|
+
{
|
|
57
|
+
name: "studio",
|
|
58
|
+
schema: 1,
|
|
59
|
+
members: [{ name: "app", dir: "app", kind: "other", because: "a plain Node server" }],
|
|
60
|
+
records: [{ kind: "decisions/decision.kind.mjs" }, { kind: WORK }],
|
|
61
|
+
},
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
66
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
|
|
67
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
68
|
+
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
69
|
+
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
70
|
+
"work/W-001-server.md": work("W-001"),
|
|
71
|
+
"work/W-002-next.md": work("W-002"),
|
|
72
|
+
"work/W-003-done.md": work("W-003", { state: "done", closed_on: "2026-09-25", evidence: [{ title: "The review", url: "https://example.com/review" }] }),
|
|
73
|
+
});
|
|
74
|
+
git(root, "add", "-A");
|
|
75
|
+
git(root, "commit", "-q", "-m", "the queue");
|
|
76
|
+
});
|
|
77
|
+
afterAll(cleanScratch);
|
|
78
|
+
|
|
79
|
+
describe("chant workspace work (#2732)", () => {
|
|
80
|
+
test("two processes claiming one work item: one wins, and the other is refused with the holder named", async () => {
|
|
81
|
+
const [a, b] = await Promise.all([
|
|
82
|
+
chant(root, ["workspace", "work", "claim", "W-001", "--holder", "worker-a", "--ttl", "600", "--json"]),
|
|
83
|
+
chant(root, ["workspace", "work", "claim", "W-001", "--holder", "worker-b", "--ttl", "600", "--json"]),
|
|
84
|
+
]);
|
|
85
|
+
const runs = [a, b];
|
|
86
|
+
const won = runs.filter((r) => r.code === 0);
|
|
87
|
+
const lost = runs.filter((r) => r.code === 2);
|
|
88
|
+
expect(won, `${a.stderr}\n${b.stderr}`).toHaveLength(1);
|
|
89
|
+
expect(lost).toHaveLength(1);
|
|
90
|
+
const winner = json(won[0]);
|
|
91
|
+
expect(winner).toMatchObject({ item: "W-001", event: "claim", kind: WORK, ref: "refs/chant/lease/work/W-001", history: { path: "_leases/W-001.jsonl" } });
|
|
92
|
+
const refusal = json(lost[0]);
|
|
93
|
+
expect(refusal.refused.heldBy.holder).toBe(winner.lease.holder);
|
|
94
|
+
expect(refusal.refused.code).toMatch(/^lease-(held|race)$/);
|
|
95
|
+
expect(refusal.refused.message).toContain(winner.lease.holder);
|
|
96
|
+
expect(git(root, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n")).toHaveLength(1);
|
|
97
|
+
}, 120_000);
|
|
98
|
+
|
|
99
|
+
test("records --json gives each work item its active lease, and status --json lists active and expired leases", async () => {
|
|
100
|
+
const claim = await chant(root, ["workspace", "work", "claim", "W-002", "--holder", "worker-c", "--ttl", "1", "--json"]);
|
|
101
|
+
expect(claim.code, claim.stderr).toBe(0);
|
|
102
|
+
await new Promise((r) => setTimeout(r, 1_100));
|
|
103
|
+
|
|
104
|
+
const records = await chant(root, ["workspace", "records", "--kind", WORK, "--json"]);
|
|
105
|
+
expect(records.code, records.stderr).toBe(0);
|
|
106
|
+
const doc = json(records);
|
|
107
|
+
contract(recordsSchema).expectValid(doc);
|
|
108
|
+
const byId = new Map((doc.records as { id: string; lease?: unknown }[]).map((r) => [r.id, r.lease]));
|
|
109
|
+
expect(byId.get("W-001")).toMatchObject({ holder: expect.stringMatching(/^worker-[ab]$/) });
|
|
110
|
+
expect(byId.get("W-002")).toBeNull(); // expired, so not active
|
|
111
|
+
expect(byId.get("W-003")).toBeNull();
|
|
112
|
+
|
|
113
|
+
const status = await chant(root, ["workspace", "status", "dev", "--json"]);
|
|
114
|
+
expect(status.code, status.stderr).toBe(0);
|
|
115
|
+
const sdoc = json(status);
|
|
116
|
+
contract(statusSchema).expectValid(sdoc);
|
|
117
|
+
expect(sdoc.leases.map((l: { item: string; state: string; member: string | null }) => [l.item, l.state, l.member])).toEqual([
|
|
118
|
+
["W-001", "active", null],
|
|
119
|
+
["W-002", "expired", null],
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// An expired lease can be claimed again, with a new token.
|
|
123
|
+
const again = await chant(root, ["workspace", "work", "claim", "W-002", "--holder", "worker-d", "--json"]);
|
|
124
|
+
expect(again.code, again.stderr).toBe(0);
|
|
125
|
+
expect(json(again).lease.token).not.toBe(json(claim).lease.token);
|
|
126
|
+
}, 120_000);
|
|
127
|
+
|
|
128
|
+
test("renew keeps the token and moves the expiry; release frees the item; the history has a line for each", async () => {
|
|
129
|
+
const claim = json(await chant(root, ["workspace", "work", "claim", "W-001", "--holder", "nobody", "--json"]));
|
|
130
|
+
const holder = claim.refused.heldBy.holder as string;
|
|
131
|
+
const token = claim.refused.heldBy.token as string;
|
|
132
|
+
|
|
133
|
+
const renew = await chant(root, ["workspace", "work", "renew", "W-001", "--holder", holder, "--token", token, "--ttl", "20m", "--json"]);
|
|
134
|
+
expect(renew.code, renew.stderr).toBe(0);
|
|
135
|
+
const renewed = json(renew).lease;
|
|
136
|
+
expect(renewed.token).toBe(token);
|
|
137
|
+
expect(Date.parse(renewed.expiresAt)).toBeGreaterThan(Date.parse(claim.refused.heldBy.expiresAt));
|
|
138
|
+
|
|
139
|
+
const wrong = await chant(root, ["workspace", "work", "release", "W-001", "--holder", "nobody", "--json"]);
|
|
140
|
+
expect(wrong.code).toBe(2);
|
|
141
|
+
expect(json(wrong).refused.code).toBe("lease-held");
|
|
142
|
+
|
|
143
|
+
const release = await chant(root, ["workspace", "work", "release", "W-001", "--holder", holder, "--outcome", "done"]);
|
|
144
|
+
expect(release.code, release.stderr).toBe(0);
|
|
145
|
+
expect(release.stdout).toContain("W-001 released by");
|
|
146
|
+
|
|
147
|
+
const events = git(root, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n").map((l) => JSON.parse(l));
|
|
148
|
+
expect(events.map((e) => e.event)).toEqual(["claim", "renew", "release"]);
|
|
149
|
+
expect(events[2]).toMatchObject({ holder, by: holder, outcome: "done", token });
|
|
150
|
+
}, 120_000);
|
|
151
|
+
|
|
152
|
+
test("an unknown item, a closed item and a bad verb are errors, not refusals", async () => {
|
|
153
|
+
const unknown = await chant(root, ["workspace", "work", "claim", "W-999", "--holder", "a", "--json"]);
|
|
154
|
+
expect(unknown.code).toBe(1);
|
|
155
|
+
expect(json(unknown).error.code).toBe("work-item-unknown");
|
|
156
|
+
const closed = await chant(root, ["workspace", "work", "claim", "W-003", "--holder", "a", "--json"]);
|
|
157
|
+
expect(closed.code).toBe(1);
|
|
158
|
+
expect(json(closed).error.code).toBe("work-item-closed");
|
|
159
|
+
const verb = await chant(root, ["workspace", "work", "grab", "W-001", "--holder", "a"]);
|
|
160
|
+
expect(verb.code).toBe(1);
|
|
161
|
+
expect(verb.stderr).toContain("claim, renew or release");
|
|
162
|
+
const noHolder = await chant(root, ["workspace", "work", "claim", "W-001"]);
|
|
163
|
+
expect(noHolder.code).toBe(1);
|
|
164
|
+
expect(noHolder.stderr).toContain("--holder");
|
|
165
|
+
}, 120_000);
|
|
166
|
+
|
|
167
|
+
test("the lease fields reach an MCP client through workspace-records and workspace-status", async () => {
|
|
168
|
+
const claim = await chant(root, ["workspace", "work", "claim", "W-001", "--holder", "mcp-worker", "--json"]);
|
|
169
|
+
expect(claim.code, claim.stderr).toBe(0);
|
|
170
|
+
const server = new McpServer([], { workspace: { cwd: root, chantCommand: CHANT } });
|
|
171
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
172
|
+
const res = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } });
|
|
173
|
+
return (res.result as { structuredContent: Record<string, unknown> }).structuredContent;
|
|
174
|
+
};
|
|
175
|
+
const records = (await call("workspace-records", { kind: WORK })) as { records: { id: string; lease: { holder: string } | null }[] };
|
|
176
|
+
expect(records.records.find((r) => r.id === "W-001")?.lease?.holder).toBe("mcp-worker");
|
|
177
|
+
const status = (await call("workspace-status", { env: "dev" })) as { leases: { item: string; holder: string; state: string }[] };
|
|
178
|
+
expect(status.leases.find((l) => l.item === "W-001")).toMatchObject({ holder: "mcp-worker", state: "active" });
|
|
179
|
+
}, 120_000);
|
|
180
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace work claim|renew|release <id> --holder <name>` (#2732,
|
|
3
|
+
* ws-055): the work lease, who is working on which work item.
|
|
4
|
+
*
|
|
5
|
+
* The lease itself is `../lifecycle/work-lease.ts`, the operator lease under
|
|
6
|
+
* the key `work/<id>`. This file finds the work item first: in the work kind
|
|
7
|
+
* `--kind` names, or in the one work kind the declaration names that has a
|
|
8
|
+
* record with the id. A claim on an id no work record has is refused, and so
|
|
9
|
+
* is a claim on a closed item (done or dropped); renew and release are not,
|
|
10
|
+
* so a lease can always be given back.
|
|
11
|
+
*
|
|
12
|
+
* The lease lives in the ledger of the member that owns the work kind file
|
|
13
|
+
* (#2524 D7), whatever directory the command runs in: `records --json`
|
|
14
|
+
* reports it from there too.
|
|
15
|
+
*
|
|
16
|
+
* Exit codes: 0 when the lease was written, 2 when it was refused (someone
|
|
17
|
+
* holds it, it expired, another clone got there first), 1 when the command
|
|
18
|
+
* could not run (usage, an unreadable kind, an unknown item).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { dirname, relative } from "node:path";
|
|
22
|
+
import { formatError } from "../cli/format";
|
|
23
|
+
import type { CommandContext } from "../cli/registry";
|
|
24
|
+
import { parseDuration } from "../op/duration";
|
|
25
|
+
import {
|
|
26
|
+
claimWorkLease,
|
|
27
|
+
releaseWorkLease,
|
|
28
|
+
renewWorkLease,
|
|
29
|
+
workLeaseRef,
|
|
30
|
+
WORK_ITEM_ID_PATTERN,
|
|
31
|
+
type WorkLease,
|
|
32
|
+
type WorkLeaseRefusal,
|
|
33
|
+
type WorkLeaseResult,
|
|
34
|
+
type LeaseHistoryWrite,
|
|
35
|
+
} from "../lifecycle/work-lease";
|
|
36
|
+
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
37
|
+
import { WorkspaceReadError } from "./declaration";
|
|
38
|
+
import { declaredKindFiles, readRecordsFor } from "./records-cli";
|
|
39
|
+
import { loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
|
|
40
|
+
import { gitRoot } from "./record-source";
|
|
41
|
+
import type { ReasonCode } from "./reason-codes";
|
|
42
|
+
|
|
43
|
+
/** The version of the `work` output this chant writes. */
|
|
44
|
+
export const WORK_LEASE_CONTRACT_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
/** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
|
|
47
|
+
export const WORK_LEASE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/work-lease/v1/work-lease.schema.json";
|
|
48
|
+
|
|
49
|
+
export { WORK_LEASE_REFUSALS } from "../lifecycle/work-lease";
|
|
50
|
+
|
|
51
|
+
const USAGE = "chant workspace work claim|renew|release <id> --holder <name> [--kind <kind file>] [--ttl <seconds|duration>] [--token <token>] [--outcome <text>] [--note <text>] [--json]";
|
|
52
|
+
|
|
53
|
+
/** Exit code when the lease was refused rather than the command failing. */
|
|
54
|
+
export const EXIT_LEASE_REFUSED = 2;
|
|
55
|
+
|
|
56
|
+
export type WorkVerb = "claim" | "renew" | "release";
|
|
57
|
+
|
|
58
|
+
/** Why the command could not run. Closed. */
|
|
59
|
+
export const WORK_ERROR_CODES = [
|
|
60
|
+
"write-usage-invalid",
|
|
61
|
+
"kind-unreadable",
|
|
62
|
+
"work-kind-missing",
|
|
63
|
+
"work-kind-ambiguous",
|
|
64
|
+
"work-item-unknown",
|
|
65
|
+
"work-item-closed",
|
|
66
|
+
"not-a-git-repository",
|
|
67
|
+
] as const satisfies readonly ReasonCode[];
|
|
68
|
+
export type WorkErrorCode = (typeof WORK_ERROR_CODES)[number];
|
|
69
|
+
|
|
70
|
+
interface DocHead {
|
|
71
|
+
$schema: string;
|
|
72
|
+
contract: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What `--json` prints. */
|
|
76
|
+
export type WorkLeaseDocument = DocHead &
|
|
77
|
+
(
|
|
78
|
+
| {
|
|
79
|
+
item: string;
|
|
80
|
+
event: WorkVerb;
|
|
81
|
+
/** The work kind file, from the repository root. */
|
|
82
|
+
kind: string;
|
|
83
|
+
ref: string;
|
|
84
|
+
lease: Omit<WorkLease, "item">;
|
|
85
|
+
history: LeaseHistoryWrite;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
item: string;
|
|
89
|
+
event: WorkVerb;
|
|
90
|
+
kind: string;
|
|
91
|
+
ref: string;
|
|
92
|
+
refused: { code: WorkLeaseRefusal; message: string; heldBy: Omit<WorkLease, "item"> | null };
|
|
93
|
+
}
|
|
94
|
+
| { error: { code: WorkErrorCode; message: string } }
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
class WorkError extends Error {
|
|
98
|
+
constructor(
|
|
99
|
+
readonly code: WorkErrorCode,
|
|
100
|
+
message: string,
|
|
101
|
+
) {
|
|
102
|
+
super(message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The work kind holding `id`: the one `kind` names, or the one declared work kind with a record `id`. */
|
|
107
|
+
async function findWorkItem(id: string, cwd: string, kind: string | undefined): Promise<{ loaded: LoadedRecordKind; state: string | null; root: string }> {
|
|
108
|
+
let candidates: string[];
|
|
109
|
+
if (kind !== undefined) {
|
|
110
|
+
candidates = [kind];
|
|
111
|
+
} else {
|
|
112
|
+
try {
|
|
113
|
+
candidates = declaredKindFiles(cwd).map((k) => k.file);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
116
|
+
throw new WorkError("work-kind-missing", `${err.code}: ${err.describe()}; without --kind, the declaration names the work kind`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const found: { loaded: LoadedRecordKind; state: string | null; root: string }[] = [];
|
|
120
|
+
let workKinds = 0;
|
|
121
|
+
for (const file of candidates) {
|
|
122
|
+
let loaded: LoadedRecordKind;
|
|
123
|
+
try {
|
|
124
|
+
loaded = await loadRecordKind(file, cwd);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err instanceof RecordReadError && kind !== undefined) throw new WorkError("kind-unreadable", `${err.code}: ${err.message}`);
|
|
127
|
+
if (err instanceof RecordReadError) continue;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
if (!loaded.kind.work) {
|
|
131
|
+
if (kind !== undefined) throw new WorkError("work-kind-missing", `${kind} is the ${loaded.kind.name} kind, which has no work block`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
workKinds++;
|
|
135
|
+
let read;
|
|
136
|
+
try {
|
|
137
|
+
read = await readRecordsFor({ kind: loaded.file, cwd });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof RecordReadError) throw new WorkError("kind-unreadable", `${err.code}: ${err.message}`);
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
const record = read.result.records.find((r) => r.id === id);
|
|
143
|
+
if (record) found.push({ loaded, state: record.state, root: read.root });
|
|
144
|
+
}
|
|
145
|
+
if (workKinds === 0) throw new WorkError("work-kind-missing", "the declaration names no work kind; pass --kind <kind file>");
|
|
146
|
+
if (found.length === 0) throw new WorkError("work-item-unknown", `no work record has the id ${id}`);
|
|
147
|
+
if (found.length > 1) throw new WorkError("work-kind-ambiguous", `${found.length} work kinds have a record ${id}; pass --kind <kind file>`);
|
|
148
|
+
return found[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse `--ttl`: a whole number of seconds, or a duration such as 10m. */
|
|
152
|
+
export function parseTtl(raw: string): number {
|
|
153
|
+
if (/^\d+$/.test(raw)) return Number(raw) * 1000;
|
|
154
|
+
return parseDuration(raw);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface WorkLeaseRequest {
|
|
158
|
+
verb: WorkVerb;
|
|
159
|
+
id: string;
|
|
160
|
+
holder: string;
|
|
161
|
+
cwd: string;
|
|
162
|
+
kind?: string;
|
|
163
|
+
ttlMs?: number;
|
|
164
|
+
token?: string;
|
|
165
|
+
outcome?: string;
|
|
166
|
+
note?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Run one claim, renew or release and build the document `--json` prints. */
|
|
170
|
+
export async function workLease(req: WorkLeaseRequest): Promise<WorkLeaseDocument> {
|
|
171
|
+
const doc = { $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION };
|
|
172
|
+
try {
|
|
173
|
+
if (!WORK_ITEM_ID_PATTERN.test(req.id) || req.id.includes("..")) throw new WorkError("write-usage-invalid", `${JSON.stringify(req.id)} is not a work item id`);
|
|
174
|
+
if (!gitRoot(req.cwd)) throw new WorkError("not-a-git-repository", "a work lease is a git ref, and this directory is not in a git repository");
|
|
175
|
+
const { loaded, state, root } = await findWorkItem(req.id, req.cwd, req.kind);
|
|
176
|
+
if (req.verb === "claim" && state !== null && (loaded.kind.closedStates ?? []).includes(state)) {
|
|
177
|
+
throw new WorkError("work-item-closed", `${req.id} is ${state}, a closed state; there is no work left to claim`);
|
|
178
|
+
}
|
|
179
|
+
const leaseCwd = dirname(loaded.file);
|
|
180
|
+
const { prefix } = await resolveMemberLedger(leaseCwd);
|
|
181
|
+
const opts = { cwd: leaseCwd, ...(req.note ? { note: req.note } : {}) };
|
|
182
|
+
let result: WorkLeaseResult;
|
|
183
|
+
if (req.verb === "claim") result = await claimWorkLease(req.id, req.holder, { ...opts, ttlMs: req.ttlMs });
|
|
184
|
+
else if (req.verb === "renew") result = await renewWorkLease(req.id, req.holder, { ...opts, ttlMs: req.ttlMs, token: req.token });
|
|
185
|
+
else result = await releaseWorkLease(req.id, req.holder, { ...opts, token: req.token, outcome: req.outcome });
|
|
186
|
+
const head = { ...doc, item: req.id, event: req.verb, kind: relative(root, loaded.file).split("\\").join("/"), ref: workLeaseRef(req.id, prefix) };
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
const heldBy = result.heldBy ? strip(result.heldBy) : null;
|
|
189
|
+
return { ...head, refused: { code: result.reason, message: result.message, heldBy } };
|
|
190
|
+
}
|
|
191
|
+
return { ...head, lease: strip(result.lease), history: result.history };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof WorkError) return { ...doc, error: { code: err.code, message: err.message } };
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function strip(l: WorkLease): Omit<WorkLease, "item"> {
|
|
199
|
+
return { holder: l.holder, token: l.token, acquiredAt: l.acquiredAt, expiresAt: l.expiresAt };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function runWorkspaceWork(ctx: CommandContext): Promise<number> {
|
|
203
|
+
const { args } = ctx;
|
|
204
|
+
const verb = args.extraPositional;
|
|
205
|
+
const fail = (message: string): number => {
|
|
206
|
+
if (args.json) console.log(JSON.stringify({ $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION, error: { code: "write-usage-invalid", message } }, null, 2));
|
|
207
|
+
else console.error(formatError({ message, hint: USAGE }));
|
|
208
|
+
return 1;
|
|
209
|
+
};
|
|
210
|
+
if (verb !== "claim" && verb !== "renew" && verb !== "release") {
|
|
211
|
+
return fail(verb ? `chant workspace work takes claim, renew or release, not ${verb}` : "chant workspace work needs claim, renew or release");
|
|
212
|
+
}
|
|
213
|
+
const id = args.extraPositional2;
|
|
214
|
+
if (!id) return fail(`${verb} needs a work item id`);
|
|
215
|
+
if (!args.holder) return fail(`${verb} needs --holder <name>: who ${verb === "release" ? "gives the lease back" : "holds the lease"}`);
|
|
216
|
+
if (verb === "release" && args.ttl !== undefined) return fail("release takes no --ttl");
|
|
217
|
+
if (verb !== "release" && args.outcome !== undefined) return fail(`${verb} takes no --outcome; it is for release`);
|
|
218
|
+
if (verb === "claim" && args.token !== undefined) return fail("claim takes no --token; a claim mints one");
|
|
219
|
+
let ttlMs: number | undefined;
|
|
220
|
+
if (args.ttl !== undefined) {
|
|
221
|
+
try {
|
|
222
|
+
ttlMs = parseTtl(args.ttl);
|
|
223
|
+
} catch {
|
|
224
|
+
return fail(`--ttl takes seconds or a duration such as 10m, not ${JSON.stringify(args.ttl)}`);
|
|
225
|
+
}
|
|
226
|
+
if (ttlMs <= 0) return fail("--ttl must be more than zero");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const doc = await workLease({ verb, id, holder: args.holder, cwd: process.cwd(), kind: args.kind, ttlMs, token: args.token, outcome: args.outcome, note: args.note });
|
|
230
|
+
if (args.json) console.log(JSON.stringify(doc, null, 2));
|
|
231
|
+
if ("error" in doc) {
|
|
232
|
+
if (!args.json) console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
if ("refused" in doc) {
|
|
236
|
+
if (!args.json) console.error(formatError({ message: `${doc.refused.code}: ${doc.refused.message}` }));
|
|
237
|
+
return EXIT_LEASE_REFUSED;
|
|
238
|
+
}
|
|
239
|
+
if (!args.json) {
|
|
240
|
+
const what = verb === "claim" ? "claimed" : verb === "renew" ? "renewed" : "released";
|
|
241
|
+
const until = verb === "release" ? "" : ` until ${doc.lease.expiresAt}`;
|
|
242
|
+
console.log(`${id} ${what} by ${args.holder}${until} (token ${doc.lease.token})`);
|
|
243
|
+
console.log(` ${doc.ref}; history ${doc.history.path}${doc.history.pushed ? "" : " (not pushed)"}`);
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|