@abelxiaoxing/cadence 2.0.14 → 2.0.16

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.
@@ -1,9 +1,11 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
- import { opendirSync, realpathSync } from "node:fs";
2
+ import { opendirSync } from "node:fs";
3
3
  import { hostname } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
5
  import {
6
6
  type CheckoutLock,
7
+ canonicalAmbientRoot,
8
+ defaultStateHome,
7
9
  resolveWorkspace,
8
10
  TaskStore,
9
11
  } from "../task-store.ts";
@@ -150,6 +152,42 @@ function names(path: string, allowed: readonly string[]): void {
150
152
  dir.closeSync();
151
153
  }
152
154
  }
155
+ interface RegistryLock {
156
+ schema: "cadence-service-registry-lock/1";
157
+ token: string;
158
+ pid: number;
159
+ host: string;
160
+ }
161
+ function decodeRegistryLock(snapshot: FileSnapshot): RegistryLock {
162
+ const o = closed(json(snapshot), ["schema", "token", "pid", "host"]);
163
+ return {
164
+ schema: literal(o.schema, "cadence-service-registry-lock/1"),
165
+ token: decodeId(o.token),
166
+ // kill(0)/negative process-group probes must never be possible.
167
+ pid: integer(o.pid, 1, 2147483647),
168
+ host: text(o.host, 255, true),
169
+ };
170
+ }
171
+ export interface RegistryLockDiagnostic {
172
+ reason: "registry-locked";
173
+ owner: "live" | "absent" | "ambiguous" | "unreadable";
174
+ }
175
+ export interface RegistryRecoveryInspection {
176
+ scope: "global-registry";
177
+ epoch: string; // exact lock incarnation, not a service epoch
178
+ coordinationDigest: string;
179
+ owner: "live" | "absent" | "ambiguous";
180
+ process: { pid: number; host: string };
181
+ reservations: number;
182
+ previousResolver: "live" | "absent" | "ambiguous" | null;
183
+ resolution: "local-interactive-only";
184
+ }
185
+ const registryNames = [
186
+ "format.json",
187
+ "roots.json",
188
+ "registry.lock",
189
+ "recovery-v1",
190
+ ];
153
191
  interface RegistryView {
154
192
  format: FileSnapshot;
155
193
  snapshot: FileSnapshot;
@@ -209,19 +247,30 @@ export type CoordinationStatus =
209
247
  * task creation, stale reclaim or accepting execution route lives here.
210
248
  */
211
249
  export class ServiceCoordination {
212
- readonly store: TaskStore;
250
+ private readonly project?: TaskStore;
213
251
  readonly files: PrivateFiles;
214
- readonly directory: string;
215
252
  readonly registryDirectory: string;
216
253
  readonly stateHome: string;
217
- constructor(workspace: string, stateHome?: string) {
218
- this.store = new TaskStore(resolveWorkspace(workspace).root, stateHome);
219
- const namespace = dirname(this.store.privateRoot);
254
+ /** null addresses only account-wide registry recovery, never an implicit cwd. */
255
+ constructor(workspace: string | null, stateHome?: string) {
256
+ this.project =
257
+ workspace === null
258
+ ? undefined
259
+ : new TaskStore(resolveWorkspace(workspace).root, stateHome);
260
+ this.stateHome = this.project
261
+ ? dirname(dirname(this.project.privateRoot))
262
+ : canonicalAmbientRoot(stateHome ?? defaultStateHome());
263
+ const namespace = join(this.stateHome, "cadence-assistant-v1");
220
264
  this.files = new PrivateFiles(namespace);
221
- this.stateHome = dirname(namespace);
222
- this.directory = join(this.store.privateRoot, "service-v1");
223
265
  this.registryDirectory = join(namespace, "service-registry-v1");
224
266
  }
267
+ get store(): TaskStore {
268
+ if (!this.project) throw new ContractError("forbidden");
269
+ return this.project;
270
+ }
271
+ get directory(): string {
272
+ return join(this.store.privateRoot, "service-v1");
273
+ }
225
274
  path(
226
275
  name:
227
276
  | "format.json"
@@ -240,11 +289,7 @@ export class ServiceCoordination {
240
289
  }
241
290
  private registry(): RegistryView | undefined {
242
291
  if (!this.files.directory(this.registryDirectory)) return undefined;
243
- names(this.registryDirectory, [
244
- "format.json",
245
- "roots.json",
246
- "registry.lock",
247
- ]);
292
+ names(this.registryDirectory, registryNames);
248
293
  const format = this.files.read(this.registryPath("format.json"));
249
294
  const snapshot = this.files.read(
250
295
  this.registryPath("roots.json"),
@@ -257,9 +302,12 @@ export class ServiceCoordination {
257
302
  const o = closed(json(snapshot, REGISTRY_BYTES), ["schema", "entries"]);
258
303
  const entries = list(o.entries, REGISTRY_ENTRIES, decodeReservation);
259
304
  for (const [i, row] of entries.entries()) {
305
+ // A missing consumer directory does not erase its reservation. Validate
306
+ // its canonical existing ancestors without recreating the missing tail;
307
+ // aliases (including dangling ones), permissions and overlaps still refuse.
260
308
  if (
261
309
  row.stateHome !== this.stateHome ||
262
- realpathSync(row.root) !== row.root
310
+ canonicalAmbientRoot(row.root) !== row.root
263
311
  )
264
312
  refused();
265
313
  // An endpoint cannot be shared by two supported owners, even for disjoint roots.
@@ -283,9 +331,9 @@ export class ServiceCoordination {
283
331
  },
284
332
  };
285
333
  }
286
- assertNamespace(walks?: DirectoryWalks): void {
287
- if (!this.files.directory(this.directory, false, walks)) refused();
288
- names(this.directory, [
334
+ assertNamespace(walks?: DirectoryWalks, directory = this.directory): void {
335
+ if (!this.files.directory(directory, false, walks)) refused();
336
+ names(directory, [
289
337
  "format.json",
290
338
  "config.json",
291
339
  "registration.json",
@@ -297,18 +345,19 @@ export class ServiceCoordination {
297
345
  "history-v2",
298
346
  "conversations-v1",
299
347
  ]);
300
- this.files.directory(join(this.directory, "history-v2"), false, walks);
348
+ this.files.directory(join(directory, "history-v2"), false, walks);
301
349
  }
302
- private selected(): Selected | undefined {
350
+ private selected(
351
+ directory = this.directory,
352
+ root = this.store.root,
353
+ stoppedOnly = false,
354
+ ): Selected | undefined {
303
355
  const walks = new DirectoryWalks();
304
- if (!this.files.directory(this.directory, false, walks)) return undefined;
305
- this.assertNamespace(walks);
306
- const manifest = this.files.read(
307
- this.path("format.json"),
308
- undefined,
309
- walks,
310
- );
311
- const config = this.files.read(this.path("config.json"), undefined, walks);
356
+ if (!this.files.directory(directory, false, walks)) return undefined;
357
+ this.assertNamespace(walks, directory);
358
+ const path = (name: string) => join(directory, name);
359
+ const manifest = this.files.read(path("format.json"), undefined, walks);
360
+ const config = this.files.read(path("config.json"), undefined, walks);
312
361
  if (!manifest || !config) refused();
313
362
  const f = closed(json(manifest), [
314
363
  "schema",
@@ -317,39 +366,45 @@ export class ServiceCoordination {
317
366
  "configIdentity",
318
367
  ]);
319
368
  literal(f.schema, "cadence-service-format/1");
320
- literal(f.root, this.store.root);
369
+ literal(f.root, root);
321
370
  literal(f.stateHome, this.stateHome);
322
371
  literal(f.configIdentity, config.identity);
323
372
  const c = decodeOwnerConfig(json(config));
324
373
  const hostConfig = this.files.read(
325
- this.path("host-config.json"),
374
+ path("host-config.json"),
326
375
  undefined,
327
376
  walks,
328
377
  );
329
378
  if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
330
379
  const registration = this.files.read(
331
- this.path("registration.json"),
380
+ path("registration.json"),
332
381
  undefined,
333
382
  walks,
334
383
  );
335
- const owner = this.files.read(this.path("owner.lock"), undefined, walks);
384
+ const owner = this.files.read(path("owner.lock"), undefined, walks);
336
385
  const capability = this.files.read(
337
- this.path("local-capability.json"),
386
+ path("local-capability.json"),
338
387
  undefined,
339
388
  walks,
340
389
  );
341
- const checkout = this.files.read(this.store.lockPath, 8192, walks);
390
+ const checkout = this.files.read(
391
+ join(dirname(directory), "checkout.lock"),
392
+ 8192,
393
+ walks,
394
+ );
342
395
  const value = registration
343
396
  ? decodeRegistration(json(registration))
344
397
  : undefined;
345
398
  if (
346
399
  value &&
347
- (value.root !== this.store.root ||
400
+ (value.root !== root ||
348
401
  value.stateHome !== this.stateHome ||
349
402
  value.configIdentity !== config.identity ||
350
403
  !endpointMatches(c.endpoint, value.endpoint))
351
404
  )
352
405
  refused();
406
+ if (stoppedOnly && (checkout || (value && value.phase !== "stopped")))
407
+ throw new ContractError("quarantine");
353
408
  if (value && value.phase !== "stopped") {
354
409
  if (!owner || !checkout || !capability) refused();
355
410
  const lock = closed(json(owner), [
@@ -448,21 +503,24 @@ export class ServiceCoordination {
448
503
  ) => T,
449
504
  ): T {
450
505
  this.files.directory(this.registryDirectory, true);
451
- names(this.registryDirectory, [
452
- "format.json",
453
- "roots.json",
454
- "registry.lock",
455
- ]);
506
+ names(this.registryDirectory, registryNames);
456
507
  const lockPath = this.registryPath("registry.lock");
457
- const lock = this.files.create(
458
- lockPath,
459
- encoded({
460
- schema: "cadence-service-registry-lock/1",
461
- token: randomUUID(),
462
- pid: process.pid,
463
- host: hostname(),
464
- }),
465
- );
508
+ let lock: FileSnapshot;
509
+ try {
510
+ lock = this.files.create(
511
+ lockPath,
512
+ encoded({
513
+ schema: "cadence-service-registry-lock/1",
514
+ token: randomUUID(),
515
+ pid: process.pid,
516
+ host: hostname(),
517
+ }),
518
+ );
519
+ } catch (error) {
520
+ if (error instanceof Error && "code" in error && error.code === "EEXIST")
521
+ throw new ContractError("registry-locked");
522
+ throw error;
523
+ }
466
524
  let dirty = false;
467
525
  try {
468
526
  let format = this.files.read(this.registryPath("format.json"));
@@ -499,11 +557,7 @@ export class ServiceCoordination {
499
557
  () => {
500
558
  this.files.assert(lockPath, lock);
501
559
  this.files.assert(this.registryPath("format.json"), format);
502
- names(this.registryDirectory, [
503
- "format.json",
504
- "roots.json",
505
- "registry.lock",
506
- ]);
560
+ names(this.registryDirectory, registryNames);
507
561
  },
508
562
  );
509
563
  this.files.remove(lockPath, lock);
@@ -523,6 +577,7 @@ export class ServiceCoordination {
523
577
  * selected host-config snapshot); only then may the identity change. */
524
578
  reselectExisting?: (hostConfig: FileSnapshot) => void,
525
579
  ): OwnerLease {
580
+ void this.store; // An account-only recovery target cannot acquire an owner.
526
581
  const config = decodeOwnerConfig(input);
527
582
  const bound = endpoint(resolvedEndpoint);
528
583
  if (!endpointMatches(config.endpoint, bound))
@@ -696,6 +751,199 @@ export class ServiceCoordination {
696
751
  });
697
752
  });
698
753
  }
754
+ /** Readonly and secret-free; never probes another host or reclaims on age. */
755
+ registryLockDiagnostic(): RegistryLockDiagnostic | undefined {
756
+ try {
757
+ const lock = this.files.read(this.registryPath("registry.lock"));
758
+ if (!lock) return undefined;
759
+ const value = decodeRegistryLock(lock);
760
+ return {
761
+ reason: "registry-locked",
762
+ owner: recoveryOwnerStatus(value.pid, value.host),
763
+ };
764
+ } catch {
765
+ return { reason: "registry-locked", owner: "unreadable" };
766
+ }
767
+ }
768
+ private registryRecoveryState() {
769
+ const lock = this.files.read(this.registryPath("registry.lock"));
770
+ if (!lock) throw new ContractError("conflict");
771
+ const value = decodeRegistryLock(lock);
772
+ names(this.registryDirectory, registryNames);
773
+ const format = this.files.read(this.registryPath("format.json"));
774
+ const roots = this.files.read(
775
+ this.registryPath("roots.json"),
776
+ REGISTRY_BYTES,
777
+ );
778
+ // A crash immediately after exclusive creation may precede initialization.
779
+ const registry = !format && !roots ? undefined : this.registry();
780
+ const entries = registry?.value.entries ?? [];
781
+ const protectedRoots = new Set(entries.map((row) => hash(row.root)));
782
+ const proofs: unknown[] = [];
783
+ // Older releases removed a reservation BEFORE the checkout fence. A dead
784
+ // short-lock PID cannot make those unreserved, partially released projects
785
+ // safe. Verify every unreserved service selection; never repair it here.
786
+ const directory = opendirSync(this.files.namespace);
787
+ try {
788
+ let count = 0;
789
+ for (
790
+ let entry = directory.readSync();
791
+ entry;
792
+ entry = directory.readSync()
793
+ ) {
794
+ if (++count > 1024) throw new ContractError("capacity");
795
+ if (
796
+ !/^[a-f0-9]{64}$/.test(entry.name) ||
797
+ protectedRoots.has(entry.name)
798
+ )
799
+ continue;
800
+ const service = join(this.files.namespace, entry.name, "service-v1");
801
+ if (!this.files.directory(service)) continue;
802
+ const manifest = this.files.read(join(service, "format.json"));
803
+ if (!manifest) refused();
804
+ const f = closed(json(manifest), [
805
+ "schema",
806
+ "root",
807
+ "stateHome",
808
+ "configIdentity",
809
+ ]);
810
+ const root = pathValue(f.root);
811
+ if (hash(root) !== entry.name) refused();
812
+ // Retained stopped metadata remains checkable even when the consumer
813
+ // deleted/moved that old working directory. Never resolve/recreate it.
814
+ const selected = this.selected(service, root, true);
815
+ if (!selected) throw new ContractError("quarantine");
816
+ proofs.push([
817
+ entry.name,
818
+ ...[
819
+ selected.manifest,
820
+ selected.config,
821
+ selected.hostConfig,
822
+ selected.registration,
823
+ ].map((file) => (file ? [file.identity, file.stamp] : null)),
824
+ ]);
825
+ }
826
+ } finally {
827
+ directory.closeSync();
828
+ }
829
+ proofs.sort((a, b) =>
830
+ JSON.stringify(a).localeCompare(JSON.stringify(b), "en"),
831
+ );
832
+ const stateDigest = hash(
833
+ encoded([
834
+ lock.identity,
835
+ lock.stamp,
836
+ format ? [format.identity, format.stamp] : null,
837
+ roots ? [roots.identity, roots.stamp] : null,
838
+ proofs,
839
+ ]),
840
+ );
841
+ const epoch = hash(encoded([lock.identity, lock.stamp]));
842
+ const archive = this.registryPath(join("recovery-v1", epoch));
843
+ const attempts: FileSnapshot[] = [];
844
+ let previousResolver: RegistryRecoveryInspection["previousResolver"] = null;
845
+ if (this.files.directory(archive)) {
846
+ // Immutable, exclusive attempts serialize resolvers. If one crashes before
847
+ // release, a fresh confirmation may append the next attempt ONLY after its
848
+ // PID is absent. No stale secondary lock needs deletion or takeover.
849
+ names(
850
+ archive,
851
+ Array.from({ length: 128 }, (_, n) => `${n}.json`),
852
+ );
853
+ for (let n = 0; n < 128; n++) {
854
+ const attempt = this.files.read(join(archive, `${n}.json`), 32768);
855
+ if (!attempt) {
856
+ names(
857
+ archive,
858
+ attempts.map((_, i) => `${i}.json`),
859
+ );
860
+ break;
861
+ }
862
+ const o = closed(json(attempt, 32768), [
863
+ "schema",
864
+ "lock",
865
+ "pid",
866
+ "host",
867
+ "digest",
868
+ ]);
869
+ literal(o.schema, "cadence-registry-recovery/1");
870
+ literal(o.lock, lock.bytes.toString("utf8"));
871
+ decodeHash(o.digest);
872
+ previousResolver = recoveryOwnerStatus(
873
+ integer(o.pid, 1, 2147483647),
874
+ text(o.host, 255, true),
875
+ );
876
+ attempts.push(attempt);
877
+ }
878
+ }
879
+ const inspection: RegistryRecoveryInspection = {
880
+ scope: "global-registry",
881
+ epoch,
882
+ coordinationDigest: hash(
883
+ encoded([stateDigest, attempts.map((a) => [a.identity, a.stamp])]),
884
+ ),
885
+ owner: recoveryOwnerStatus(value.pid, value.host),
886
+ process: { pid: value.pid, host: value.host },
887
+ reservations: entries.length,
888
+ previousResolver,
889
+ resolution: "local-interactive-only",
890
+ };
891
+ return { lock, archive, attempts, stateDigest, inspection };
892
+ }
893
+ inspectRegistryRecovery(): RegistryRecoveryInspection {
894
+ return this.registryRecoveryState().inspection;
895
+ }
896
+ /** Local native management only. Retire just the proven dead short lock after
897
+ * durable evidence; reservations, owner/checkout locks and history stay intact. */
898
+ resolveRegistryRecovery(
899
+ expected: RegistryRecoveryInspection,
900
+ assertAuthority: () => void,
901
+ ) {
902
+ assertAuthority();
903
+ const state = this.registryRecoveryState();
904
+ if (encoded(state.inspection) !== encoded(expected))
905
+ throw new ContractError("conflict");
906
+ if (
907
+ expected.owner !== "absent" ||
908
+ (expected.previousResolver !== null &&
909
+ expected.previousResolver !== "absent")
910
+ )
911
+ throw new ContractError("quarantine");
912
+ if (state.attempts.length >= 128) throw new ContractError("capacity");
913
+ assertAuthority();
914
+ const receiptPath = join(state.archive, `${state.attempts.length}.json`);
915
+ const receipt = this.files.create(
916
+ receiptPath,
917
+ encoded({
918
+ schema: "cadence-registry-recovery/1",
919
+ lock: state.lock.bytes.toString("utf8"),
920
+ pid: process.pid,
921
+ host: hostname(),
922
+ digest: expected.coordinationDigest,
923
+ }),
924
+ 32768,
925
+ );
926
+ // create fsyncs/closes the original receipt before any release effect.
927
+ assertAuthority();
928
+ const current = this.registryRecoveryState();
929
+ if (
930
+ current.stateDigest !== state.stateDigest ||
931
+ current.inspection.owner !== "absent" ||
932
+ current.attempts.length !== state.attempts.length + 1
933
+ )
934
+ throw new ContractError("conflict");
935
+ for (const [n, attempt] of state.attempts.entries())
936
+ this.files.assert(join(state.archive, `${n}.json`), attempt, 32768);
937
+ this.files.assert(receiptPath, receipt, 32768);
938
+ assertAuthority();
939
+ this.files.remove(this.registryPath("registry.lock"), state.lock);
940
+ return {
941
+ status: "resolved",
942
+ scope: "global-registry",
943
+ recoveryId: expected.epoch,
944
+ notice: "registry-only-project-fences-preserved",
945
+ } as const;
946
+ }
699
947
  /** Capture exact private resources BEFORE the append/confirmation awaits. The
700
948
  * closure is internal local management authority, never a wire capability.
701
949
  * A durable bridge must be verified by the caller before EVERY release effect. */