@abelxiaoxing/cadence 1.8.4 → 2.0.1
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/README.md +324 -117
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/agents/design-explorer.md +14 -18
- package/agents/diagnosis-worker.md +14 -14
- package/agents/implementation-worker.md +16 -17
- package/config/workflow-task.example.json +7 -0
- package/docs/remote-access.md +60 -0
- package/docs/reuse-and-distribution.md +44 -0
- package/docs/service-management.md +108 -0
- package/licenses/bundled-web.txt +6245 -0
- package/licenses/pi-web-ui-MIT.txt +21 -0
- package/licenses/pi-web-ui-reuse.md +22 -0
- package/package.json +92 -20
- package/src/index.ts +21 -1802
- package/src/native-build.ts +6 -0
- package/src/native-client.ts +103 -0
- package/src/native-export.ts +58 -0
- package/src/native-history.ts +359 -0
- package/src/operator-cli.mjs +453 -1937
- package/src/plan-proposal.ts +87 -0
- package/src/sdk-worker.mjs +549 -0
- package/src/service/contracts/active.ts +82 -0
- package/src/service/contracts/budget.ts +148 -0
- package/src/service/contracts/build.ts +67 -0
- package/src/service/contracts/capture.ts +48 -0
- package/src/service/contracts/core.ts +33 -0
- package/src/service/contracts/current.ts +261 -0
- package/src/service/contracts/domain.ts +68 -0
- package/src/service/contracts/history.ts +2245 -0
- package/src/service/contracts/models.ts +139 -0
- package/src/service/coordination.ts +862 -0
- package/src/service/private-files.ts +204 -0
- package/src/service-cli.mjs +5592 -0
- package/src/service-client.ts +747 -0
- package/src/service-contract.ts +245 -0
- package/src/service-host.mjs +22382 -0
- package/src/service-http-client.ts +425 -0
- package/src/service-wire.ts +372 -0
- package/src/task-shell.ts +1045 -0
- package/src/task-store.ts +479 -0
- package/src/task-types.ts +184 -0
- package/src/verification-diagnostics.ts +29 -72
- package/src/worker-capture.mjs +1197 -0
- package/src/worker-public.mjs +1753 -0
- package/src/worker-redaction.mjs +608 -0
- package/src/worker-tools.mjs +202 -0
- package/web/dist/assets/KaTeX_AMS-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/web/dist/assets/cadence.css +2 -0
- package/web/dist/assets/cadence.js +7 -0
- package/web/dist/assets/markdown.js +296 -0
- package/web/dist/assets/react.js +9 -0
- package/web/dist/assets/rolldown-runtime.js +1 -0
- package/web/dist/assets/timeline-client.js +41 -0
- package/web/dist/assets/vis-timeline.css +1274 -0
- package/web/dist/assets/vis-timeline.js +32 -0
- package/web/dist/index.html +18 -0
- package/web/dist/manifest.json +505 -0
- package/config/plan-draft.example.json +0 -125
- package/config/plan-draft.multiple-tasks.example.json +0 -375
- package/config/plan-draft.quick.example.json +0 -78
- package/prompts/abel-design.md +0 -382
- package/prompts/abel-diagnose.md +0 -71
- package/prompts/abel-implement.md +0 -284
- package/prompts/abel-init.md +0 -52
- package/src/activation.ts +0 -44
- package/src/activity-contracts.ts +0 -82
- package/src/agent-registry.ts +0 -53
- package/src/apply-transaction.ts +0 -1437
- package/src/artifact-store.ts +0 -213
- package/src/build-windows-job.mjs +0 -95
- package/src/candidate-artifact.ts +0 -143
- package/src/candidate-context.ts +0 -289
- package/src/candidate-patch.ts +0 -271
- package/src/candidate-workspace.ts +0 -80
- package/src/canonical.ts +0 -16
- package/src/change-contract-schema.ts +0 -191
- package/src/change-contract.ts +0 -324
- package/src/change-verification.ts +0 -1617
- package/src/contracts.ts +0 -2466
- package/src/control-contracts.ts +0 -382
- package/src/delivery-compiler.ts +0 -1805
- package/src/delivery-revision.ts +0 -42
- package/src/design-control.ts +0 -1299
- package/src/design-diagnostics.ts +0 -181
- package/src/design-journal.ts +0 -1773
- package/src/directory-sync.ts +0 -36
- package/src/durable-contracts.ts +0 -227
- package/src/durable-workflow.ts +0 -822
- package/src/evidence-draft.ts +0 -122
- package/src/execution-profile.ts +0 -64
- package/src/execution-retention.ts +0 -77
- package/src/file-snapshot.ts +0 -163
- package/src/implement-continuation.ts +0 -559
- package/src/implement-graph.ts +0 -690
- package/src/implement-plan.ts +0 -72
- package/src/install-config.d.mts +0 -6
- package/src/isolation-backend.ts +0 -353
- package/src/model-source.ts +0 -22
- package/src/openspec-cli.ts +0 -494
- package/src/operator-cli-entry.mjs +0 -24
- package/src/operator-tools.ts +0 -309
- package/src/package-candidate-process.ts +0 -206
- package/src/package-candidate.ts +0 -24
- package/src/package-delivery.ts +0 -486
- package/src/package-state.ts +0 -320
- package/src/package-verification.ts +0 -635
- package/src/package-workflow.ts +0 -305
- package/src/packet-runtime.ts +0 -459
- package/src/phase-execution.ts +0 -2163
- package/src/pi-adapter.ts +0 -19
- package/src/plan-draft-summary.ts +0 -527
- package/src/plan-draft.ts +0 -502
- package/src/remove-path.ts +0 -32
- package/src/run-state.ts +0 -420
- package/src/run-store.ts +0 -1179
- package/src/safe-path.ts +0 -77
- package/src/scoped-grep-worker.mjs +0 -23
- package/src/scoped-tools.ts +0 -775
- package/src/single-task-draft.ts +0 -142
- package/src/sqlite-schema.ts +0 -171
- package/src/state-root.ts +0 -215
- package/src/storage-schema.ts +0 -360
- package/src/subagent-activity.ts +0 -1417
- package/src/subagent-process.ts +0 -476
- package/src/task-ledger.ts +0 -1338
- package/src/transport-budget.ts +0 -50
- package/src/verification-capability.ts +0 -1191
- package/src/verification-environment-io.ts +0 -389
- package/src/verification-environment.ts +0 -162
- package/src/verification-identity.ts +0 -77
- package/src/verification-scan.ts +0 -64
- package/src/windows-job-backend.ts +0 -419
- package/src/windows-job.c +0 -149
- package/src/workflow-engine.ts +0 -28
- package/src/workflow-policy.ts +0 -1670
- package/src/workflow-recovery-policy.ts +0 -97
- package/src/workflow-scheduling.ts +0 -105
- package/src/workflow-state-machine.ts +0 -4341
- package/src/workflow-status.ts +0 -576
- package/src/workspace-io-entry.mjs +0 -76
- package/src/workspace-io-worker.mjs +0 -1312
- package/src/workspace-io.ts +0 -89
- package/src/workspace-store.ts +0 -634
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { opendirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type CheckoutLock,
|
|
7
|
+
resolveWorkspace,
|
|
8
|
+
TaskStore,
|
|
9
|
+
} from "../task-store.ts";
|
|
10
|
+
import {
|
|
11
|
+
ContractError,
|
|
12
|
+
closed,
|
|
13
|
+
decodeHash,
|
|
14
|
+
decodeId,
|
|
15
|
+
integer,
|
|
16
|
+
list,
|
|
17
|
+
literal,
|
|
18
|
+
oneOf,
|
|
19
|
+
text,
|
|
20
|
+
} from "./contracts/core.ts";
|
|
21
|
+
import {
|
|
22
|
+
encoded,
|
|
23
|
+
type FileSnapshot,
|
|
24
|
+
hash,
|
|
25
|
+
json,
|
|
26
|
+
PrivateFiles,
|
|
27
|
+
REGISTRY_BYTES,
|
|
28
|
+
REGISTRY_ENTRIES,
|
|
29
|
+
refused,
|
|
30
|
+
} from "./private-files.ts";
|
|
31
|
+
|
|
32
|
+
export interface OwnerConfig {
|
|
33
|
+
schema: "cadence-service-owner-config/1";
|
|
34
|
+
/** Requested loopback policy; :0 persists unchanged across explicit starts. */
|
|
35
|
+
endpoint: string;
|
|
36
|
+
/** Identity of secret-free selected service policy, NOT provider/auth bytes. */
|
|
37
|
+
policyIdentity: string;
|
|
38
|
+
}
|
|
39
|
+
export type OwnerPhase = "idle" | "stopping" | "quarantined" | "stopped";
|
|
40
|
+
export interface Registration {
|
|
41
|
+
schema: "cadence-service-registration/1";
|
|
42
|
+
root: string;
|
|
43
|
+
stateHome: string;
|
|
44
|
+
configIdentity: string;
|
|
45
|
+
endpoint: string;
|
|
46
|
+
epoch: string;
|
|
47
|
+
pid: number;
|
|
48
|
+
host: string;
|
|
49
|
+
ownerToken: string;
|
|
50
|
+
checkoutToken: string;
|
|
51
|
+
phase: OwnerPhase;
|
|
52
|
+
}
|
|
53
|
+
type Reservation = Omit<Registration, "schema" | "phase" | "checkoutToken">;
|
|
54
|
+
interface Registry {
|
|
55
|
+
schema: "cadence-service-registry/1";
|
|
56
|
+
entries: Reservation[];
|
|
57
|
+
}
|
|
58
|
+
const registrationKeys = [
|
|
59
|
+
"schema",
|
|
60
|
+
"root",
|
|
61
|
+
"stateHome",
|
|
62
|
+
"configIdentity",
|
|
63
|
+
"endpoint",
|
|
64
|
+
"epoch",
|
|
65
|
+
"pid",
|
|
66
|
+
"host",
|
|
67
|
+
"ownerToken",
|
|
68
|
+
"checkoutToken",
|
|
69
|
+
"phase",
|
|
70
|
+
];
|
|
71
|
+
const reservationKeys = registrationKeys.filter(
|
|
72
|
+
(k) => !["schema", "phase", "checkoutToken"].includes(k),
|
|
73
|
+
);
|
|
74
|
+
function pathValue(value: unknown): string {
|
|
75
|
+
const path = text(value, 4096, true);
|
|
76
|
+
if (!isAbsolute(path)) refused();
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
function endpoint(value: unknown, allowZero = false): string {
|
|
80
|
+
const s = text(value, 128, true);
|
|
81
|
+
const match = /^http:\/\/(127\.0\.0\.1|\[::1\]):(0|[1-9][0-9]{0,4})$/.exec(s);
|
|
82
|
+
if (!match || Number(match[2]) > 65535 || (!allowZero && match[2] === "0"))
|
|
83
|
+
refused();
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
function endpointMatches(requested: string, bound: string): boolean {
|
|
87
|
+
return requested.endsWith(":0")
|
|
88
|
+
? requested.slice(0, -1) === bound.slice(0, bound.lastIndexOf(":") + 1)
|
|
89
|
+
: requested === bound;
|
|
90
|
+
}
|
|
91
|
+
export function decodeOwnerConfig(value: unknown): OwnerConfig {
|
|
92
|
+
const o = closed(value, ["schema", "endpoint", "policyIdentity"]);
|
|
93
|
+
return {
|
|
94
|
+
schema: literal(o.schema, "cadence-service-owner-config/1"),
|
|
95
|
+
endpoint: endpoint(o.endpoint, true),
|
|
96
|
+
policyIdentity: decodeHash(o.policyIdentity),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function decodeReservation(value: unknown): Reservation {
|
|
100
|
+
const o = closed(value, reservationKeys);
|
|
101
|
+
return {
|
|
102
|
+
root: pathValue(o.root),
|
|
103
|
+
stateHome: pathValue(o.stateHome),
|
|
104
|
+
configIdentity: decodeHash(o.configIdentity),
|
|
105
|
+
endpoint: endpoint(o.endpoint),
|
|
106
|
+
epoch: decodeId(o.epoch),
|
|
107
|
+
pid: integer(o.pid, 1),
|
|
108
|
+
host: text(o.host, 255, true),
|
|
109
|
+
ownerToken: decodeId(o.ownerToken),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function reservation(r: Registration): Reservation {
|
|
113
|
+
const {
|
|
114
|
+
schema: _schema,
|
|
115
|
+
phase: _phase,
|
|
116
|
+
checkoutToken: _checkout,
|
|
117
|
+
...row
|
|
118
|
+
} = r;
|
|
119
|
+
return row;
|
|
120
|
+
}
|
|
121
|
+
function decodeRegistration(value: unknown): Registration {
|
|
122
|
+
const o = closed(value, registrationKeys);
|
|
123
|
+
const row = Object.fromEntries(reservationKeys.map((k) => [k, o[k]]));
|
|
124
|
+
return {
|
|
125
|
+
schema: literal(o.schema, "cadence-service-registration/1"),
|
|
126
|
+
...decodeReservation(row),
|
|
127
|
+
checkoutToken: decodeId(o.checkoutToken),
|
|
128
|
+
phase: oneOf(o.phase, ["idle", "stopping", "quarantined", "stopped"]),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Segment-aware, symmetric, independent of Git classification. */
|
|
132
|
+
export function rootsOverlap(a: string, b: string): boolean {
|
|
133
|
+
const within = (x: string, y: string) => {
|
|
134
|
+
const r = relative(x, y);
|
|
135
|
+
return (
|
|
136
|
+
r === "" || (r !== ".." && !r.startsWith(`..${sep}`) && !isAbsolute(r))
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
return within(a, b) || within(b, a);
|
|
140
|
+
}
|
|
141
|
+
function names(path: string, allowed: readonly string[]): void {
|
|
142
|
+
const dir = opendirSync(path);
|
|
143
|
+
try {
|
|
144
|
+
let count = 0;
|
|
145
|
+
for (let entry = dir.readSync(); entry; entry = dir.readSync()) {
|
|
146
|
+
if (++count > allowed.length || !allowed.includes(entry.name)) refused();
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
dir.closeSync();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
interface RegistryView {
|
|
153
|
+
format: FileSnapshot;
|
|
154
|
+
snapshot: FileSnapshot;
|
|
155
|
+
value: Registry;
|
|
156
|
+
}
|
|
157
|
+
interface Selected {
|
|
158
|
+
config: FileSnapshot;
|
|
159
|
+
hostConfig?: FileSnapshot;
|
|
160
|
+
manifest: FileSnapshot;
|
|
161
|
+
registration?: FileSnapshot;
|
|
162
|
+
owner?: FileSnapshot;
|
|
163
|
+
capability?: FileSnapshot;
|
|
164
|
+
checkout?: FileSnapshot;
|
|
165
|
+
value?: Registration;
|
|
166
|
+
}
|
|
167
|
+
export function recoveryOwnerStatus(
|
|
168
|
+
pid: number,
|
|
169
|
+
host: string,
|
|
170
|
+
): "live" | "absent" | "ambiguous" {
|
|
171
|
+
if (host !== hostname()) return "ambiguous";
|
|
172
|
+
try {
|
|
173
|
+
process.kill(pid, 0);
|
|
174
|
+
return "live";
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH"
|
|
177
|
+
? "absent"
|
|
178
|
+
: "ambiguous";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function recoveryDigest(registry: RegistryView, selected: Selected): string {
|
|
182
|
+
return hash(
|
|
183
|
+
encoded([
|
|
184
|
+
registry.format.identity,
|
|
185
|
+
registry.format.stamp,
|
|
186
|
+
registry.snapshot.identity,
|
|
187
|
+
registry.snapshot.stamp,
|
|
188
|
+
selected.capability?.stamp ?? null, // incarnation only; never a secret-derived capability digest
|
|
189
|
+
...[
|
|
190
|
+
selected.manifest,
|
|
191
|
+
selected.config,
|
|
192
|
+
selected.hostConfig,
|
|
193
|
+
selected.registration,
|
|
194
|
+
selected.owner,
|
|
195
|
+
selected.checkout,
|
|
196
|
+
].map((value) => (value ? [value.identity, value.stamp] : null)),
|
|
197
|
+
]),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
export type CoordinationStatus =
|
|
201
|
+
| { status: "absent" | "unavailable" }
|
|
202
|
+
| {
|
|
203
|
+
status: "stopped" | "unavailable" | "stopping" | "quarantined";
|
|
204
|
+
epoch: string | null;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Explicit filesystem ownership only. No listener, worker, auth resolution,
|
|
208
|
+
* task creation, stale reclaim or accepting execution route lives here.
|
|
209
|
+
*/
|
|
210
|
+
export class ServiceCoordination {
|
|
211
|
+
readonly store: TaskStore;
|
|
212
|
+
readonly files: PrivateFiles;
|
|
213
|
+
readonly directory: string;
|
|
214
|
+
readonly registryDirectory: string;
|
|
215
|
+
readonly stateHome: string;
|
|
216
|
+
constructor(workspace: string, stateHome?: string) {
|
|
217
|
+
this.store = new TaskStore(resolveWorkspace(workspace).root, stateHome);
|
|
218
|
+
const namespace = dirname(this.store.privateRoot);
|
|
219
|
+
this.files = new PrivateFiles(namespace);
|
|
220
|
+
this.stateHome = dirname(namespace);
|
|
221
|
+
this.directory = join(this.store.privateRoot, "service-v1");
|
|
222
|
+
this.registryDirectory = join(namespace, "service-registry-v1");
|
|
223
|
+
}
|
|
224
|
+
path(
|
|
225
|
+
name:
|
|
226
|
+
| "format.json"
|
|
227
|
+
| "config.json"
|
|
228
|
+
| "registration.json"
|
|
229
|
+
| "owner.lock"
|
|
230
|
+
| "local-capability.json"
|
|
231
|
+
| "host-config.json"
|
|
232
|
+
| "access-config.json"
|
|
233
|
+
| "access.json",
|
|
234
|
+
): string {
|
|
235
|
+
return join(this.directory, name);
|
|
236
|
+
}
|
|
237
|
+
private registryPath(name: string): string {
|
|
238
|
+
return join(this.registryDirectory, name);
|
|
239
|
+
}
|
|
240
|
+
private registry(): RegistryView | undefined {
|
|
241
|
+
if (!this.files.directory(this.registryDirectory)) return undefined;
|
|
242
|
+
names(this.registryDirectory, [
|
|
243
|
+
"format.json",
|
|
244
|
+
"roots.json",
|
|
245
|
+
"registry.lock",
|
|
246
|
+
]);
|
|
247
|
+
const format = this.files.read(this.registryPath("format.json"));
|
|
248
|
+
const snapshot = this.files.read(
|
|
249
|
+
this.registryPath("roots.json"),
|
|
250
|
+
REGISTRY_BYTES,
|
|
251
|
+
);
|
|
252
|
+
if (!format || !snapshot) refused();
|
|
253
|
+
const f = closed(json(format), ["schema", "stateHome"]);
|
|
254
|
+
literal(f.schema, "cadence-service-registry-format/1");
|
|
255
|
+
literal(f.stateHome, this.stateHome);
|
|
256
|
+
const o = closed(json(snapshot, REGISTRY_BYTES), ["schema", "entries"]);
|
|
257
|
+
const entries = list(o.entries, REGISTRY_ENTRIES, decodeReservation);
|
|
258
|
+
for (const [i, row] of entries.entries()) {
|
|
259
|
+
if (
|
|
260
|
+
row.stateHome !== this.stateHome ||
|
|
261
|
+
realpathSync(row.root) !== row.root
|
|
262
|
+
)
|
|
263
|
+
refused();
|
|
264
|
+
// An endpoint cannot be shared by two supported owners, even for disjoint roots.
|
|
265
|
+
if (
|
|
266
|
+
entries
|
|
267
|
+
.slice(0, i)
|
|
268
|
+
.some(
|
|
269
|
+
(other) =>
|
|
270
|
+
rootsOverlap(row.root, other.root) ||
|
|
271
|
+
row.endpoint === other.endpoint,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
refused();
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
format,
|
|
278
|
+
snapshot,
|
|
279
|
+
value: {
|
|
280
|
+
schema: literal(o.schema, "cadence-service-registry/1"),
|
|
281
|
+
entries,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
assertNamespace(): void {
|
|
286
|
+
if (!this.files.directory(this.directory)) refused();
|
|
287
|
+
names(this.directory, [
|
|
288
|
+
"format.json",
|
|
289
|
+
"config.json",
|
|
290
|
+
"registration.json",
|
|
291
|
+
"owner.lock",
|
|
292
|
+
"local-capability.json",
|
|
293
|
+
"access-config.json",
|
|
294
|
+
"access.json",
|
|
295
|
+
"host-config.json",
|
|
296
|
+
"history-v2",
|
|
297
|
+
]);
|
|
298
|
+
this.files.directory(join(this.directory, "history-v2"));
|
|
299
|
+
}
|
|
300
|
+
private selected(): Selected | undefined {
|
|
301
|
+
if (!this.files.directory(this.directory)) return undefined;
|
|
302
|
+
this.assertNamespace();
|
|
303
|
+
const manifest = this.files.read(this.path("format.json"));
|
|
304
|
+
const config = this.files.read(this.path("config.json"));
|
|
305
|
+
if (!manifest || !config) refused();
|
|
306
|
+
const f = closed(json(manifest), [
|
|
307
|
+
"schema",
|
|
308
|
+
"root",
|
|
309
|
+
"stateHome",
|
|
310
|
+
"configIdentity",
|
|
311
|
+
]);
|
|
312
|
+
literal(f.schema, "cadence-service-format/1");
|
|
313
|
+
literal(f.root, this.store.root);
|
|
314
|
+
literal(f.stateHome, this.stateHome);
|
|
315
|
+
literal(f.configIdentity, config.identity);
|
|
316
|
+
const c = decodeOwnerConfig(json(config));
|
|
317
|
+
const hostConfig = this.files.read(this.path("host-config.json"));
|
|
318
|
+
if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
|
|
319
|
+
const registration = this.files.read(this.path("registration.json"));
|
|
320
|
+
const owner = this.files.read(this.path("owner.lock"));
|
|
321
|
+
const capability = this.files.read(this.path("local-capability.json"));
|
|
322
|
+
const checkout = this.files.read(this.store.lockPath, 8192);
|
|
323
|
+
const value = registration
|
|
324
|
+
? decodeRegistration(json(registration))
|
|
325
|
+
: undefined;
|
|
326
|
+
if (
|
|
327
|
+
value &&
|
|
328
|
+
(value.root !== this.store.root ||
|
|
329
|
+
value.stateHome !== this.stateHome ||
|
|
330
|
+
value.configIdentity !== config.identity ||
|
|
331
|
+
!endpointMatches(c.endpoint, value.endpoint))
|
|
332
|
+
)
|
|
333
|
+
refused();
|
|
334
|
+
if (value && value.phase !== "stopped") {
|
|
335
|
+
if (!owner || !checkout || !capability) refused();
|
|
336
|
+
const lock = closed(json(owner), [
|
|
337
|
+
"schema",
|
|
338
|
+
"epoch",
|
|
339
|
+
"token",
|
|
340
|
+
"pid",
|
|
341
|
+
"host",
|
|
342
|
+
]);
|
|
343
|
+
literal(lock.schema, "cadence-service-owner-lock/1");
|
|
344
|
+
literal(lock.epoch, value.epoch);
|
|
345
|
+
literal(lock.token, value.ownerToken);
|
|
346
|
+
literal(lock.pid, value.pid);
|
|
347
|
+
literal(lock.host, value.host);
|
|
348
|
+
const legacy = this.store.inspectLock();
|
|
349
|
+
if (
|
|
350
|
+
!legacy ||
|
|
351
|
+
legacy.token !== value.checkoutToken ||
|
|
352
|
+
legacy.pid !== value.pid ||
|
|
353
|
+
legacy.host !== value.host
|
|
354
|
+
)
|
|
355
|
+
refused();
|
|
356
|
+
const cap = closed(json(capability), ["schema", "epoch", "capability"]);
|
|
357
|
+
literal(cap.schema, "cadence-service-local-capability/1");
|
|
358
|
+
literal(cap.epoch, value.epoch);
|
|
359
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(text(cap.capability, 43))) refused();
|
|
360
|
+
} else if (owner || capability || (value && checkout)) refused();
|
|
361
|
+
return {
|
|
362
|
+
config,
|
|
363
|
+
hostConfig,
|
|
364
|
+
manifest,
|
|
365
|
+
registration,
|
|
366
|
+
owner,
|
|
367
|
+
capability,
|
|
368
|
+
checkout,
|
|
369
|
+
value,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** A PID alone cannot establish alive/authenticated readiness. Transport will
|
|
373
|
+
* challenge the epoch separately; stale file status remains unavailable here. */
|
|
374
|
+
inspect(): CoordinationStatus {
|
|
375
|
+
try {
|
|
376
|
+
if (this.files.read(this.registryPath("registry.lock")))
|
|
377
|
+
return { status: "unavailable" };
|
|
378
|
+
const registry = this.registry();
|
|
379
|
+
const selected = this.selected();
|
|
380
|
+
const row = registry?.value.entries.find(
|
|
381
|
+
(r) => r.root === this.store.root,
|
|
382
|
+
);
|
|
383
|
+
if (!selected)
|
|
384
|
+
return row ? { status: "unavailable" } : { status: "absent" };
|
|
385
|
+
if (!selected.value)
|
|
386
|
+
return row
|
|
387
|
+
? { status: "unavailable" }
|
|
388
|
+
: { status: "stopped", epoch: null };
|
|
389
|
+
const r = selected.value;
|
|
390
|
+
if (r.phase === "stopped")
|
|
391
|
+
return { status: row ? "unavailable" : "stopped", epoch: r.epoch };
|
|
392
|
+
if (!row || encoded(row) !== encoded(reservation(r)))
|
|
393
|
+
return { status: "unavailable", epoch: r.epoch };
|
|
394
|
+
return {
|
|
395
|
+
status: r.phase === "idle" ? "unavailable" : r.phase,
|
|
396
|
+
epoch: r.epoch,
|
|
397
|
+
};
|
|
398
|
+
} catch {
|
|
399
|
+
return { status: "unavailable" };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** Explicit initialization is not discovery. Partial initialization is never
|
|
403
|
+
* adopted/reset. Caller must retain the failure and request manual diagnosis. */
|
|
404
|
+
initialize(input: OwnerConfig): void {
|
|
405
|
+
const config = decodeOwnerConfig(input);
|
|
406
|
+
const existing = this.selected();
|
|
407
|
+
if (existing) {
|
|
408
|
+
if (existing.config.identity !== hash(encoded(config)))
|
|
409
|
+
throw new ContractError("conflict");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.files.directory(this.directory, true);
|
|
413
|
+
const saved = this.files.create(this.path("config.json"), encoded(config));
|
|
414
|
+
this.files.create(
|
|
415
|
+
this.path("format.json"),
|
|
416
|
+
encoded({
|
|
417
|
+
schema: "cadence-service-format/1",
|
|
418
|
+
root: this.store.root,
|
|
419
|
+
stateHome: this.stateHome,
|
|
420
|
+
configIdentity: saved.identity,
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
private withRegistry<T>(
|
|
425
|
+
operation: (
|
|
426
|
+
view: RegistryView,
|
|
427
|
+
mutate: () => void,
|
|
428
|
+
assertHeld: () => void,
|
|
429
|
+
) => T,
|
|
430
|
+
): T {
|
|
431
|
+
this.files.directory(this.registryDirectory, true);
|
|
432
|
+
names(this.registryDirectory, [
|
|
433
|
+
"format.json",
|
|
434
|
+
"roots.json",
|
|
435
|
+
"registry.lock",
|
|
436
|
+
]);
|
|
437
|
+
const lockPath = this.registryPath("registry.lock");
|
|
438
|
+
const lock = this.files.create(
|
|
439
|
+
lockPath,
|
|
440
|
+
encoded({
|
|
441
|
+
schema: "cadence-service-registry-lock/1",
|
|
442
|
+
token: randomUUID(),
|
|
443
|
+
pid: process.pid,
|
|
444
|
+
host: hostname(),
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
let dirty = false;
|
|
448
|
+
try {
|
|
449
|
+
let format = this.files.read(this.registryPath("format.json"));
|
|
450
|
+
const roots = this.files.read(
|
|
451
|
+
this.registryPath("roots.json"),
|
|
452
|
+
REGISTRY_BYTES,
|
|
453
|
+
);
|
|
454
|
+
if (!format && !roots) {
|
|
455
|
+
dirty = true;
|
|
456
|
+
format = this.files.create(
|
|
457
|
+
this.registryPath("format.json"),
|
|
458
|
+
encoded({
|
|
459
|
+
schema: "cadence-service-registry-format/1",
|
|
460
|
+
stateHome: this.stateHome,
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
this.files.create(
|
|
464
|
+
this.registryPath("roots.json"),
|
|
465
|
+
encoded({ schema: "cadence-service-registry/1", entries: [] }),
|
|
466
|
+
REGISTRY_BYTES,
|
|
467
|
+
);
|
|
468
|
+
dirty = false;
|
|
469
|
+
}
|
|
470
|
+
// Corrupt/partial existing registry is uncertainty, not an inert conflict.
|
|
471
|
+
dirty = true;
|
|
472
|
+
const view = this.registry();
|
|
473
|
+
if (!view) refused();
|
|
474
|
+
dirty = false;
|
|
475
|
+
const result = operation(
|
|
476
|
+
view,
|
|
477
|
+
() => {
|
|
478
|
+
dirty = true;
|
|
479
|
+
},
|
|
480
|
+
() => {
|
|
481
|
+
this.files.assert(lockPath, lock);
|
|
482
|
+
this.files.assert(this.registryPath("format.json"), format);
|
|
483
|
+
names(this.registryDirectory, [
|
|
484
|
+
"format.json",
|
|
485
|
+
"roots.json",
|
|
486
|
+
"registry.lock",
|
|
487
|
+
]);
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
this.files.remove(lockPath, lock);
|
|
491
|
+
return result;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
// Before any write, a conflict is a known inert refusal. Persistence or
|
|
494
|
+
// release uncertainty retains this exact short lock, never age-expires it.
|
|
495
|
+
if (!dirty) this.files.remove(lockPath, lock);
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
acquire(
|
|
500
|
+
input: OwnerConfig,
|
|
501
|
+
resolvedEndpoint: string = input.endpoint,
|
|
502
|
+
initializeNew?: () => void,
|
|
503
|
+
): OwnerLease {
|
|
504
|
+
const config = decodeOwnerConfig(input);
|
|
505
|
+
const bound = endpoint(resolvedEndpoint);
|
|
506
|
+
if (!endpointMatches(config.endpoint, bound))
|
|
507
|
+
throw new ContractError("conflict");
|
|
508
|
+
// Do not initialize a conflicting root as a side effect of a failed start.
|
|
509
|
+
return this.withRegistry((registry, mutate) => {
|
|
510
|
+
if (
|
|
511
|
+
registry.value.entries.some(
|
|
512
|
+
(r) => rootsOverlap(r.root, this.store.root) || r.endpoint === bound,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
throw new ContractError("conflict");
|
|
516
|
+
if (registry.value.entries.length >= REGISTRY_ENTRIES)
|
|
517
|
+
throw new ContractError("capacity");
|
|
518
|
+
const old = this.selected();
|
|
519
|
+
if (old?.value && old.value.phase !== "stopped")
|
|
520
|
+
throw new ContractError("quarantine");
|
|
521
|
+
if (this.store.inspectLock()) throw new ContractError("conflict");
|
|
522
|
+
if (old && old.config.identity !== hash(encoded(config)))
|
|
523
|
+
throw new ContractError("conflict");
|
|
524
|
+
// Reserve BEFORE root initialization/legacy acquisition. Any later failure
|
|
525
|
+
// keeps the reservation and short lock as an explicit partial fence.
|
|
526
|
+
const row: Reservation = {
|
|
527
|
+
root: this.store.root,
|
|
528
|
+
stateHome: this.stateHome,
|
|
529
|
+
configIdentity: hash(encoded(config)),
|
|
530
|
+
endpoint: bound,
|
|
531
|
+
epoch: randomUUID(),
|
|
532
|
+
pid: process.pid,
|
|
533
|
+
host: hostname(),
|
|
534
|
+
ownerToken: randomUUID(),
|
|
535
|
+
};
|
|
536
|
+
mutate();
|
|
537
|
+
this.files.replace(
|
|
538
|
+
this.registryPath("roots.json"),
|
|
539
|
+
registry.snapshot,
|
|
540
|
+
encoded({
|
|
541
|
+
...registry.value,
|
|
542
|
+
entries: [...registry.value.entries, row],
|
|
543
|
+
}),
|
|
544
|
+
REGISTRY_BYTES,
|
|
545
|
+
);
|
|
546
|
+
this.initialize(config);
|
|
547
|
+
// Called only for genuinely absent selected metadata, under the registry
|
|
548
|
+
// reservation and before registration. Failures preserve the partial fence.
|
|
549
|
+
if (!old) initializeNew?.();
|
|
550
|
+
const checkout = this.store.acquire();
|
|
551
|
+
const checkoutFile = this.files.read(this.store.lockPath, 8192);
|
|
552
|
+
if (!checkoutFile) refused();
|
|
553
|
+
const owner = this.files.create(
|
|
554
|
+
this.path("owner.lock"),
|
|
555
|
+
encoded({
|
|
556
|
+
schema: "cadence-service-owner-lock/1",
|
|
557
|
+
epoch: row.epoch,
|
|
558
|
+
token: row.ownerToken,
|
|
559
|
+
pid: row.pid,
|
|
560
|
+
host: row.host,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
const capability = this.files.create(
|
|
564
|
+
this.path("local-capability.json"),
|
|
565
|
+
encoded({
|
|
566
|
+
schema: "cadence-service-local-capability/1",
|
|
567
|
+
epoch: row.epoch,
|
|
568
|
+
capability: randomBytes(32).toString("base64url"),
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
const value: Registration = {
|
|
572
|
+
schema: "cadence-service-registration/1",
|
|
573
|
+
...row,
|
|
574
|
+
checkoutToken: checkout.token,
|
|
575
|
+
phase: "idle",
|
|
576
|
+
};
|
|
577
|
+
const registration = old?.registration
|
|
578
|
+
? this.files.replace(
|
|
579
|
+
this.path("registration.json"),
|
|
580
|
+
old.registration,
|
|
581
|
+
encoded(value),
|
|
582
|
+
)
|
|
583
|
+
: this.files.create(this.path("registration.json"), encoded(value));
|
|
584
|
+
const selected = this.selected();
|
|
585
|
+
if (!selected) refused();
|
|
586
|
+
return new OwnerLease(this, checkout, {
|
|
587
|
+
...selected,
|
|
588
|
+
owner,
|
|
589
|
+
capability,
|
|
590
|
+
checkout: checkoutFile,
|
|
591
|
+
registration,
|
|
592
|
+
value,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
configuration(): OwnerConfig | undefined {
|
|
597
|
+
const selected = this.selected();
|
|
598
|
+
return selected ? decodeOwnerConfig(json(selected.config)) : undefined;
|
|
599
|
+
}
|
|
600
|
+
/** Private direct-native client material; never a public status projection. */
|
|
601
|
+
localAccess(): { endpoint: string; epoch: string; capability: string } {
|
|
602
|
+
if (this.files.read(this.registryPath("registry.lock"))) refused();
|
|
603
|
+
const selected = this.selected(),
|
|
604
|
+
registry = this.registry();
|
|
605
|
+
if (
|
|
606
|
+
!selected?.value ||
|
|
607
|
+
!selected.capability ||
|
|
608
|
+
!registry ||
|
|
609
|
+
selected.value.phase === "stopped" ||
|
|
610
|
+
!registry.value.entries.some(
|
|
611
|
+
(row) =>
|
|
612
|
+
selected.value &&
|
|
613
|
+
encoded(row) === encoded(reservation(selected.value)),
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
throw new ContractError("unavailable");
|
|
617
|
+
const cap = closed(json(selected.capability), [
|
|
618
|
+
"schema",
|
|
619
|
+
"epoch",
|
|
620
|
+
"capability",
|
|
621
|
+
]);
|
|
622
|
+
return {
|
|
623
|
+
endpoint: selected.value.endpoint,
|
|
624
|
+
epoch: selected.value.epoch,
|
|
625
|
+
capability: text(cap.capability, 43, true),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** Internal release adapter. Never called by status/restart/recovery. */
|
|
629
|
+
release(lease: OwnerLease): void {
|
|
630
|
+
this.withRegistry((view, mutate, assertHeld) => {
|
|
631
|
+
lease.assert();
|
|
632
|
+
const r = lease.registration;
|
|
633
|
+
const rows = view.value.entries.filter((row) => row.epoch === r.epoch);
|
|
634
|
+
if (rows.length !== 1 || encoded(rows[0]) !== encoded(reservation(r)))
|
|
635
|
+
refused();
|
|
636
|
+
mutate();
|
|
637
|
+
lease.phase("stopped");
|
|
638
|
+
const releasedRegistry = this.files.replace(
|
|
639
|
+
this.registryPath("roots.json"),
|
|
640
|
+
view.snapshot,
|
|
641
|
+
encoded({
|
|
642
|
+
...view.value,
|
|
643
|
+
entries: view.value.entries.filter((row) => row.epoch !== r.epoch),
|
|
644
|
+
}),
|
|
645
|
+
REGISTRY_BYTES,
|
|
646
|
+
);
|
|
647
|
+
lease.removeOwnedFiles(() => {
|
|
648
|
+
assertHeld();
|
|
649
|
+
this.files.assert(
|
|
650
|
+
this.registryPath("roots.json"),
|
|
651
|
+
releasedRegistry,
|
|
652
|
+
REGISTRY_BYTES,
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/** Capture exact private resources BEFORE the append/confirmation awaits. The
|
|
658
|
+
* closure is internal local management authority, never a wire capability.
|
|
659
|
+
* A durable bridge must be verified by the caller before EVERY release effect. */
|
|
660
|
+
prepareRecoveryRelease(
|
|
661
|
+
expected: string,
|
|
662
|
+
assertAuthority: () => void,
|
|
663
|
+
): (assertDurable: () => void) => void {
|
|
664
|
+
assertAuthority();
|
|
665
|
+
if (this.recoverySnapshot().digest !== expected)
|
|
666
|
+
throw new ContractError("conflict");
|
|
667
|
+
const selected = this.selected();
|
|
668
|
+
if (
|
|
669
|
+
!selected?.value ||
|
|
670
|
+
!selected.registration ||
|
|
671
|
+
!selected.owner ||
|
|
672
|
+
!selected.checkout ||
|
|
673
|
+
!selected.capability
|
|
674
|
+
)
|
|
675
|
+
throw new ContractError("quarantine");
|
|
676
|
+
const r = selected.value;
|
|
677
|
+
const absent = () => {
|
|
678
|
+
assertAuthority();
|
|
679
|
+
if (recoveryOwnerStatus(r.pid, r.host) !== "absent")
|
|
680
|
+
throw new ContractError("quarantine");
|
|
681
|
+
};
|
|
682
|
+
absent();
|
|
683
|
+
let used = false;
|
|
684
|
+
return (assertDurable) => {
|
|
685
|
+
if (used) throw new ContractError("consumed-grant");
|
|
686
|
+
used = true;
|
|
687
|
+
this.withRegistry((view, mutate, assertHeld) => {
|
|
688
|
+
let registry = view.snapshot;
|
|
689
|
+
let registration = selected.registration;
|
|
690
|
+
let capability = selected.capability;
|
|
691
|
+
let owner = selected.owner;
|
|
692
|
+
let checkout = selected.checkout;
|
|
693
|
+
if (!registration || !capability || !owner || !checkout)
|
|
694
|
+
throw new ContractError("conflict");
|
|
695
|
+
if (recoveryDigest(view, selected) !== expected)
|
|
696
|
+
throw new ContractError("conflict");
|
|
697
|
+
const assert = () => {
|
|
698
|
+
absent();
|
|
699
|
+
assertHeld();
|
|
700
|
+
this.assertNamespace();
|
|
701
|
+
this.files.assert(
|
|
702
|
+
this.registryPath("roots.json"),
|
|
703
|
+
registry,
|
|
704
|
+
REGISTRY_BYTES,
|
|
705
|
+
);
|
|
706
|
+
for (const [name, snapshot] of [
|
|
707
|
+
["format.json", selected.manifest],
|
|
708
|
+
["config.json", selected.config],
|
|
709
|
+
["host-config.json", selected.hostConfig],
|
|
710
|
+
["registration.json", registration],
|
|
711
|
+
["local-capability.json", capability],
|
|
712
|
+
["owner.lock", owner],
|
|
713
|
+
] as const)
|
|
714
|
+
this.files.assert(this.path(name), snapshot);
|
|
715
|
+
this.files.assert(this.store.lockPath, checkout, 8192);
|
|
716
|
+
assertDurable();
|
|
717
|
+
};
|
|
718
|
+
assert();
|
|
719
|
+
const matching = view.value.entries.filter(
|
|
720
|
+
(row) => row.epoch === r.epoch,
|
|
721
|
+
);
|
|
722
|
+
if (
|
|
723
|
+
matching.length !== 1 ||
|
|
724
|
+
encoded(matching[0]) !== encoded(reservation(r))
|
|
725
|
+
)
|
|
726
|
+
throw new ContractError("conflict");
|
|
727
|
+
mutate(); // every subsequent partial failure retains the registry fence
|
|
728
|
+
registration = this.files.replace(
|
|
729
|
+
this.path("registration.json"),
|
|
730
|
+
registration,
|
|
731
|
+
encoded({ ...r, phase: "stopped" }),
|
|
732
|
+
);
|
|
733
|
+
assert();
|
|
734
|
+
registry = this.files.replace(
|
|
735
|
+
this.registryPath("roots.json"),
|
|
736
|
+
registry,
|
|
737
|
+
encoded({
|
|
738
|
+
...view.value,
|
|
739
|
+
entries: view.value.entries.filter((row) => row.epoch !== r.epoch),
|
|
740
|
+
}),
|
|
741
|
+
REGISTRY_BYTES,
|
|
742
|
+
);
|
|
743
|
+
assert();
|
|
744
|
+
this.files.remove(this.path("local-capability.json"), capability);
|
|
745
|
+
capability = undefined;
|
|
746
|
+
assert();
|
|
747
|
+
this.files.remove(this.path("owner.lock"), owner);
|
|
748
|
+
owner = undefined;
|
|
749
|
+
assert();
|
|
750
|
+
// Only the exact v1 lock tied to THIS registration, never a legacy lock.
|
|
751
|
+
this.files.remove(this.store.lockPath, checkout, 8192);
|
|
752
|
+
checkout = undefined;
|
|
753
|
+
assert();
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/** Private recovery identity only. Does not read/scan task records or logs.
|
|
758
|
+
* W3 independently supplies the affected-history digest; capability bytes and
|
|
759
|
+
* their hashes are deliberately absent from this recovery projection. */
|
|
760
|
+
recoverySnapshot(): { registration: Registration; digest: string } {
|
|
761
|
+
if (this.files.read(this.registryPath("registry.lock")))
|
|
762
|
+
throw new ContractError("quarantine");
|
|
763
|
+
const registry = this.registry();
|
|
764
|
+
const selected = this.selected();
|
|
765
|
+
if (
|
|
766
|
+
!registry ||
|
|
767
|
+
!selected?.value ||
|
|
768
|
+
!selected.registration ||
|
|
769
|
+
!selected.owner ||
|
|
770
|
+
!selected.checkout
|
|
771
|
+
)
|
|
772
|
+
refused();
|
|
773
|
+
const value = selected.value;
|
|
774
|
+
const matching = registry.value.entries.filter(
|
|
775
|
+
(row) => row.epoch === value.epoch,
|
|
776
|
+
);
|
|
777
|
+
if (
|
|
778
|
+
matching.length !== 1 ||
|
|
779
|
+
encoded(matching[0]) !== encoded(reservation(value))
|
|
780
|
+
)
|
|
781
|
+
refused();
|
|
782
|
+
return {
|
|
783
|
+
registration: { ...selected.value },
|
|
784
|
+
digest: recoveryDigest(registry, selected),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Held only by the original owner. Raw-byte AND inode/time identities supplement
|
|
790
|
+
* TaskStore's semantic lock assertion, including whitespace-only interference. */
|
|
791
|
+
export class OwnerLease {
|
|
792
|
+
private released = false;
|
|
793
|
+
constructor(
|
|
794
|
+
readonly coordination: ServiceCoordination,
|
|
795
|
+
readonly checkout: CheckoutLock,
|
|
796
|
+
private selected: Selected & {
|
|
797
|
+
value: Registration;
|
|
798
|
+
registration: FileSnapshot;
|
|
799
|
+
},
|
|
800
|
+
) {}
|
|
801
|
+
get registration(): Readonly<Registration> {
|
|
802
|
+
return { ...this.selected.value };
|
|
803
|
+
}
|
|
804
|
+
assert(): void {
|
|
805
|
+
if (
|
|
806
|
+
this.released ||
|
|
807
|
+
this.selected.value.pid !== process.pid ||
|
|
808
|
+
this.selected.value.host !== hostname()
|
|
809
|
+
)
|
|
810
|
+
throw new ContractError("conflict");
|
|
811
|
+
const c = this.coordination;
|
|
812
|
+
c.assertNamespace();
|
|
813
|
+
for (const [name, snapshot] of [
|
|
814
|
+
["config.json", this.selected.config],
|
|
815
|
+
["host-config.json", this.selected.hostConfig],
|
|
816
|
+
["format.json", this.selected.manifest],
|
|
817
|
+
["registration.json", this.selected.registration],
|
|
818
|
+
["owner.lock", this.selected.owner],
|
|
819
|
+
["local-capability.json", this.selected.capability],
|
|
820
|
+
] as const)
|
|
821
|
+
c.files.assert(c.path(name), snapshot);
|
|
822
|
+
c.files.assert(c.store.lockPath, this.selected.checkout, 8192);
|
|
823
|
+
c.store.assertOwner(this.checkout);
|
|
824
|
+
}
|
|
825
|
+
phase(phase: OwnerPhase): void {
|
|
826
|
+
this.assert();
|
|
827
|
+
const value = { ...this.selected.value, phase };
|
|
828
|
+
const registration = this.coordination.files.replace(
|
|
829
|
+
this.coordination.path("registration.json"),
|
|
830
|
+
this.selected.registration,
|
|
831
|
+
encoded(value),
|
|
832
|
+
);
|
|
833
|
+
this.selected = { ...this.selected, value, registration };
|
|
834
|
+
}
|
|
835
|
+
release(): void {
|
|
836
|
+
this.assert();
|
|
837
|
+
this.coordination.release(this);
|
|
838
|
+
this.released = true;
|
|
839
|
+
}
|
|
840
|
+
/** Coordination's final ordered release; callers must use owner.stop(). */
|
|
841
|
+
removeOwnedFiles(assertRegistry: () => void): void {
|
|
842
|
+
this.assert();
|
|
843
|
+
assertRegistry();
|
|
844
|
+
const c = this.coordination;
|
|
845
|
+
if (!this.selected.capability || !this.selected.owner) refused();
|
|
846
|
+
const owner = this.selected.owner;
|
|
847
|
+
c.files.remove(c.path("local-capability.json"), this.selected.capability);
|
|
848
|
+
this.selected = { ...this.selected, capability: undefined };
|
|
849
|
+
// Only successful removals change the expected state. A failure or change
|
|
850
|
+
// at a later boundary must retain every remaining fence, not finish release.
|
|
851
|
+
this.assert();
|
|
852
|
+
assertRegistry();
|
|
853
|
+
c.files.remove(c.path("owner.lock"), owner);
|
|
854
|
+
this.selected = { ...this.selected, owner: undefined };
|
|
855
|
+
this.assert();
|
|
856
|
+
assertRegistry();
|
|
857
|
+
// Last per-checkout removal: legacy v1 writers stay blocked through all
|
|
858
|
+
// preceding durable state publication. Multi-file release is not atomic.
|
|
859
|
+
c.files.assert(c.store.lockPath, this.selected.checkout, 8192);
|
|
860
|
+
c.store.release(this.checkout);
|
|
861
|
+
}
|
|
862
|
+
}
|