@abelxiaoxing/cadence 2.0.13 → 2.0.15

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.
@@ -0,0 +1,303 @@
1
+ /** Finite, authenticated workbench API. Ordinary chat is not task admission. */
2
+ import {
3
+ bool,
4
+ canonicalJson,
5
+ closed,
6
+ decodeId,
7
+ integer,
8
+ list,
9
+ nullable,
10
+ oneOf,
11
+ text,
12
+ } from "./core.ts";
13
+ import { type ChoiceVersion, decodeChoiceVersion } from "./models.ts";
14
+
15
+ export type WorkbenchRead =
16
+ | { kind: "conversations" }
17
+ | { kind: "task-origin"; taskId: string }
18
+ | { kind: "conversation"; id: string; before: string | null }
19
+ | { kind: "files"; path: string }
20
+ | { kind: "file"; path: string }
21
+ | { kind: "directories"; path: string }
22
+ | { kind: "projects" };
23
+ export type WorkbenchCommand =
24
+ | {
25
+ kind: "link-task";
26
+ commandId: string;
27
+ epoch: string;
28
+ taskId: string;
29
+ conversationId: string;
30
+ }
31
+ | {
32
+ kind: "send";
33
+ commandId: string;
34
+ conversationId: string;
35
+ head: string | null;
36
+ text: string;
37
+ model: ChoiceVersion;
38
+ }
39
+ | {
40
+ kind: "interrupt";
41
+ commandId: string;
42
+ conversationId: string;
43
+ turnId: string;
44
+ epoch: string;
45
+ }
46
+ | { kind: "open-project"; commandId: string; path: string; epoch: string };
47
+ export type ChatStatus =
48
+ | "queued"
49
+ | "running"
50
+ | "completed"
51
+ | "failed"
52
+ | "canceled"
53
+ | "timeout"
54
+ | "unknown";
55
+ export interface ChatItem {
56
+ id: string;
57
+ role: "user" | "assistant" | "tool";
58
+ text: string;
59
+ name: string;
60
+ args: string;
61
+ error: boolean;
62
+ }
63
+ export interface ChatTurn {
64
+ id: string;
65
+ epoch: string;
66
+ status: ChatStatus;
67
+ items: ChatItem[];
68
+ notice: string;
69
+ }
70
+ export interface ChatSummary {
71
+ id: string;
72
+ title: string;
73
+ head: string;
74
+ status: ChatStatus;
75
+ updatedAt: number;
76
+ }
77
+ export interface FileEntry {
78
+ name: string;
79
+ path: string;
80
+ directory: boolean;
81
+ }
82
+ export type WorkbenchResult =
83
+ | { kind: "task-origin"; taskId: string; conversationId: string | null }
84
+ | { kind: "conversations"; entries: ChatSummary[] }
85
+ | {
86
+ kind: "conversation";
87
+ summary: ChatSummary | null;
88
+ turns: ChatTurn[];
89
+ before: string | null;
90
+ }
91
+ | {
92
+ kind: "files" | "directories";
93
+ path: string;
94
+ parent: string | null;
95
+ entries: FileEntry[];
96
+ limited: boolean;
97
+ }
98
+ | { kind: "file"; path: string; text: string; limited: boolean }
99
+ | { kind: "projects"; entries: { path: string; current: boolean }[] }
100
+ | { kind: "accepted"; commandId: string }
101
+ | { kind: "project"; commandId: string; path: string; link: string };
102
+ export interface WorkbenchPort {
103
+ read(request: WorkbenchRead): WorkbenchResult;
104
+ command(
105
+ request: WorkbenchCommand,
106
+ ): WorkbenchResult | Promise<WorkbenchResult>;
107
+ }
108
+ const statuses = [
109
+ "queued",
110
+ "running",
111
+ "completed",
112
+ "failed",
113
+ "canceled",
114
+ "timeout",
115
+ "unknown",
116
+ ] as const;
117
+ export function decodeWorkbenchRead(v: unknown): WorkbenchRead {
118
+ const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
119
+ switch (kind) {
120
+ case "conversations":
121
+ case "projects":
122
+ closed(v, ["kind"]);
123
+ return { kind };
124
+ case "task-origin": {
125
+ const o = closed(v, ["kind", "taskId"]);
126
+ return { kind, taskId: decodeId(o.taskId) };
127
+ }
128
+ case "conversation": {
129
+ const o = closed(v, ["kind", "id", "before"]);
130
+ return { kind, id: decodeId(o.id), before: nullable(o.before, decodeId) };
131
+ }
132
+ default: {
133
+ const o = closed(v, ["kind", "path"]);
134
+ return {
135
+ kind: oneOf(o.kind, ["files", "file", "directories"]),
136
+ path: text(o.path, 4096),
137
+ };
138
+ }
139
+ }
140
+ }
141
+ export function decodeWorkbenchCommand(v: unknown): WorkbenchCommand {
142
+ const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
143
+ if (kind === "link-task") {
144
+ const o = closed(v, [
145
+ "kind",
146
+ "commandId",
147
+ "epoch",
148
+ "taskId",
149
+ "conversationId",
150
+ ]);
151
+ return {
152
+ kind,
153
+ commandId: decodeId(o.commandId),
154
+ epoch: decodeId(o.epoch),
155
+ taskId: decodeId(o.taskId),
156
+ conversationId: decodeId(o.conversationId),
157
+ };
158
+ }
159
+ if (kind === "send") {
160
+ const o = closed(v, [
161
+ "kind",
162
+ "commandId",
163
+ "conversationId",
164
+ "head",
165
+ "text",
166
+ "model",
167
+ ]);
168
+ return {
169
+ kind,
170
+ commandId: decodeId(o.commandId),
171
+ conversationId: decodeId(o.conversationId),
172
+ head: nullable(o.head, decodeId),
173
+ text: text(o.text, 16 * 1024, true),
174
+ model: decodeChoiceVersion(o.model),
175
+ };
176
+ }
177
+ if (kind === "interrupt") {
178
+ const o = closed(v, [
179
+ "kind",
180
+ "commandId",
181
+ "conversationId",
182
+ "turnId",
183
+ "epoch",
184
+ ]);
185
+ return {
186
+ kind,
187
+ commandId: decodeId(o.commandId),
188
+ conversationId: decodeId(o.conversationId),
189
+ turnId: decodeId(o.turnId),
190
+ epoch: decodeId(o.epoch),
191
+ };
192
+ }
193
+ const o = closed(v, ["kind", "commandId", "path", "epoch"]);
194
+ return {
195
+ kind: oneOf(o.kind, ["open-project"]),
196
+ commandId: decodeId(o.commandId),
197
+ path: text(o.path, 4096, true),
198
+ epoch: decodeId(o.epoch),
199
+ };
200
+ }
201
+ export function decodeChatTurn(v: unknown): ChatTurn {
202
+ const o = closed(v, ["id", "epoch", "status", "items", "notice"]);
203
+ return {
204
+ id: decodeId(o.id),
205
+ epoch: decodeId(o.epoch),
206
+ status: oneOf(o.status, statuses),
207
+ notice: text(o.notice, 1024),
208
+ items: list(o.items, 256, (v) => {
209
+ const i = closed(v, ["id", "role", "text", "name", "args", "error"]);
210
+ return {
211
+ id: text(i.id, 160, true),
212
+ role: oneOf(i.role, ["user", "assistant", "tool"]),
213
+ text: text(i.text, 256 * 1024),
214
+ name: text(i.name, 128),
215
+ args: text(i.args, 64 * 1024),
216
+ error: bool(i.error),
217
+ };
218
+ }),
219
+ };
220
+ }
221
+ export function decodeChatSummary(v: unknown): ChatSummary {
222
+ const o = closed(v, ["id", "title", "head", "status", "updatedAt"]);
223
+ return {
224
+ id: decodeId(o.id),
225
+ title: text(o.title, 512, true),
226
+ head: decodeId(o.head),
227
+ status: oneOf(o.status, statuses),
228
+ updatedAt: integer(o.updatedAt, 0),
229
+ };
230
+ }
231
+ export function decodeWorkbenchResult(v: unknown): WorkbenchResult {
232
+ // A complete finite reply fits the existing page ceiling. No arbitrary file body.
233
+ canonicalJson(v, 512 * 1024);
234
+ const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
235
+ if (kind === "task-origin") {
236
+ const o = closed(v, ["kind", "taskId", "conversationId"]);
237
+ return {
238
+ kind,
239
+ taskId: decodeId(o.taskId),
240
+ conversationId: nullable(o.conversationId, decodeId),
241
+ };
242
+ }
243
+ if (kind === "conversations") {
244
+ const o = closed(v, ["kind", "entries"]);
245
+ return { kind, entries: list(o.entries, 200, decodeChatSummary) };
246
+ }
247
+ if (kind === "conversation") {
248
+ const o = closed(v, ["kind", "summary", "turns", "before"]);
249
+ return {
250
+ kind,
251
+ summary: nullable(o.summary, decodeChatSummary),
252
+ turns: list(o.turns, 20, decodeChatTurn),
253
+ before: nullable(o.before, decodeId),
254
+ };
255
+ }
256
+ if (kind === "files" || kind === "directories") {
257
+ const o = closed(v, ["kind", "path", "parent", "entries", "limited"]);
258
+ return {
259
+ kind,
260
+ path: text(o.path, 4096),
261
+ parent: nullable(o.parent, (p) => text(p, 4096)),
262
+ limited: bool(o.limited),
263
+ entries: list(o.entries, 200, (v) => {
264
+ const e = closed(v, ["name", "path", "directory"]);
265
+ return {
266
+ name: text(e.name, 1024, true),
267
+ path: text(e.path, 4096, true),
268
+ directory: bool(e.directory),
269
+ };
270
+ }),
271
+ };
272
+ }
273
+ if (kind === "file") {
274
+ const o = closed(v, ["kind", "path", "text", "limited"]);
275
+ return {
276
+ kind,
277
+ path: text(o.path, 4096),
278
+ text: text(o.text, 256 * 1024),
279
+ limited: bool(o.limited),
280
+ };
281
+ }
282
+ if (kind === "projects") {
283
+ const o = closed(v, ["kind", "entries"]);
284
+ return {
285
+ kind,
286
+ entries: list(o.entries, 128, (v) => {
287
+ const e = closed(v, ["path", "current"]);
288
+ return { path: text(e.path, 4096, true), current: bool(e.current) };
289
+ }),
290
+ };
291
+ }
292
+ if (kind === "accepted") {
293
+ const o = closed(v, ["kind", "commandId"]);
294
+ return { kind, commandId: decodeId(o.commandId) };
295
+ }
296
+ const o = closed(v, ["kind", "commandId", "path", "link"]);
297
+ return {
298
+ kind: oneOf(o.kind, ["project"]),
299
+ commandId: decodeId(o.commandId),
300
+ path: text(o.path, 4096, true),
301
+ link: text(o.link, 8192, true),
302
+ };
303
+ }
@@ -150,6 +150,42 @@ function names(path: string, allowed: readonly string[]): void {
150
150
  dir.closeSync();
151
151
  }
