@tiphys/kernel 0.0.0 → 0.1.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/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
package/dist/src/lock.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
/**
|
|
6
|
+
* Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
|
|
7
|
+
* constraint C-2). One orchestrator per fleet: the lock file at
|
|
8
|
+
* state/orchestrator.lock holds a JSON lease {holderId, hostname,
|
|
9
|
+
* acquiredAt, expiresAt, durationSeconds, token} where holderId is an
|
|
10
|
+
* opaque value generated at acquire. Liveness is lease freshness only:
|
|
11
|
+
* this module never probes any running program in any way (FM-053), and
|
|
12
|
+
* the death of a holder is deliberately invisible to the lease (an
|
|
13
|
+
* unexpired lease excludes even when its holder is gone; recovery is the
|
|
14
|
+
* explicit --take-over after expiry).
|
|
15
|
+
*
|
|
16
|
+
* Mutation contract (EXT-F-01, adopted verbatim from the plan):
|
|
17
|
+
* - Every mutation (acquire, renew, release, takeover) goes through the
|
|
18
|
+
* ONE shared atomic mutation primitive, applyLeaseMutation. A mutation
|
|
19
|
+
* is decided against an observed lease state and applied only if the
|
|
20
|
+
* file still holds exactly that state; the application is confirmed by
|
|
21
|
+
* re-reading the unique per-mutation token it wrote. A confirmation
|
|
22
|
+
* showing another writer's token means the mutation lost and returns
|
|
23
|
+
* failure without retry. Ownership is not valid until the primitive
|
|
24
|
+
* completes.
|
|
25
|
+
* - Renew fails on an expired lease and succeeds only while holderId
|
|
26
|
+
* matches and expiresAt is in the future.
|
|
27
|
+
* - Takeover succeeds only if the observed lease is still the lease being
|
|
28
|
+
* replaced (compare-and-swap over the lease file content) and
|
|
29
|
+
* serializes with renew, release, and competing takeovers.
|
|
30
|
+
*
|
|
31
|
+
* Serialization mechanism: mutations are serialized through a claim file
|
|
32
|
+
* beside the lock (<lock>.mutex, created O_EXCL; FM-022's
|
|
33
|
+
* serialize-through-a-claim pattern), and inside that claim the primitive
|
|
34
|
+
* re-reads the lock, byte-compares it with the observed state, applies
|
|
35
|
+
* via O_EXCL create (absent lock, PR-006) or write-temp-then-rename, and
|
|
36
|
+
* confirms by re-reading its own token (FM-022 write-then-verify). No
|
|
37
|
+
* steal protocol exists on purpose (FM-058): a claim file left behind by
|
|
38
|
+
* a crashed mutation makes later mutations fail loudly after a bounded
|
|
39
|
+
* wait, naming the file for manual removal; the critical section is a
|
|
40
|
+
* few file operations, so this window is tiny.
|
|
41
|
+
*
|
|
42
|
+
* What the token confirmation is and is NOT (corrected per D-2; the
|
|
43
|
+
* previous wording here claimed a second safety net that does not
|
|
44
|
+
* exist). The confirmation read asserts only "my bytes are in the file
|
|
45
|
+
* now". That is last-writer-wins: it catches an intruder who applied
|
|
46
|
+
* AFTER this mutation, and it does NOT catch one who applied before and
|
|
47
|
+
* merely lost the race to write last. The O_EXCL claim file is
|
|
48
|
+
* therefore the sole serializer, and it is advisory: no handle is held
|
|
49
|
+
* on it and nothing checks ownership when it is unlinked. The apply is
|
|
50
|
+
* preceded by a second read-and-compare (stillMatches) so that a lost
|
|
51
|
+
* claim degrades to a clean loss instead of a double win, but that
|
|
52
|
+
* narrows the window rather than closing it. Deleting a live claim file
|
|
53
|
+
* can still produce two holders; that is why the CLI's remedy text now
|
|
54
|
+
* says so instead of inviting it.
|
|
55
|
+
*
|
|
56
|
+
* Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
|
|
57
|
+
* within one filesystem and one clock, the fleet home the lock file lives
|
|
58
|
+
* in. Cross-environment exclusion for a fleet shared through a git remote
|
|
59
|
+
* is M4 residue and is not claimed here. Mutations of the lock file made
|
|
60
|
+
* outside this module (manual edits) are not covered by the contract.
|
|
61
|
+
*
|
|
62
|
+
* Renewal discipline (PR-203): the default lease lasts 900 seconds and
|
|
63
|
+
* the holder renews at or before half-life (renewByMs). Holdership on
|
|
64
|
+
* mutating kernel commands (spawn, teardown) is verified against the
|
|
65
|
+
* lease by M1-P4.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_LEASE_DURATION_SECONDS = 900;
|
|
68
|
+
/** Bounded wait for the mutation claim file, then fail loudly. */
|
|
69
|
+
const MUTEX_WAIT_TOTAL_MS = 5000;
|
|
70
|
+
const MUTEX_WAIT_POLL_MS = 10;
|
|
71
|
+
function parseLease(raw) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const candidate = parsed;
|
|
80
|
+
if (typeof candidate.holderId !== "string" ||
|
|
81
|
+
candidate.holderId === "" ||
|
|
82
|
+
typeof candidate.hostname !== "string" ||
|
|
83
|
+
typeof candidate.acquiredAt !== "string" ||
|
|
84
|
+
typeof candidate.expiresAt !== "string" ||
|
|
85
|
+
typeof candidate.durationSeconds !== "number" ||
|
|
86
|
+
typeof candidate.token !== "string" ||
|
|
87
|
+
Number.isNaN(Date.parse(candidate.expiresAt))) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
export function renderLease(lease) {
|
|
93
|
+
return `${JSON.stringify(lease, null, 2)}\n`;
|
|
94
|
+
}
|
|
95
|
+
/** Read the current lock file state: absent, or present with raw bytes. */
|
|
96
|
+
export function observeLease(lockPath) {
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = readFileSync(lockPath, "utf8");
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === "ENOENT") {
|
|
103
|
+
return { kind: "absent" };
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return { kind: "present", raw, lease: parseLease(raw) };
|
|
108
|
+
}
|
|
109
|
+
export function isExpired(lease, nowMs) {
|
|
110
|
+
return Date.parse(lease.expiresAt) <= nowMs;
|
|
111
|
+
}
|
|
112
|
+
/** The renew-by deadline (half-life of the current term), for holders. */
|
|
113
|
+
export function renewByMs(lease) {
|
|
114
|
+
return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
|
|
115
|
+
}
|
|
116
|
+
function readCurrent(lockPath) {
|
|
117
|
+
try {
|
|
118
|
+
return { present: true, raw: readFileSync(lockPath, "utf8") };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code === "ENOENT") {
|
|
122
|
+
return { present: false, raw: "" };
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Re-read the lock file and re-compare it against the state a mutation
|
|
129
|
+
* was decided on. Used immediately before every apply (D-2).
|
|
130
|
+
*/
|
|
131
|
+
function stillMatches(lockPath, observed) {
|
|
132
|
+
const current = readCurrent(lockPath);
|
|
133
|
+
if (observed.kind === "absent") {
|
|
134
|
+
return !current.present;
|
|
135
|
+
}
|
|
136
|
+
return current.present && current.raw === observed.raw;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The single staging path a lease rename goes through (CR-202). One
|
|
140
|
+
* fixed name is safe because staging only ever happens inside the
|
|
141
|
+
* mutation claim, and it makes strand cleanup deterministic.
|
|
142
|
+
*/
|
|
143
|
+
export function stagePathFor(lockPath) {
|
|
144
|
+
return `${lockPath}.stage`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The one shared atomic mutation primitive (EXT-F-01). Applies next (new
|
|
148
|
+
* file content, or null to remove the lock) only if the lock file still
|
|
149
|
+
* holds exactly the observed state, and confirms the application by
|
|
150
|
+
* re-reading the mutation's own token. Loses without retry otherwise.
|
|
151
|
+
*/
|
|
152
|
+
export async function applyLeaseMutation(lockPath, observed, next, token) {
|
|
153
|
+
const mutexPath = `${lockPath}.mutex`;
|
|
154
|
+
const deadline = Date.now() + MUTEX_WAIT_TOTAL_MS;
|
|
155
|
+
for (;;) {
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(mutexPath, token, { flag: "wx" });
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "EEXIST") {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
if (Date.now() >= deadline) {
|
|
165
|
+
// CR-204: name the lease situation first, so an operator is not
|
|
166
|
+
// nudged toward a takeover when the obstacle is a claim file and
|
|
167
|
+
// there is no lease at all.
|
|
168
|
+
// The remedy sentence is deliberately NOT part of this reason:
|
|
169
|
+
// the CLI appends it from the claimTimeout flag, so the
|
|
170
|
+
// classification is load-bearing at every layer rather than
|
|
171
|
+
// carried along as prose (CR-204).
|
|
172
|
+
// U-6: an expired lease is not a held one. Saying "lock held"
|
|
173
|
+
// here contradicted lock status in the same fleet, which calls
|
|
174
|
+
// the same lease expired, and reads as "the holder is alive"
|
|
175
|
+
// when the truth is the opposite.
|
|
176
|
+
const holder = observed.kind === "present" && observed.lease !== undefined
|
|
177
|
+
? isExpired(observed.lease, Date.now())
|
|
178
|
+
? `expired lease from ${observed.lease.holderId}`
|
|
179
|
+
: `lock held by ${observed.lease.holderId}`
|
|
180
|
+
: "no lease, no live holder";
|
|
181
|
+
return {
|
|
182
|
+
won: false,
|
|
183
|
+
claimTimeout: true,
|
|
184
|
+
reason: `${holder}; stale claim file ${mutexPath} blocking after ` +
|
|
185
|
+
`${String(MUTEX_WAIT_TOTAL_MS)}ms`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
await sleep(MUTEX_WAIT_POLL_MS);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
// CR-202: clear any stranded stage left by a mutation that died
|
|
193
|
+
// between its stage write and its rename. This runs inside the
|
|
194
|
+
// claim, so it provably cannot race a live mutation (a live one
|
|
195
|
+
// would hold the claim), which is why no age heuristic is needed
|
|
196
|
+
// or wanted. It is unconditional because the release path (unlink)
|
|
197
|
+
// and the absent-lock acquire path (O_EXCL create) never touch the
|
|
198
|
+
// stage: cleaning only in the rename branch would let a strand
|
|
199
|
+
// survive a release/acquire-only sequence indefinitely.
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(stagePathFor(lockPath));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// No strand present, which is the normal case.
|
|
205
|
+
}
|
|
206
|
+
const current = readCurrent(lockPath);
|
|
207
|
+
if (observed.kind === "absent") {
|
|
208
|
+
if (current.present) {
|
|
209
|
+
return {
|
|
210
|
+
won: false,
|
|
211
|
+
reason: "lost: a lease appeared after this mutation observed none",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else if (!current.present) {
|
|
216
|
+
return {
|
|
217
|
+
won: false,
|
|
218
|
+
reason: "lost: the observed lease is gone",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
else if (current.raw !== observed.raw) {
|
|
222
|
+
return {
|
|
223
|
+
won: false,
|
|
224
|
+
reason: "lost: the lease changed after this mutation observed it",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// D-2: re-read and re-compare immediately before every apply. The
|
|
228
|
+
// claim file is the ONLY serializer, and it is advisory: no handle
|
|
229
|
+
// is held on it, and the operator remedy this CLI prints tells a
|
|
230
|
+
// human to delete it. If a claim is lost that way, another mutation
|
|
231
|
+
// can enter this section concurrently; without this second compare
|
|
232
|
+
// both could apply and both could believe they won. With it, the
|
|
233
|
+
// loser sees changed bytes and degrades to a clean loss. This
|
|
234
|
+
// narrows the window to the syscall gap; it does not remove it, and
|
|
235
|
+
// the module docs say so rather than claiming a guarantee.
|
|
236
|
+
if (!stillMatches(lockPath, observed)) {
|
|
237
|
+
return {
|
|
238
|
+
won: false,
|
|
239
|
+
reason: "lost: the lease changed while this mutation held the claim",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (next === null) {
|
|
243
|
+
unlinkSync(lockPath);
|
|
244
|
+
const confirm = readCurrent(lockPath);
|
|
245
|
+
if (confirm.present) {
|
|
246
|
+
return { won: false, reason: "lost: removal did not stick" };
|
|
247
|
+
}
|
|
248
|
+
return { won: true };
|
|
249
|
+
}
|
|
250
|
+
if (observed.kind === "absent") {
|
|
251
|
+
// D-1: publish the initial lease atomically. writeFileSync with
|
|
252
|
+
// flag "wx" is openSync(O_EXCL) followed by a SEPARATE writeSync,
|
|
253
|
+
// so the lock file's NAME becomes visible at length zero before
|
|
254
|
+
// the lease bytes land. observeLease, leaseStatus and doctor all
|
|
255
|
+
// read outside the claim, so a reader landing in that window sees
|
|
256
|
+
// an empty file and reports a healthy fleet as corrupt; it turned
|
|
257
|
+
// acceptance criterion 3's own witness red on pristine code.
|
|
258
|
+
//
|
|
259
|
+
// linkSync gives both properties at once: the stage file already
|
|
260
|
+
// holds the complete lease, and link fails with EEXIST if the
|
|
261
|
+
// lock path exists, which is exactly the atomic exclusive-create
|
|
262
|
+
// test PR-006 asks for. So exclusion is preserved and the name
|
|
263
|
+
// never exists half-published. renew and takeover were already
|
|
264
|
+
// immune because they stage then rename; this makes the absent
|
|
265
|
+
// lock path use the same discipline, which is the asymmetry the
|
|
266
|
+
// module previously left unjustified.
|
|
267
|
+
const stagePath = stagePathFor(lockPath);
|
|
268
|
+
writeFileSync(stagePath, next);
|
|
269
|
+
try {
|
|
270
|
+
linkSync(stagePath, lockPath);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code === "EEXIST") {
|
|
274
|
+
return {
|
|
275
|
+
won: false,
|
|
276
|
+
reason: "lost: a lease appeared after this mutation observed none",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
try {
|
|
283
|
+
unlinkSync(stagePath);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// The stage sweep at the top of the claim also covers this.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// The stage is ONE fixed path beside the lock (CR-202). Every
|
|
292
|
+
// stage write happens inside the claim, so no two stages can ever
|
|
293
|
+
// coexist and a fixed name needs no uniqueness to be safe; the
|
|
294
|
+
// claim-held sweep above then makes cleanup of a crash strand
|
|
295
|
+
// deterministic (exactly one possible strand path, removed
|
|
296
|
+
// unconditionally) instead of an age-based guess over a family of
|
|
297
|
+
// unique names. The CAS is untouched by this choice: the
|
|
298
|
+
// byte-compare against the observed state and the token
|
|
299
|
+
// confirmation read both still happen inside the claim, and the
|
|
300
|
+
// rename remains atomic within one directory.
|
|
301
|
+
const stagePath = stagePathFor(lockPath);
|
|
302
|
+
writeFileSync(stagePath, next);
|
|
303
|
+
try {
|
|
304
|
+
renameSync(stagePath, lockPath);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
try {
|
|
308
|
+
unlinkSync(stagePath);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Stage cleanup is best effort; the original error surfaces.
|
|
312
|
+
}
|
|
313
|
+
if (error.code === "ENOENT") {
|
|
314
|
+
// Another mutation swept this stage, which means it entered
|
|
315
|
+
// the critical section concurrently, which means this claim
|
|
316
|
+
// was lost (D-2). Report it as a loss rather than letting a
|
|
317
|
+
// raw ENOENT stack out of the CLI.
|
|
318
|
+
return {
|
|
319
|
+
won: false,
|
|
320
|
+
reason: "lost: the staged lease disappeared before it was published, " +
|
|
321
|
+
"which means another mutation held the claim concurrently",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const confirm = readCurrent(lockPath);
|
|
328
|
+
const confirmedLease = confirm.present ? parseLease(confirm.raw) : undefined;
|
|
329
|
+
if (confirmedLease === undefined || confirmedLease.token !== token) {
|
|
330
|
+
return {
|
|
331
|
+
won: false,
|
|
332
|
+
reason: "lost: confirmation read shows another writer's token",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return { won: true };
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
try {
|
|
339
|
+
unlinkSync(mutexPath);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Claim file already gone; nothing to release.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function buildLease(nowMs, durationSeconds) {
|
|
347
|
+
return {
|
|
348
|
+
holderId: randomUUID(),
|
|
349
|
+
hostname: hostname(),
|
|
350
|
+
acquiredAt: new Date(nowMs).toISOString(),
|
|
351
|
+
expiresAt: new Date(nowMs + durationSeconds * 1000).toISOString(),
|
|
352
|
+
durationSeconds,
|
|
353
|
+
token: randomUUID(),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Acquire the lease, or take over an expired one when takeover is set.
|
|
358
|
+
* Refusals (lock held, expired without takeover, unexpired takeover)
|
|
359
|
+
* never mutate the file; only won mutations do.
|
|
360
|
+
*/
|
|
361
|
+
export async function acquireLease(lockPath, options = {}) {
|
|
362
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
363
|
+
const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
|
|
364
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
365
|
+
if (observed.kind === "present") {
|
|
366
|
+
if (observed.lease === undefined) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (!isExpired(observed.lease, nowMs)) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
reason: options.takeover === true
|
|
376
|
+
? `takeover refused: lock held by ${observed.lease.holderId}, unexpired until ${observed.lease.expiresAt}`
|
|
377
|
+
: `lock held by ${observed.lease.holderId}, expires ${observed.lease.expiresAt}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (options.takeover !== true) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
reason: `lease expired (holder ${observed.lease.holderId}, expired ` +
|
|
384
|
+
`${observed.lease.expiresAt}); acquire refused, takeover is ` +
|
|
385
|
+
`explicit: lock acquire --take-over`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const lease = buildLease(nowMs, durationSeconds);
|
|
390
|
+
const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
|
|
391
|
+
if (!result.won) {
|
|
392
|
+
// CR-204: a claim-file timeout is not a held lease. Its reason
|
|
393
|
+
// already states the lease situation, so it is never re-prefixed
|
|
394
|
+
// with "lock held", and the classification travels to the caller.
|
|
395
|
+
if (result.claimTimeout === true) {
|
|
396
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
397
|
+
}
|
|
398
|
+
return { ok: false, reason: `lock held (${result.reason})` };
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, lease };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Renew the lease held by holderId. Fails on an expired lease even when
|
|
404
|
+
* holderId matches (EXT-F-01: a paused holder whose lease expired cannot
|
|
405
|
+
* renew), fails on a holder mismatch, and never mutates the file on any
|
|
406
|
+
* failure. The new expiry strictly increases (a renew that does not
|
|
407
|
+
* extend the lease would be useless).
|
|
408
|
+
*/
|
|
409
|
+
export async function renewLease(lockPath, holderId, options = {}) {
|
|
410
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
411
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
412
|
+
if (observed.kind === "absent") {
|
|
413
|
+
return { ok: false, reason: "renew refused: no lease present" };
|
|
414
|
+
}
|
|
415
|
+
if (observed.lease === undefined) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (observed.lease.holderId !== holderId) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
reason: `renew refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (isExpired(observed.lease, nowMs)) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
reason: `renew refused: lease expired ${observed.lease.expiresAt}; an ` +
|
|
431
|
+
`expired lease cannot be renewed, re-acquire or take over instead`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const durationSeconds = options.durationSeconds ?? observed.lease.durationSeconds;
|
|
435
|
+
const newExpiresMs = Math.max(nowMs + durationSeconds * 1000, Date.parse(observed.lease.expiresAt) + 1);
|
|
436
|
+
const lease = {
|
|
437
|
+
...observed.lease,
|
|
438
|
+
expiresAt: new Date(newExpiresMs).toISOString(),
|
|
439
|
+
durationSeconds,
|
|
440
|
+
token: randomUUID(),
|
|
441
|
+
};
|
|
442
|
+
const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
|
|
443
|
+
if (!result.won) {
|
|
444
|
+
if (result.claimTimeout === true) {
|
|
445
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
446
|
+
}
|
|
447
|
+
return { ok: false, reason: `renew ${result.reason}` };
|
|
448
|
+
}
|
|
449
|
+
return { ok: true, lease };
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Release the lease held by holderId. Expiry does not block a release
|
|
453
|
+
* (an expired former holder may clean up its own lease), but a holder
|
|
454
|
+
* mismatch refuses, so a losing holder can never remove the winner's
|
|
455
|
+
* lease; and the compare-and-swap in the primitive means a release
|
|
456
|
+
* staged before a takeover completes loses to it.
|
|
457
|
+
*/
|
|
458
|
+
export async function releaseLease(lockPath, holderId, options = {}) {
|
|
459
|
+
const observed = options.observed ?? observeLease(lockPath);
|
|
460
|
+
if (observed.kind === "absent") {
|
|
461
|
+
return { ok: false, reason: "release refused: no lease present" };
|
|
462
|
+
}
|
|
463
|
+
if (observed.lease === undefined) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
reason: `lease file ${lockPath} is corrupt; inspect it manually`,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (observed.lease.holderId !== holderId) {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
reason: `release refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const result = await applyLeaseMutation(lockPath, observed, null, randomUUID());
|
|
476
|
+
if (!result.won) {
|
|
477
|
+
if (result.claimTimeout === true) {
|
|
478
|
+
return { ok: false, reason: result.reason, claimTimeout: true };
|
|
479
|
+
}
|
|
480
|
+
return { ok: false, reason: `release ${result.reason}` };
|
|
481
|
+
}
|
|
482
|
+
return { ok: true, lease: null };
|
|
483
|
+
}
|
|
484
|
+
/** Report the lock state; reading only, never mutating (always safe). */
|
|
485
|
+
export function leaseStatus(lockPath, nowMs = Date.now()) {
|
|
486
|
+
const observed = observeLease(lockPath);
|
|
487
|
+
if (observed.kind === "absent") {
|
|
488
|
+
return { state: "free" };
|
|
489
|
+
}
|
|
490
|
+
if (observed.lease === undefined) {
|
|
491
|
+
return {
|
|
492
|
+
state: "corrupt",
|
|
493
|
+
detail: `lease file ${lockPath} does not parse as a lease`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
state: isExpired(observed.lease, nowMs) ? "expired" : "held",
|
|
498
|
+
lease: observed.lease,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READING THE SHIPPED ASSURANCE MODE DEFINITIONS (kernel plan M3, M3-P3
|
|
3
|
+
* step 5).
|
|
4
|
+
*
|
|
5
|
+
* `assurance-modes.yaml` ships at the package root, beside `gate-registry.yaml`
|
|
6
|
+
* and the `schemas/` directory. This module locates it, decodes it, and
|
|
7
|
+
* answers one question: what does a declared mode require. Nothing here
|
|
8
|
+
* RESOLVES a mode into behaviour and nothing here enforces one. M3 never
|
|
9
|
+
* executes `direct-pr` or `local-only`, and building an enforcement engine for
|
|
10
|
+
* a mode this milestone never enters is the M1-P3 failure the plan is trying
|
|
11
|
+
* not to repeat.
|
|
12
|
+
*
|
|
13
|
+
* NO VALIDATION HAPPENS HERE, deliberately. `tiphys validate --type
|
|
14
|
+
* assurance-modes` is the command that says whether the document is
|
|
15
|
+
* well-formed, and duplicating its rules in a reader would produce a second
|
|
16
|
+
* opinion to keep in sync. This module reads what is there and reports what it
|
|
17
|
+
* cannot find.
|
|
18
|
+
*/
|
|
19
|
+
/** The shipped document's basename, at the package root. */
|
|
20
|
+
export declare const MODES_FILENAME = "assurance-modes.yaml";
|
|
21
|
+
/**
|
|
22
|
+
* Locate the package root by walking UP from this module and testing for the
|
|
23
|
+
* shipped document.
|
|
24
|
+
*
|
|
25
|
+
* The depth differs between the two layouts this code runs in: from source it
|
|
26
|
+
* is `src/` and the root is one level up, and from the built entry it is
|
|
27
|
+
* `dist/src/` and the root is two levels up. Counting `..` would be right in
|
|
28
|
+
* exactly one of them, which is the layout-dependent break
|
|
29
|
+
* `schemasDirectory()` already documents. Walking up and TESTING is right in
|
|
30
|
+
* both, and in a relocated copy as well.
|
|
31
|
+
*/
|
|
32
|
+
export declare function packageRoot(): string;
|
|
33
|
+
/** One mode as the document declares it. Fields absent from the document stay absent. */
|
|
34
|
+
export interface Mode {
|
|
35
|
+
id: string;
|
|
36
|
+
declaredBy: string;
|
|
37
|
+
pipeline: string[];
|
|
38
|
+
skips: string[];
|
|
39
|
+
gateSets: string[];
|
|
40
|
+
mergeAuthority: string;
|
|
41
|
+
grantedBy?: string;
|
|
42
|
+
conditions?: string[];
|
|
43
|
+
reviewContracts?: string[];
|
|
44
|
+
escalationBounds?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
export type ModesRead =
|
|
47
|
+
/**
|
|
48
|
+
* `raw` is the DECODED DOCUMENT before this module projects it into `Mode`
|
|
49
|
+
* records. It is returned because a caller must be able to validate what it
|
|
50
|
+
* is about to serve, and the projection is lossy by design (it drops
|
|
51
|
+
* anything the projection does not name). Handing a caller only the
|
|
52
|
+
* projection would force it to validate a shape no schema describes.
|
|
53
|
+
*/
|
|
54
|
+
{
|
|
55
|
+
ok: true;
|
|
56
|
+
path: string;
|
|
57
|
+
raw: unknown;
|
|
58
|
+
modes: Mode[];
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: string;
|
|
62
|
+
};
|
|
63
|
+
/** Read and decode the shipped document, or say why it could not be read. */
|
|
64
|
+
export declare function readModes(path?: string): ModesRead;
|
|
65
|
+
/**
|
|
66
|
+
* Where the rendered document came from. `shippedDocument` is true only when
|
|
67
|
+
* the reader was given no `--file` and therefore read the kernel's OWN
|
|
68
|
+
* `assurance-modes.yaml` from the package root.
|
|
69
|
+
*
|
|
70
|
+
* It is a REQUIRED parameter rather than an option with a default, because the
|
|
71
|
+
* execution-status line below is a claim about a specific document and a
|
|
72
|
+
* default would let a caller make that claim by omission.
|
|
73
|
+
*/
|
|
74
|
+
export interface RenderContext {
|
|
75
|
+
shippedDocument: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The execution status of one mode, DERIVED rather than looked up in a list of
|
|
79
|
+
* ids (CR-004 item 2, DR-0020).
|
|
80
|
+
*
|
|
81
|
+
* Two facts are available and both are checkable by the reader: whether this is
|
|
82
|
+
* the kernel's own document, and whether this mode IS the one blueprint section
|
|
83
|
+
* 8 names as the un-downgraded process, "The current proven process is the
|
|
84
|
+
* definition of `full`. Downgrades are declared, never improvised."
|
|
85
|
+
*
|
|
86
|
+
* WHY THE NAME AND NOT THE SKIP COUNT (CR-002, round 9). This function used
|
|
87
|
+
* `mode.skips.length === 0` as its proxy for "this is the un-downgraded mode".
|
|
88
|
+
* The proxy held only because `full` happened to be the only mode with an empty
|
|
89
|
+
* list, nothing enforced that, and `skips[]` is shipped DATA. Two measured
|
|
90
|
+
* consequences, both at exit 0 with every registry gate green: giving `full` one
|
|
91
|
+
* bogus `skips[]` entry made this function say that no phase of the tiphys
|
|
92
|
+
* project had ever been delivered under `full`, which is false about the mode
|
|
93
|
+
* this project has delivered every phase under; and giving `direct-pr` an empty
|
|
94
|
+
* `skips[]` made a mode nobody has ever entered claim to be the one the project
|
|
95
|
+
* follows, with `merge-authority: owner` printed beneath it, which is not the
|
|
96
|
+
* regime in force (DR-0015). Blueprint section 8 defines `full` BY NAME, so the
|
|
97
|
+
* name is the primary fact and the skip count is a consequence of it.
|
|
98
|
+
*
|
|
99
|
+
* THE COUNT IS STILL REPORTED, as a fact about the mode rather than as the
|
|
100
|
+
* ground of the claim. That distinction is the whole finding: a number may be
|
|
101
|
+
* shown without being believed.
|
|
102
|
+
*
|
|
103
|
+
* WHAT MAKES THE `full` SENTENCE TRUE IS DATA, AND IT IS GUARDED IN TWO PLACES
|
|
104
|
+
* THAT COVER DIFFERENT DOCUMENTS. Keying off the name moves the burden: the
|
|
105
|
+
* claim is only as good as the `full` in the document being served genuinely
|
|
106
|
+
* being un-downgraded.
|
|
107
|
+
*
|
|
108
|
+
* The VALIDATOR carries it for EVERY document (round 10, CRB9-02).
|
|
109
|
+
* `mode-no-undeclared-downgrade` rejects a `skips[]` entry that the same mode's
|
|
110
|
+
* pipeline runs, and also one that the reference does not run; on the reference
|
|
111
|
+
* those two exhaust the cases, so a `full` declaring any skipped stage at all is
|
|
112
|
+
* refused and this sentence is never printed over it. Until that second part
|
|
113
|
+
* existed, a `full` whose stage had MOVED from `pipeline` into `skips` was an
|
|
114
|
+
* HONEST downgrade that validated at exit 0, and `tiphys mode show --mode full`
|
|
115
|
+
* printed this sentence about fifteen lines above a `skips: deploy-verify` row.
|
|
116
|
+
*
|
|
117
|
+
* A REGISTERED TEST carries it for THIS repository's own document, asserting the
|
|
118
|
+
* shipped `full` declares no skipped stage. It is kept rather than superseded:
|
|
119
|
+
* a test guards the document, the check guards every other document, and the
|
|
120
|
+
* failure this pair exists to prevent is a property asserted where it is stated
|
|
121
|
+
* and not enforced where it is consumed.
|
|
122
|
+
*
|
|
123
|
+
* WHAT THIS DELIBERATELY DOES NOT SAY. It does not say that tiphys runs
|
|
124
|
+
* anything: nothing runs on tiphys before M4. The un-downgraded mode of the
|
|
125
|
+
* kernel's own document is the process the tiphys PROJECT follows for its own
|
|
126
|
+
* delivery; the downgraded ones have never been entered at all. And for a
|
|
127
|
+
* document that is not the kernel's own, the answer is that tiphys does not
|
|
128
|
+
* know, because it does not.
|
|
129
|
+
*/
|
|
130
|
+
export declare function executionStatus(mode: Mode, context: RenderContext): string;
|
|
131
|
+
/**
|
|
132
|
+
* The standing limits of this release, printed on every invocation.
|
|
133
|
+
*
|
|
134
|
+
* IT SAYS ONLY WHAT THE SHIPPED SCHEMAS DO. The vocabularies really are closed
|
|
135
|
+
* enums, so "a document naming any other id is rejected" is the enum's own
|
|
136
|
+
* behaviour and not a claim about intent. The M4 sentence is attributed to
|
|
137
|
+
* DR-0020 rather than stated as a property of the code.
|
|
138
|
+
*/
|
|
139
|
+
export declare const RELEASE_LIMITS: string;
|
|
140
|
+
/**
|
|
141
|
+
* Render one mode for a human or for a brief.
|
|
142
|
+
*
|
|
143
|
+
* THE SHAPE IS PART OF THE CONTRACT, because criterion 2 asserts over it: a
|
|
144
|
+
* section is a line ending in a colon at column zero, and its items are the
|
|
145
|
+
* lines indented by exactly two spaces beneath it. That makes "prints exactly
|
|
146
|
+
* the twelve stage ids in order" something a test can extract rather than
|
|
147
|
+
* something a reader has to eyeball.
|
|
148
|
+
*/
|
|
149
|
+
export declare function renderMode(mode: Mode, context: RenderContext): string[];
|