@intentius/chant 0.89.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/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -5
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +55 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- 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/op-verb-class.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/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.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 +29 -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-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +12 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- 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/handlers/serve.ts +2 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +23 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-tools.test.ts +211 -0
- package/src/cli/mcp/workspace-tools.ts +449 -0
- 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/op-verb-class.ts +6 -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/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- 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 +36 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +18 -0
- package/src/workspace/records.ts +61 -3
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
- 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,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
|
|
package/src/op/discover.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { OpConfig } from "./types";
|
|
|
5
5
|
import { walkDiscovery, workspaceMemberDirs } from "../discovery/walk";
|
|
6
6
|
import { hasDiscoveryMarkerSync } from "../discovery/files";
|
|
7
7
|
import { resolveDiscoveryGlobs } from "../config";
|
|
8
|
+
import { isStewardDeclaration, type StewardDeclaration } from "./steward";
|
|
8
9
|
|
|
9
10
|
export interface DiscoveredOp {
|
|
10
11
|
config: OpConfig;
|
|
@@ -152,6 +153,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
|
|
|
152
153
|
const mod = (await import(filePath)) as Record<string, unknown>;
|
|
153
154
|
const exported = opsExportedBy(mod);
|
|
154
155
|
|
|
156
|
+
// A file may hold a steward and no Op (#2731): the steward sits beside
|
|
157
|
+
// the Ops it runs, and `discoverStewards` reads it from here.
|
|
158
|
+
if (exported.length === 0 && Object.values(mod).some(isStewardDeclaration)) continue;
|
|
155
159
|
if (exported.length === 0) {
|
|
156
160
|
errors.push(
|
|
157
161
|
`${filePath}: exports no Op. Expected \`export default Op({...})\` or a named export such as \`export const deploy = Op({...})\``,
|
|
@@ -180,3 +184,70 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
|
|
|
180
184
|
|
|
181
185
|
return { ops, errors };
|
|
182
186
|
}
|
|
187
|
+
|
|
188
|
+
export interface DiscoveredSteward {
|
|
189
|
+
declaration: StewardDeclaration;
|
|
190
|
+
filePath: string;
|
|
191
|
+
exportName: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface StewardDiscoveryResult {
|
|
195
|
+
stewards: Map<string, DiscoveredSteward>;
|
|
196
|
+
/** Files that could not be imported. */
|
|
197
|
+
errors: string[];
|
|
198
|
+
/** Stewards dropped because they would be a second writer: a name or an Op another steward already has. */
|
|
199
|
+
conflicts: string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every steward declared in the project's `*.op.ts` files (#2731), found the
|
|
204
|
+
* way {@link discoverOps} finds Ops, keyed by the steward's name.
|
|
205
|
+
*
|
|
206
|
+
* Refuses, as errors, the two ways a project could end up with two writers:
|
|
207
|
+
* two stewards of one name, and one Op listed by two stewards. The steward
|
|
208
|
+
* declared first (in file walk order) keeps its entry; the other is dropped
|
|
209
|
+
* and reported in `conflicts`.
|
|
210
|
+
*/
|
|
211
|
+
export async function discoverStewards(opts?: { cwd?: string }): Promise<StewardDiscoveryResult> {
|
|
212
|
+
const errors: string[] = [];
|
|
213
|
+
const conflicts: string[] = [];
|
|
214
|
+
const stewards = new Map<string, DiscoveredSteward>();
|
|
215
|
+
const opOwner = new Map<string, string>();
|
|
216
|
+
|
|
217
|
+
const root = await findDiscoveryRoot(opts?.cwd);
|
|
218
|
+
const files = await collectOpFiles(root);
|
|
219
|
+
|
|
220
|
+
for (const filePath of files) {
|
|
221
|
+
let mod: Record<string, unknown>;
|
|
222
|
+
try {
|
|
223
|
+
mod = (await import(filePath)) as Record<string, unknown>;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const seen = new Set<unknown>();
|
|
229
|
+
const exportNames = ["default", ...Object.keys(mod).filter((k) => k !== "default")];
|
|
230
|
+
for (const exportName of exportNames) {
|
|
231
|
+
const value = mod[exportName];
|
|
232
|
+
if (!isStewardDeclaration(value) || seen.has(value)) continue;
|
|
233
|
+
seen.add(value);
|
|
234
|
+
const prior = stewards.get(value.name);
|
|
235
|
+
if (prior) {
|
|
236
|
+
conflicts.push(`Duplicate steward "${value.name}" in ${filePath} and ${prior.filePath}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const claimed = value.ops.find((op) => opOwner.has(op.name));
|
|
240
|
+
if (claimed) {
|
|
241
|
+
conflicts.push(
|
|
242
|
+
`${filePath}: steward "${value.name}" lists op "${claimed.name}", which steward "${opOwner.get(claimed.name)}" already runs. ` +
|
|
243
|
+
`Two stewards running one Op are two writers; list it on one of them.`,
|
|
244
|
+
);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
for (const op of value.ops) opOwner.set(op.name, value.name);
|
|
248
|
+
stewards.set(value.name, { declaration: value, filePath, exportName });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { stewards, errors, conflicts };
|
|
253
|
+
}
|
package/src/op/index.ts
CHANGED
|
@@ -15,8 +15,12 @@ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-life
|
|
|
15
15
|
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
|
|
16
16
|
export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
|
|
17
17
|
export type { FreshnessResult } from "./emulator-freshness";
|
|
18
|
-
export type { OpConfig, OpSchedule, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
|
|
19
|
-
export { outcomeAttributesOf } from "./types";
|
|
18
|
+
export type { OpConfig, OpSchedule, OpWorkLease, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
|
|
19
|
+
export { outcomeAttributesOf, WORK_LEASE_STEP_ID } from "./types";
|
|
20
|
+
export {
|
|
21
|
+
RunWorkLease, workLeaseOutput, stewardWorkHolder, workLeaseProblems, workLeaseNeedsRunItem, LEASE_LOST, WORK_BRANCH_PREFIX,
|
|
22
|
+
} from "./work-lease-run";
|
|
23
|
+
export type { WorkLeaseOutput, WorkLeaseRunResult, WorkClaimOutcome, RunWorkLeaseOptions } from "./work-lease-run";
|
|
20
24
|
export { isValidCronExpression, cronSyntaxMessage, cronMatches, cronDueBetween } from "./cron";
|
|
21
25
|
export {
|
|
22
26
|
WatchOp, ReconcileOp, ApplyOp, ConvergeOp,
|
|
@@ -100,8 +104,17 @@ export type {
|
|
|
100
104
|
} from "./converge-rule";
|
|
101
105
|
export {
|
|
102
106
|
discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
|
|
103
|
-
formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS,
|
|
107
|
+
formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS, acquireStewardLease,
|
|
104
108
|
} from "./operator";
|
|
109
|
+
export {
|
|
110
|
+
declareSteward, isStewardDeclaration, stewardFormFor, stewardOpConfig, normaliseStewardForm, stewardLeaseName,
|
|
111
|
+
STEWARD_KIND, STEWARD_FORMS, STEWARD_NAME_PATTERN, DEFAULT_STEWARD_ENV,
|
|
112
|
+
} from "./steward";
|
|
113
|
+
export type {
|
|
114
|
+
StewardDeclaration, StewardDeclarationConfig, StewardForm, StewardFormSpec, StewardOpInput,
|
|
115
|
+
} from "./steward";
|
|
116
|
+
export { discoverStewards } from "./discover";
|
|
117
|
+
export type { DiscoveredSteward, StewardDiscoveryResult } from "./discover";
|
|
105
118
|
export type {
|
|
106
119
|
OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions,
|
|
107
120
|
ChangeSubscriber, OperatorSignalEvent,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { lifecycleReceiptFile, lifecycleReceiptStore } from "./lifecycle-receipt-store";
|
|
7
|
+
import { receiptActivities, type EffectReceiptRef } from "./receipt-store";
|
|
8
|
+
|
|
9
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
10
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
11
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function initRepo(dir: string): void {
|
|
15
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
16
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
17
|
+
git(["config", "user.name", "Test"], dir);
|
|
18
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
19
|
+
git(["add", "README.md"], dir);
|
|
20
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RECEIPT: EffectReceiptRef = { name: "fly-migration:shop/001_init.sql", effect: "fly-migrate", flavor: "hash", inputs: {} };
|
|
24
|
+
|
|
25
|
+
describe("lifecycleReceiptStore", () => {
|
|
26
|
+
test("a receipt is absent, then written under <env>/receipts on chant/lifecycle", async () => {
|
|
27
|
+
await withTestDir(async (dir) => {
|
|
28
|
+
initRepo(dir);
|
|
29
|
+
const store = lifecycleReceiptStore({ environment: "prod", cwd: dir });
|
|
30
|
+
expect(await store.read(RECEIPT)).toBeUndefined();
|
|
31
|
+
await store.write(RECEIPT, "sha256:abc");
|
|
32
|
+
expect(await store.read(RECEIPT)).toBe("sha256:abc");
|
|
33
|
+
const shown = git(["show", `chant/lifecycle:prod/${lifecycleReceiptFile(RECEIPT.name)}`], dir);
|
|
34
|
+
expect(shown.exitCode).toBe(0);
|
|
35
|
+
expect(shown.stdout.trim()).toBe("sha256:abc");
|
|
36
|
+
// Another environment has its own receipts.
|
|
37
|
+
expect(await lifecycleReceiptStore({ environment: "dev", cwd: dir }).read(RECEIPT)).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("bound to core's receipt activities, the effect step's read-compare-write goes through it", async () => {
|
|
42
|
+
await withTestDir(async (dir) => {
|
|
43
|
+
initRepo(dir);
|
|
44
|
+
const acts = receiptActivities(lifecycleReceiptStore({ environment: "prod", cwd: dir }));
|
|
45
|
+
const first = await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" });
|
|
46
|
+
expect(first).toEqual({ current: null, expectation: "sha256:1", applied: false });
|
|
47
|
+
await acts.receiptWrite({ receipt: RECEIPT, expectation: "sha256:1" });
|
|
48
|
+
expect((await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" })).applied).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("with no environment and no CHANT_ENV it refuses rather than guessing", async () => {
|
|
53
|
+
const store = lifecycleReceiptStore({ env: {} });
|
|
54
|
+
await expect(store.read(RECEIPT)).rejects.toThrow(/no environment/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("receipt names become safe file names", () => {
|
|
58
|
+
expect(lifecycleReceiptFile("fly-migration:app/002 add.sql")).toBe("receipts/fly-migration_app_002_add.sql.receipt");
|
|
59
|
+
});
|
|
60
|
+
});
|