@abelxiaoxing/cadence 2.0.14 → 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.
- package/README.md +22 -1
- package/docs/service-management.md +41 -3
- package/package.json +1 -1
- package/src/native-build.ts +1 -1
- package/src/service/coordination.ts +275 -44
- package/src/service-cli.mjs +259 -40
- package/src/service-host.mjs +228 -43
- package/src/worker-public.mjs +1 -0
- package/web/dist/assets/cadence.js +3 -3
- package/web/dist/manifest.json +5 -5
package/README.md
CHANGED
|
@@ -332,6 +332,11 @@ Use their current declarations rather than copying DTO parsing, reducers, a seco
|
|
|
332
332
|
Ordinary-user Git trust behavior remains unchanged.
|
|
333
333
|
System services require uid0, bypass user managers/logind entirely and require confirmed native Stop before manager stop or definition replacement; changed state-home inputs, old user units and detached running owners refuse implicit adoption.
|
|
334
334
|
Registry reservation atomically rejects canonical ancestor/descendant overlaps, including nested Git roots and aliases; a different state-home does not coordinate arbitrary other owners.
|
|
335
|
+
- The short global registry lock never expires or auto-reclaims from PID absence.
|
|
336
|
+
`recover inspect|resolve` handles a valid dead same-host lock explicitly before project recovery, with fresh local-terminal acknowledgments, exact registry/unreserved-project checks and a flushed exclusive evidence receipt.
|
|
337
|
+
Concurrent or interrupted resolvers cannot remove a successor lock; all project reservations, locks and history remain intact.
|
|
338
|
+
Unreserved partial release, unsafe/corrupt registry or receipt bytes, live/remote/ambiguous holders and changed resources stay fenced.
|
|
339
|
+
Readonly diagnosis and project Open name `registry-locked`; see [global registry recovery](docs/service-management.md#residual-global-registry-lock).
|
|
335
340
|
- Native entry requires fresh raw Enter within1500ms, trusted/idle TUI, no pending modal/messages and no child guard.
|
|
336
341
|
Browser authentication grants access, not task consent; normal mutation ingress also requires independently built compatible client/service/assets identities.
|
|
337
342
|
- Boot model choice is absent until explicitly selected.
|
|
@@ -450,6 +455,7 @@ Byte values are UTF-8/encoded bounds where specified; they are not targets to si
|
|
|
450
455
|
| Navigation | 8192 encoded ASCII bytes; encrypted plaintext6000 bytes; no execution authority |
|
|
451
456
|
| Live/export | 16 viewers,256 messages/1MiB each; two search/two export readers;120s download idle, not total archive timeout |
|
|
452
457
|
| Storage | 10GiB checkout quota;512MiB reserved per episode;256MiB free-space floor; no TTL cleanup |
|
|
458
|
+
| Registry recovery | 1024 namespace entries; 128 immutable attempts per lock incarnation; no evidence eviction |
|
|
453
459
|
| Unified workbench | 128 associated projects; 1024 durable opening receipts; 64 connections; bounded proxy frames and bodies; no eviction |
|
|
454
460
|
| Assets | 16MiB per member/64MiB closure; fixed complete manifest and all font bodies |
|
|
455
461
|
|
|
@@ -505,7 +511,7 @@ Preserve failed attempts; correct a demonstrated cause before rerunning, and nev
|
|
|
505
511
|
Regression owners:
|
|
506
512
|
|
|
507
513
|
- `service-contract`, `service-current`, `service-model-choice`, `service-auth`: closed DTOs, exact source/choice/current authority, security and compatibility.
|
|
508
|
-
- `service-owner`, `service-accepted-work`, `service-stop-continuation`, `service-recovery`: original operations, overlap, Stop, unknown fences and informed recovery.
|
|
514
|
+
- `service-owner`, `service-accepted-work`, `service-stop-continuation`, `service-recovery`, `service-registry-recovery`: original operations, overlap, Stop, unknown fences and informed recovery.
|
|
509
515
|
- `service-scheduler`, `service-checks`, `service-human-review`, `service-accepting-host`: real SDK overlap/exclusion, native checks, review and publication.
|
|
510
516
|
- `worker-*`, `lightweight-sdk`, `lightweight-process-diff`: genuine SDK/tool capture, split redaction, no-spill bounds and original native facts.
|
|
511
517
|
- `service-history-*`: durable ordering, cold/no-index/restarted proof/search/export, corruption/torn/quota and expected-byte publication.
|
|
@@ -536,6 +542,21 @@ Review the independently enumerated package member list and every emitted body,
|
|
|
536
542
|
Keep public Pi peers external for the extension, the exact Undici/ws runtime edges, the exact optional SDK pins for the standalone owner and all upstream/font/vendor notices.
|
|
537
543
|
Preserve original license bytes through their precise Git attributes; line-ending normalization must not invalidate audited source/notice hashes.
|
|
538
544
|
|
|
545
|
+
### Release 2.0.15 acceptance snapshot
|
|
546
|
+
|
|
547
|
+
The release candidate passed the complete root suite: 2034 tests in 85 files, with 27 platform skips on Linux.
|
|
548
|
+
The final browser-only frame-readiness adjustment then passed all 9 real-browser scenarios; 130 web tests and the 135-member real-pack audit also passed.
|
|
549
|
+
This is separate-lane evidence, not a claim that one final `verify` invocation was entirely green.
|
|
550
|
+
Observed environment: Linux x86_64, Node26.7.0, Bun1.3.14, Pi SDK0.84.3 and existing Chrome for Testing151.0.7922.34; root runs used `VITEST_MAX_WORKERS=1` without changing test deadlines.
|
|
551
|
+
New coverage includes actual registry-writer and resolver exits, concurrent resolvers, unchanged project fences/history, changed/linked/remote/live resources, a removed stopped workspace, piped/pasted/canceled refusal, genuine native-TTY confirmation and browser Open after explicit recovery without replay.
|
|
552
|
+
|
|
553
|
+
The first integration command was cut off by its 25-minute outer command budget while tests were still running.
|
|
554
|
+
The next completed root run failed one omitted browser-test provenance pin (updated) and three owner/proxy settlement or timeout cases, which passed separately; their timing cause was not established.
|
|
555
|
+
A later complete root/web pass encountered Chromium's first screenshot-capture failure; the browser fixture now waits for painted frames before that capture, without retrying operations or swallowing capture errors, and all browser scenarios passed afterward.
|
|
556
|
+
The last full attempt hit six history-test deadlines in two files; those unchanged files passed all 45 tests in a subsequent diagnostic run, and the earlier complete root pass remains distinct from that later failure.
|
|
557
|
+
Failed logs and isolated fixture evidence were preserved; no test budget was increased, dependency/browser downloaded, consumer lock deleted or service installed/restarted.
|
|
558
|
+
No paid-provider use or new native Windows/macOS qualification is claimed.
|
|
559
|
+
|
|
539
560
|
### Release 2.0.14 acceptance snapshot
|
|
540
561
|
|
|
541
562
|
Full `verify` passed on the release candidate before this evidence-only note, with `VITEST_MAX_WORKERS=1`: 2000 root tests in 84 files (27 skipped on Linux), 130 web tests, 9 real-browser scenarios and the 135-member real-pack audit.
|
|
@@ -258,7 +258,7 @@ At sign-out or shutdown Windows ends the owner without orderly Stop, which can l
|
|
|
258
258
|
Ctrl-C, SIGTERM, machine crash or terminal destruction is **not** the authenticated orderly Stop route and can leave recovery fences.
|
|
259
259
|
A recovered `stopped` registration means acknowledged fence release, not retroactive native closure certification.
|
|
260
260
|
|
|
261
|
-
`diagnose` reports fixed pending/recording facts and `proxyAuth: direct|validated-proxy
|
|
261
|
+
`diagnose` reports fixed pending/recording facts and `proxyAuth: direct|validated-proxy` when the owner is reachable; a blocked global registry instead reports its fixed lock reason and local recovery hint.
|
|
262
262
|
No normal status/diagnosis prints access secrets, provider credentials or outbound proxy URLs.
|
|
263
263
|
Keep the explicitly revealed token link private.
|
|
264
264
|
Browser entry redirects to a token-free URL and uses an HttpOnly cookie; that does not retract a link already copied into history, a clipboard or external logs.
|
|
@@ -300,8 +300,46 @@ cadence recover inspect --workspace /absolute/project
|
|
|
300
300
|
cadence recover resolve --workspace /absolute/project
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
Inspection is readonly.
|
|
304
|
-
|
|
303
|
+
Inspection is readonly and handles the global registry lock before project-owner recovery.
|
|
304
|
+
|
|
305
|
+
### Residual global registry lock
|
|
306
|
+
|
|
307
|
+
`status` and `diagnose` report `reason: "registry-locked"` and the lock holder observation (`live`, `absent`, `ambiguous` or `unreadable`).
|
|
308
|
+
Project Open reports `registry-locked` instead of an opaque `unavailable`; the folder picker explains the local recovery command.
|
|
309
|
+
This lock is shared by every project in the selected state home, so trying a different project does not bypass it.
|
|
310
|
+
Use the same account and `--state-home` as the owner; omit that flag only when it uses the default state home:
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
cadence recover inspect --workspace /absolute/project --state-home /absolute/state-home
|
|
314
|
+
cadence recover resolve --workspace /absolute/project --state-home /absolute/state-home
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
With a registry lock present, inspection reports `scope: "global-registry"`, the exact lock-incarnation digest, holder PID/host, reservation count and any previous resolver's liveness.
|
|
318
|
+
A project with no selected owner can be used to address this global operation; it still must be an existing suitable directory.
|
|
319
|
+
Resolve requires the same four fresh local-terminal acknowledgments described below, with the displayed lock-incarnation digest instead of a service epoch.
|
|
320
|
+
The holder must be on this host and its PID probe must specifically report absence; age, a remote host, permission denial or a reused live PID never authorizes release.
|
|
321
|
+
|
|
322
|
+
Recovery validates the registry and checks that unreserved project selections have no remaining owner/checkout fences.
|
|
323
|
+
A normally stopped project whose working directory was later removed is checked through its retained private metadata; recovery neither requires nor reconstructs that directory.
|
|
324
|
+
An interrupted reservation remains reserved even when its project initialization is incomplete: unrelated projects can reopen, but that project's uncertain resources are not repaired.
|
|
325
|
+
An older release interrupted after removing a reservation but before releasing its project locks is unsafe and remains blocked, as do corrupt/partial registry bytes, linked files and changed resources.
|
|
326
|
+
Inspection reports `blocked-preserve-state` when it cannot establish those conditions; preserve that state for manual diagnosis rather than deleting locks.
|
|
327
|
+
|
|
328
|
+
Before releasing only `registry.lock`, the resolver exclusively creates, flushes and closes a private receipt under `service-registry-v1/recovery-v1/<lock-incarnation>/<attempt>.json`, retaining the original lock bytes and acknowledged digest.
|
|
329
|
+
It then rechecks the lock incarnation, registry, unreserved project selections, receipt and terminal authority.
|
|
330
|
+
Concurrent resolvers cannot release a successor lock.
|
|
331
|
+
If a resolver dies after the receipt but before release, a new inspection and fresh terminal confirmation may append the next attempt after that resolver's same-host PID is absent; neither receipt nor lock is silently reclaimed.
|
|
332
|
+
Torn or unsafe receipts remain preserved and refused.
|
|
333
|
+
Inspection is bounded to 1024 namespace entries and 128 attempts for one lock incarnation; it never evicts evidence to make space.
|
|
334
|
+
|
|
335
|
+
All project reservations, owner locks, checkout locks, capabilities, history and unknown outcomes remain unchanged.
|
|
336
|
+
No Open/Start is replayed, no process is signaled, and no service is installed or started by recovery.
|
|
337
|
+
After success, explicitly open the desired project again.
|
|
338
|
+
If its own owner is still fenced, run `recover inspect` again and follow project-owner recovery separately; restoring global registry access does not certify or release that owner's work.
|
|
339
|
+
|
|
340
|
+
### Project-owner recovery
|
|
341
|
+
|
|
342
|
+
With no registry lock present, inspection displays the epoch, exact coordination/history digests, owner PID/host observation and independent process/credential/storage uncertainty.
|
|
305
343
|
Those digests identify preserved resources, not successful work or credential validity.
|
|
306
344
|
A journal with no unmatched intent does not prove every escaped descendant or external credential writer stopped.
|
|
307
345
|
|
package/package.json
CHANGED
package/src/native-build.ts
CHANGED
|
@@ -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(
|
|
288
|
-
names(
|
|
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",
|
|
@@ -297,18 +329,19 @@ export class ServiceCoordination {
|
|
|
297
329
|
"history-v2",
|
|
298
330
|
"conversations-v1",
|
|
299
331
|
]);
|
|
300
|
-
this.files.directory(join(
|
|
332
|
+
this.files.directory(join(directory, "history-v2"), false, walks);
|
|
301
333
|
}
|
|
302
|
-
private selected(
|
|
334
|
+
private selected(
|
|
335
|
+
directory = this.directory,
|
|
336
|
+
root = this.store.root,
|
|
337
|
+
stoppedOnly = false,
|
|
338
|
+
): Selected | undefined {
|
|
303
339
|
const walks = new DirectoryWalks();
|
|
304
|
-
if (!this.files.directory(
|
|
305
|
-
this.assertNamespace(walks);
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
walks,
|
|
310
|
-
);
|
|
311
|
-
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);
|
|
312
345
|
if (!manifest || !config) refused();
|
|
313
346
|
const f = closed(json(manifest), [
|
|
314
347
|
"schema",
|
|
@@ -317,39 +350,45 @@ export class ServiceCoordination {
|
|
|
317
350
|
"configIdentity",
|
|
318
351
|
]);
|
|
319
352
|
literal(f.schema, "cadence-service-format/1");
|
|
320
|
-
literal(f.root,
|
|
353
|
+
literal(f.root, root);
|
|
321
354
|
literal(f.stateHome, this.stateHome);
|
|
322
355
|
literal(f.configIdentity, config.identity);
|
|
323
356
|
const c = decodeOwnerConfig(json(config));
|
|
324
357
|
const hostConfig = this.files.read(
|
|
325
|
-
|
|
358
|
+
path("host-config.json"),
|
|
326
359
|
undefined,
|
|
327
360
|
walks,
|
|
328
361
|
);
|
|
329
362
|
if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
|
|
330
363
|
const registration = this.files.read(
|
|
331
|
-
|
|
364
|
+
path("registration.json"),
|
|
332
365
|
undefined,
|
|
333
366
|
walks,
|
|
334
367
|
);
|
|
335
|
-
const owner = this.files.read(
|
|
368
|
+
const owner = this.files.read(path("owner.lock"), undefined, walks);
|
|
336
369
|
const capability = this.files.read(
|
|
337
|
-
|
|
370
|
+
path("local-capability.json"),
|
|
338
371
|
undefined,
|
|
339
372
|
walks,
|
|
340
373
|
);
|
|
341
|
-
const checkout = this.files.read(
|
|
374
|
+
const checkout = this.files.read(
|
|
375
|
+
join(dirname(directory), "checkout.lock"),
|
|
376
|
+
8192,
|
|
377
|
+
walks,
|
|
378
|
+
);
|
|
342
379
|
const value = registration
|
|
343
380
|
? decodeRegistration(json(registration))
|
|
344
381
|
: undefined;
|
|
345
382
|
if (
|
|
346
383
|
value &&
|
|
347
|
-
(value.root !==
|
|
384
|
+
(value.root !== root ||
|
|
348
385
|
value.stateHome !== this.stateHome ||
|
|
349
386
|
value.configIdentity !== config.identity ||
|
|
350
387
|
!endpointMatches(c.endpoint, value.endpoint))
|
|
351
388
|
)
|
|
352
389
|
refused();
|
|
390
|
+
if (stoppedOnly && (checkout || (value && value.phase !== "stopped")))
|
|
391
|
+
throw new ContractError("quarantine");
|
|
353
392
|
if (value && value.phase !== "stopped") {
|
|
354
393
|
if (!owner || !checkout || !capability) refused();
|
|
355
394
|
const lock = closed(json(owner), [
|
|
@@ -448,21 +487,24 @@ export class ServiceCoordination {
|
|
|
448
487
|
) => T,
|
|
449
488
|
): T {
|
|
450
489
|
this.files.directory(this.registryDirectory, true);
|
|
451
|
-
names(this.registryDirectory,
|
|
452
|
-
"format.json",
|
|
453
|
-
"roots.json",
|
|
454
|
-
"registry.lock",
|
|
455
|
-
]);
|
|
490
|
+
names(this.registryDirectory, registryNames);
|
|
456
491
|
const lockPath = this.registryPath("registry.lock");
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
+
}
|
|
466
508
|
let dirty = false;
|
|
467
509
|
try {
|
|
468
510
|
let format = this.files.read(this.registryPath("format.json"));
|
|
@@ -499,11 +541,7 @@ export class ServiceCoordination {
|
|
|
499
541
|
() => {
|
|
500
542
|
this.files.assert(lockPath, lock);
|
|
501
543
|
this.files.assert(this.registryPath("format.json"), format);
|
|
502
|
-
names(this.registryDirectory,
|
|
503
|
-
"format.json",
|
|
504
|
-
"roots.json",
|
|
505
|
-
"registry.lock",
|
|
506
|
-
]);
|
|
544
|
+
names(this.registryDirectory, registryNames);
|
|
507
545
|
},
|
|
508
546
|
);
|
|
509
547
|
this.files.remove(lockPath, lock);
|
|
@@ -696,6 +734,199 @@ export class ServiceCoordination {
|
|
|
696
734
|
});
|
|
697
735
|
});
|
|
698
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
|
+
}
|
|
699
930
|
/** Capture exact private resources BEFORE the append/confirmation awaits. The
|
|
700
931
|
* closure is internal local management authority, never a wire capability.
|
|
701
932
|
* A durable bridge must be verified by the caller before EVERY release effect. */
|