152
152
  }
153
+ interface RegistryLock {
154
+ schema: "cadence-service-registry-lock/1";
155
+ token: string;
156
+ pid: number;
157
+ host: string;
158
+ }
159
+ function decodeRegistryLock(snapshot: FileSnapshot): RegistryLock {
160
+ const o = closed(json(snapshot), ["schema", "token", "pid", "host"]);
161
+ return {
162
+ schema: literal(o.schema, "cadence-service-registry-lock/1"),
163
+ token: decodeId(o.token),
164
+ // kill(0)/negative process-group probes must never be possible.
165
+ pid: integer(o.pid, 1, 2147483647),
166
+ host: text(o.host, 255, true),
167
+ };
168
+ }
169
+ export interface RegistryLockDiagnostic {
170
+ reason: "registry-locked";
171
+ owner: "live" | "absent" | "ambiguous" | "unreadable";
172
+ }
173
+ export interface RegistryRecoveryInspection {
174
+ scope: "global-registry";
175
+ epoch: string; // exact lock incarnation, not a service epoch
176
+ coordinationDigest: string;
177
+ owner: "live" | "absent" | "ambiguous";
178
+ process: { pid: number; host: string };
179
+ reservations: number;
180
+ previousResolver: "live" | "absent" | "ambiguous" | null;
181
+ resolution: "local-interactive-only";
182
+ }
183
+ const registryNames = [
184
+ "format.json",
185
+ "roots.json",
186
+ "registry.lock",
187
+ "recovery-v1",
188
+ ];
153
189
  interface RegistryView {
154
190
  format: FileSnapshot;
155
191
  snapshot: FileSnapshot;
@@ -240,11 +276,7 @@ export class ServiceCoordination {
240
276
  }
241
277
  private registry(): RegistryView | undefined {
242
278
  if (!this.files.directory(this.registryDirectory)) return undefined;
243
- names(this.registryDirectory, [
244
- "format.json",
245
- "roots.json",
246
- "registry.lock",
247
- ]);
279
+ names(this.registryDirectory, registryNames);
248
280
  const format = this.files.read(this.registryPath("format.json"));
249
281
  const snapshot = this.files.read(
250
282
  this.registryPath("roots.json"),
@@ -283,9 +315,9 @@ export class ServiceCoordination {
283
315
  },
284
316
  };
285
317
  }
286
- assertNamespace(walks?: DirectoryWalks): void {
287
- if (!this.files.directory(this.directory, false, walks)) refused();
288
- names(this.directory, [
318
+ assertNamespace(walks?: DirectoryWalks, directory = this.directory): void {
319
+ if (!this.files.directory(directory, false, walks)) refused();
320
+ names(directory, [
289
321
  "format.json",
290
322
  "config.json",
291
323
  "registration.json",
@@ -295,19 +327,21 @@ export class ServiceCoordination {
295
327
  "access.json",
296
328
  "host-config.json",
297
329
  "history-v2",
330
+ "conversations-v1",
298
331
  ]);
299
- this.files.directory(join(this.directory, "history-v2"), false, walks);
332
+ this.files.directory(join(directory, "history-v2"), false, walks);
300
333
  }
301
- private selected(): Selected | undefined {
334
+ private selected(
335
+ directory = this.directory,
336
+ root = this.store.root,
337
+ stoppedOnly = false,
338
+ ): Selected | undefined {
302
339
  const walks = new DirectoryWalks();
303
- if (!this.files.directory(this.directory, false, walks)) return undefined;
304
- this.assertNamespace(walks);
305
- const manifest = this.files.read(
306
- this.path("format.json"),
307
- undefined,
308
- walks,
309
- );
310
- const config = this.files.read(this.path("config.json"), undefined, walks);
340
+ if (!this.files.directory(directory, false, walks)) return undefined;
341
+ this.assertNamespace(walks, directory);
342
+ const path = (name: string) => join(directory, name);
343
+ const manifest = this.files.read(path("format.json"), undefined, walks);
344
+ const config = this.files.read(path("config.json"), undefined, walks);
311
345
  if (!manifest || !config) refused();
312
346
  const f = closed(json(manifest), [
313
347
  "schema",
@@ -316,39 +350,45 @@ export class ServiceCoordination {
316
350
  "configIdentity",
317
351
  ]);
318
352
  literal(f.schema, "cadence-service-format/1");
319
- literal(f.root, this.store.root);
353
+ literal(f.root, root);
320
354
  literal(f.stateHome, this.stateHome);
321
355
  literal(f.configIdentity, config.identity);
322
356
  const c = decodeOwnerConfig(json(config));
323
357
  const hostConfig = this.files.read(
324
- this.path("host-config.json"),
358
+ path("host-config.json"),
325
359
  undefined,
326
360
  walks,
327
361
  );
328
362
  if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
329
363
  const registration = this.files.read(
330
- this.path("registration.json"),
364
+ path("registration.json"),
331
365
  undefined,
332
366
  walks,
333
367
  );
334
- const owner = this.files.read(this.path("owner.lock"), undefined, walks);
368
+ const owner = this.files.read(path("owner.lock"), undefined, walks);
335
369
  const capability = this.files.read(
336
- this.path("local-capability.json"),
370
+ path("local-capability.json"),
337
371
  undefined,
338
372
  walks,
339
373
  );
340
- const checkout = this.files.read(this.store.lockPath, 8192, walks);
374
+ const checkout = this.files.read(
375
+ join(dirname(directory), "checkout.lock"),
376
+ 8192,
377
+ walks,
378
+ );
341
379
  const value = registration
342
380
  ? decodeRegistration(json(registration))
343
381
  : undefined;
344
382
  if (
345
383
  value &&
346
- (value.root !== this.store.root ||
384
+ (value.root !== root ||
347
385
  value.stateHome !== this.stateHome ||
348
386
  value.configIdentity !== config.identity ||
349
387
  !endpointMatches(c.endpoint, value.endpoint))
350
388
  )
351
389
  refused();
390
+ if (stoppedOnly && (checkout || (value && value.phase !== "stopped")))
391
+ throw new ContractError("quarantine");
352
392
  if (value && value.phase !== "stopped") {
353
393
  if (!owner || !checkout || !capability) refused();
354
394
  const lock = closed(json(owner), [
@@ -447,21 +487,24 @@ export class ServiceCoordination {
447
487
  ) => T,
448
488
  ): T {
449
489
  this.files.directory(this.registryDirectory, true);
450
- names(this.registryDirectory, [
451
- "format.json",
452
- "roots.json",
453
- "registry.lock",
454
- ]);
490
+ names(this.registryDirectory, registryNames);
455
491
  const lockPath = this.registryPath("registry.lock");
456
- const lock = this.files.create(
457
- lockPath,
458
- encoded({
459
- schema: "cadence-service-registry-lock/1",
460
- token: randomUUID(),
461
- pid: process.pid,
462
- host: hostname(),
463
- }),
464
- );
492
+ let lock: FileSnapshot;
493
+ try {
494
+ lock = this.files.create(
495
+ lockPath,
496
+ encoded({
497
+ schema: "cadence-service-registry-lock/1",
498
+ token: randomUUID(),
499
+ pid: process.pid,
500
+ host: hostname(),
501
+ }),
502
+ );
503
+ } catch (error) {
504
+ if (error instanceof Error && "code" in error && error.code === "EEXIST")
505
+ throw new ContractError("registry-locked");
506
+ throw error;
507
+ }
465
508
  let dirty = false;
466
509
  try {
467
510
  let format = this.files.read(this.registryPath("format.json"));
@@ -498,11 +541,7 @@ export class ServiceCoordination {
498
541
  () => {
499
542
  this.files.assert(lockPath, lock);
500
543
  this.files.assert(this.registryPath("format.json"), format);
501
- names(this.registryDirectory, [
502
- "format.json",
503
- "roots.json",
504
- "registry.lock",
505
- ]);
544
+ names(this.registryDirectory, registryNames);
506
545
  },
507
546
  );
508
547
  this.files.remove(lockPath, lock);
@@ -695,6 +734,199 @@ export class ServiceCoordination {
695
734
  });
696
735
  });
697
736
  }
737
+ /** Readonly and secret-free; never probes another host or reclaims on age. */
738
+ registryLockDiagnostic(): RegistryLockDiagnostic | undefined {
739
+ try {
740
+ const lock = this.files.read(this.registryPath("registry.lock"));
741
+ if (!lock) return undefined;
742
+ const value = decodeRegistryLock(lock);
743
+ return {
744
+ reason: "registry-locked",
745
+ owner: recoveryOwnerStatus(value.pid, value.host),
746
+ };
747
+ } catch {
748
+ return { reason: "registry-locked", owner: "unreadable" };
749
+ }
750
+ }
751
+ private registryRecoveryState() {
752
+ const lock = this.files.read(this.registryPath("registry.lock"));
753
+ if (!lock) throw new ContractError("conflict");
754
+ const value = decodeRegistryLock(lock);
755
+ names(this.registryDirectory, registryNames);
756
+ const format = this.files.read(this.registryPath("format.json"));
757
+ const roots = this.files.read(
758
+ this.registryPath("roots.json"),
759
+ REGISTRY_BYTES,
760
+ );
761
+ // A crash immediately after exclusive creation may precede initialization.
762
+ const registry = !format && !roots ? undefined : this.registry();
763
+ const entries = registry?.value.entries ?? [];
764
+ const protectedRoots = new Set(entries.map((row) => hash(row.root)));
765
+ const proofs: unknown[] = [];
766
+ // Older releases removed a reservation BEFORE the checkout fence. A dead
767
+ // short-lock PID cannot make those unreserved, partially released projects
768
+ // safe. Verify every unreserved service selection; never repair it here.
769
+ const directory = opendirSync(this.files.namespace);
770
+ try {
771
+ let count = 0;
772
+ for (
773
+ let entry = directory.readSync();
774
+ entry;
775
+ entry = directory.readSync()
776
+ ) {
777
+ if (++count > 1024) throw new ContractError("capacity");
778
+ if (
779
+ !/^[a-f0-9]{64}$/.test(entry.name) ||
780
+ protectedRoots.has(entry.name)
781
+ )
782
+ continue;
783
+ const service = join(this.files.namespace, entry.name, "service-v1");
784
+ if (!this.files.directory(service)) continue;
785
+ const manifest = this.files.read(join(service, "format.json"));
786
+ if (!manifest) refused();
787
+ const f = closed(json(manifest), [
788
+ "schema",
789
+ "root",
790
+ "stateHome",
791
+ "configIdentity",
792
+ ]);
793
+ const root = pathValue(f.root);
794
+ if (hash(root) !== entry.name) refused();
795
+ // Retained stopped metadata remains checkable even when the consumer
796
+ // deleted/moved that old working directory. Never resolve/recreate it.
797
+ const selected = this.selected(service, root, true);
798
+ if (!selected) throw new ContractError("quarantine");
799
+ proofs.push([
800
+ entry.name,
801
+ ...[
802
+ selected.manifest,
803
+ selected.config,
804
+ selected.hostConfig,
805
+ selected.registration,
806
+ ].map((file) => (file ? [file.identity, file.stamp] : null)),
807
+ ]);
808
+ }
809
+ } finally {
810
+ directory.closeSync();
811
+ }
812
+ proofs.sort((a, b) =>
813
+ JSON.stringify(a).localeCompare(JSON.stringify(b), "en"),
814
+ );
815
+ const stateDigest = hash(
816
+ encoded([
817
+ lock.identity,
818
+ lock.stamp,
819
+ format ? [format.identity, format.stamp] : null,
820
+ roots ? [roots.identity, roots.stamp] : null,
821
+ proofs,
822
+ ]),
823
+ );
824
+ const epoch = hash(encoded([lock.identity, lock.stamp]));
825
+ const archive = this.registryPath(join("recovery-v1", epoch));
826
+ const attempts: FileSnapshot[] = [];
827
+ let previousResolver: RegistryRecoveryInspection["previousResolver"] = null;
828
+ if (this.files.directory(archive)) {
829
+ // Immutable, exclusive attempts serialize resolvers. If one crashes before
830
+ // release, a fresh confirmation may append the next attempt ONLY after its
831
+ // PID is absent. No stale secondary lock needs deletion or takeover.
832
+ names(
833
+ archive,
834
+ Array.from({ length: 128 }, (_, n) => `${n}.json`),
835
+ );
836
+ for (let n = 0; n < 128; n++) {
837
+ const attempt = this.files.read(join(archive, `${n}.json`), 32768);
838
+ if (!attempt) {
839
+ names(
840
+ archive,
841
+ attempts.map((_, i) => `${i}.json`),
842
+ );
843
+ break;
844
+ }
845
+ const o = closed(json(attempt, 32768), [
846
+ "schema",
847
+ "lock",
848
+ "pid",
849
+ "host",
850
+ "digest",
851
+ ]);
852
+ literal(o.schema, "cadence-registry-recovery/1");
853
+ literal(o.lock, lock.bytes.toString("utf8"));
854
+ decodeHash(o.digest);
855
+ previousResolver = recoveryOwnerStatus(
856
+ integer(o.pid, 1, 2147483647),
857
+ text(o.host, 255, true),
858
+ );
859
+ attempts.push(attempt);
860
+ }
861
+ }
862
+ const inspection: RegistryRecoveryInspection = {
863
+ scope: "global-registry",
864
+ epoch,
865
+ coordinationDigest: hash(
866
+ encoded([stateDigest, attempts.map((a) => [a.identity, a.stamp])]),
867
+ ),
868
+ owner: recoveryOwnerStatus(value.pid, value.host),
869
+ process: { pid: value.pid, host: value.host },
870
+ reservations: entries.length,
871
+ previousResolver,
872
+ resolution: "local-interactive-only",
873
+ };
874
+ return { lock, archive, attempts, stateDigest, inspection };
875
+ }
876
+ inspectRegistryRecovery(): RegistryRecoveryInspection {
877
+ return this.registryRecoveryState().inspection;
878
+ }
879
+ /** Local native management only. Retire just the proven dead short lock after
880
+ * durable evidence; reservations, owner/checkout locks and history stay intact. */
881
+ resolveRegistryRecovery(
882
+ expected: RegistryRecoveryInspection,
883
+ assertAuthority: () => void,
884
+ ) {
885
+ assertAuthority();
886
+ const state = this.registryRecoveryState();
887
+ if (encoded(state.inspection) !== encoded(expected))
888
+ throw new ContractError("conflict");
889
+ if (
890
+ expected.owner !== "absent" ||
891
+ (expected.previousResolver !== null &&
892
+ expected.previousResolver !== "absent")
893
+ )
894
+ throw new ContractError("quarantine");
895
+ if (state.attempts.length >= 128) throw new ContractError("capacity");
896
+ assertAuthority();
897
+ const receiptPath = join(state.archive, `${state.attempts.length}.json`);
898
+ const receipt = this.files.create(
899
+ receiptPath,
900
+ encoded({
901
+ schema: "cadence-registry-recovery/1",
902
+ lock: state.lock.bytes.toString("utf8"),
903
+ pid: process.pid,
904
+ host: hostname(),
905
+ digest: expected.coordinationDigest,
906
+ }),
907
+ 32768,
908
+ );
909
+ // create fsyncs/closes the original receipt before any release effect.
910
+ assertAuthority();
911
+ const current = this.registryRecoveryState();
912
+ if (
913
+ current.stateDigest !== state.stateDigest ||
914
+ current.inspection.owner !== "absent" ||
915
+ current.attempts.length !== state.attempts.length + 1
916
+ )
917
+ throw new ContractError("conflict");
918
+ for (const [n, attempt] of state.attempts.entries())
919
+ this.files.assert(join(state.archive, `${n}.json`), attempt, 32768);
920
+ this.files.assert(receiptPath, receipt, 32768);
921
+ assertAuthority();
922
+ this.files.remove(this.registryPath("registry.lock"), state.lock);
923
+ return {
924
+ status: "resolved",
925
+ scope: "global-registry",
926
+ recoveryId: expected.epoch,
927
+ notice: "registry-only-project-fences-preserved",
928
+ } as const;
929
+ }
698
930
  /** Capture exact private resources BEFORE the append/confirmation awaits. The
699
931
  * closure is internal local management authority, never a wire capability.
700
932
  * A durable bridge must be verified by the caller before EVERY release effect. */