agl 21.0.2 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/changelog.md +9 -0
  2. package/dist_serve/bundle.js +1 -1
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.aglhome.d.ts +25 -0
  5. package/dist_ts/classes.aglhome.js +65 -0
  6. package/dist_ts/classes.authmodels.js +7 -12
  7. package/dist_ts/classes.authstore.js +2 -29
  8. package/dist_ts/classes.cli.js +93 -35
  9. package/dist_ts/classes.config.d.ts +2 -11
  10. package/dist_ts/classes.config.js +14 -80
  11. package/dist_ts/classes.controller.d.ts +4 -1
  12. package/dist_ts/classes.controller.js +64 -26
  13. package/dist_ts/classes.embeddeddb.js +6 -5
  14. package/dist_ts/classes.gitreversion.js +3 -2
  15. package/dist_ts/classes.upgradecoordinator.d.ts +6 -1
  16. package/dist_ts/classes.upgradecoordinator.js +588 -177
  17. package/dist_ts/classes.upgradetransaction.js +46 -12
  18. package/dist_ts/classes.uploadmanager.d.ts +12 -0
  19. package/dist_ts/classes.uploadmanager.js +204 -2
  20. package/dist_ts/constants.upgradeenvironment.d.ts +2 -0
  21. package/dist_ts/constants.upgradeenvironment.js +3 -0
  22. package/dist_ts/functions.controllerdataroot.d.ts +4 -5
  23. package/dist_ts/functions.controllerdataroot.js +4 -29
  24. package/dist_ts/functions.embeddeddb.d.ts +1 -1
  25. package/dist_ts/functions.embeddeddb.js +8 -3
  26. package/dist_ts/functions.runtimeenvironment.js +2 -1
  27. package/dist_ts/index.d.ts +1 -0
  28. package/dist_ts/index.js +2 -1
  29. package/dist_ts/interfaces.config.d.ts +5 -7
  30. package/dist_ts_migration/classes.documentmigrationrunner.js +3 -1
  31. package/dist_ts_migration/index.d.ts +2 -0
  32. package/dist_ts_migration/index.js +3 -1
  33. package/dist_ts_migration/v23_aglhome.d.ts +91 -0
  34. package/dist_ts_migration/v23_aglhome.js +1775 -0
  35. package/dist_ts_migration/v23_runtimeconfig.d.ts +11 -0
  36. package/dist_ts_migration/v23_runtimeconfig.js +87 -0
  37. package/dist_ts_migration/v2_controllerdataroot.d.ts +5 -0
  38. package/dist_ts_migration/v2_controllerdataroot.js +176 -15
  39. package/package.json +1 -1
  40. package/readme.md +116 -35
  41. package/readme.plan.md +24 -9
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/classes.aglhome.ts +116 -0
  44. package/ts/classes.authmodels.ts +5 -13
  45. package/ts/classes.authstore.ts +1 -39
  46. package/ts/classes.cli.ts +92 -34
  47. package/ts/classes.config.ts +20 -117
  48. package/ts/classes.controller.ts +92 -26
  49. package/ts/classes.embeddeddb.ts +5 -4
  50. package/ts/classes.gitreversion.ts +3 -2
  51. package/ts/classes.upgradecoordinator.ts +668 -191
  52. package/ts/classes.upgradetransaction.ts +46 -11
  53. package/ts/classes.uploadmanager.ts +231 -1
  54. package/ts/constants.upgradeenvironment.ts +2 -0
  55. package/ts/functions.controllerdataroot.ts +11 -47
  56. package/ts/functions.embeddeddb.ts +13 -2
  57. package/ts/functions.runtimeenvironment.ts +1 -0
  58. package/ts/index.ts +1 -0
  59. package/ts/interfaces.config.ts +5 -7
  60. package/ts_migration/classes.documentmigrationrunner.ts +2 -0
  61. package/ts_migration/index.ts +2 -0
  62. package/ts_migration/v23_aglhome.ts +2101 -0
  63. package/ts_migration/v23_runtimeconfig.ts +112 -0
  64. package/ts_migration/v2_controllerdataroot.ts +174 -16
  65. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,112 @@
1
+ import * as plugins from '../ts/plugins.js';
2
+ import {
3
+ assertControllerAuthDocument,
4
+ ControllerAuthModel,
5
+ } from '../ts/classes.authmodels.js';
6
+ import { AuthError, type IControllerAuthDocument } from '../ts/interfaces.auth.js';
7
+
8
+ const migrationPageLimit = 128;
9
+ const assertRuntimeConfigDocument: (
10
+ valueArg: unknown,
11
+ ) => asserts valueArg is IControllerAuthDocument = assertControllerAuthDocument;
12
+
13
+ const persistedBodyFromRawDocument = (
14
+ valueArg: Record<string, unknown>,
15
+ ): Record<string, unknown> => {
16
+ const body = { ...valueArg };
17
+ delete body._id;
18
+ delete body._smartdataRevision;
19
+ return body;
20
+ };
21
+
22
+ const isAbsoluteNormalizedPath = (valueArg: unknown): valueArg is string => (
23
+ typeof valueArg === 'string'
24
+ && valueArg.length > 0
25
+ && plugins.path.isAbsolute(valueArg)
26
+ && plugins.path.normalize(valueArg) === valueArg
27
+ );
28
+
29
+ export const migrateV23RuntimeConfigDocument = (
30
+ valueArg: Record<string, unknown>,
31
+ ): { document: IControllerAuthDocument; migrated: boolean } => {
32
+ const config = valueArg.config;
33
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
34
+ throw new Error('Invalid legacy controller runtime configuration.');
35
+ }
36
+ const configRecord = config as Record<string, unknown>;
37
+ if (!Object.hasOwn(configRecord, 'workspaceDirectory')) {
38
+ assertRuntimeConfigDocument(valueArg);
39
+ return { document: valueArg, migrated: false };
40
+ }
41
+ if (!isAbsoluteNormalizedPath(configRecord.workspaceDirectory)) {
42
+ throw new Error('Legacy controller workspace is not an absolute normalized path.');
43
+ }
44
+ const projectsRoot = Object.hasOwn(configRecord, 'projectsRoot')
45
+ ? configRecord.projectsRoot
46
+ : configRecord.workspaceDirectory;
47
+ if (!isAbsoluteNormalizedPath(projectsRoot)) {
48
+ throw new Error('Legacy controller projects root is not an absolute normalized path.');
49
+ }
50
+ const migratedConfig: Record<string, unknown> = { ...configRecord, projectsRoot };
51
+ delete migratedConfig.workspaceDirectory;
52
+ const migrated: Record<string, unknown> = { ...valueArg, config: migratedConfig };
53
+ assertRuntimeConfigDocument(migrated);
54
+ return { document: migrated, migrated: true };
55
+ };
56
+
57
+ export class RuntimeConfigV23Migration {
58
+ constructor(private readonly database: plugins.smartdata.SmartdataDb | undefined) {}
59
+
60
+ public async run(): Promise<void> {
61
+ if (!this.database) {
62
+ throw new AuthError('not_initialized', 'The authentication store database is unavailable.');
63
+ }
64
+ const collection = ControllerAuthModel.collection.mongoDbCollection;
65
+ let lastId: plugins.smartdata.TStoredDocument<IControllerAuthDocument>['_id'] | undefined;
66
+ while (true) {
67
+ const cursor = collection.find(
68
+ lastId ? { _id: { $gt: lastId } } : {},
69
+ ).sort({ _id: 1 }).limit(migrationPageLimit);
70
+ let documents: Awaited<ReturnType<typeof cursor.toArray>>;
71
+ try {
72
+ documents = await cursor.toArray();
73
+ } finally {
74
+ await cursor.close();
75
+ }
76
+ for (const raw of documents) {
77
+ const migration = migrateV23RuntimeConfigDocument(
78
+ persistedBodyFromRawDocument(raw),
79
+ );
80
+ if (!migration.migrated) continue;
81
+ const selector = raw._smartdataRevision === undefined
82
+ ? { _id: raw._id, _smartdataRevision: { $exists: false } }
83
+ : { _id: raw._id, _smartdataRevision: raw._smartdataRevision };
84
+ const replaced = await collection.findOneAndReplace(
85
+ selector,
86
+ { _id: raw._id, ...migration.document, _smartdataRevision: plugins.crypto.randomUUID() },
87
+ { returnDocument: 'after', includeResultMetadata: false, upsert: false },
88
+ );
89
+ if (replaced) continue;
90
+ const concurrent = await collection.findOne({ _id: raw._id });
91
+ if (!concurrent) {
92
+ throw new AuthError(
93
+ 'concurrent_change',
94
+ 'The runtime configuration migration changed concurrently.',
95
+ );
96
+ }
97
+ const reconciled = migrateV23RuntimeConfigDocument(
98
+ persistedBodyFromRawDocument(concurrent),
99
+ );
100
+ if (reconciled.migrated) {
101
+ throw new AuthError(
102
+ 'concurrent_change',
103
+ 'The runtime configuration migration changed concurrently.',
104
+ );
105
+ }
106
+ }
107
+ const last = documents.at(-1);
108
+ if (!last || documents.length < migrationPageLimit) break;
109
+ lastId = last._id;
110
+ }
111
+ }
112
+ }
@@ -31,6 +31,7 @@ const maximumUpgradeLogs = 64;
31
31
  const maximumLockArtifacts = 64;
32
32
  const maximumLockAttempts = 200;
33
33
  const lockWaitMilliseconds = 50;
34
+ const lockGuardInitializationGraceMs = 30_000;
34
35
 
35
36
  const credentialHashPattern = /^[a-f0-9]{64}$/;
36
37
  const digestPattern = /^[a-f0-9]{64}$/;
@@ -39,6 +40,7 @@ const decimalPattern = /^(0|[1-9][0-9]*)$/;
39
40
  const controllerLogPattern = /^controller-([1-9][0-9]{0,4})\.log(?:\.old)?$/;
40
41
  const upgradeLogPattern = /^upgrade-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{6}\.[0-9]{3}Z-[A-Za-z0-9_-]{10}\.log$/;
41
42
  const embeddedDatabaseJournalTemporaryPattern = /^\.smartdb-location-migration\.json\.[1-9][0-9]*-[a-f0-9]{16}\.tmp$/;
43
+ const lockTemporarySuffixPattern = /^\.tmp-([0-9]+)-([1-9][0-9]*)-([a-f0-9]{64})$/;
42
44
 
43
45
  export type TControllerDataRootMigrationPhase =
44
46
  | 'legacy-authoritative'
@@ -116,6 +118,7 @@ export interface IControllerDataRootMigrationOptions {
116
118
  newEmbeddedSocketPath: string;
117
119
  invokerPid: number;
118
120
  listDataWriterProcesses: () => Promise<readonly IControllerDataWriterProcessRecord[]>;
121
+ preserveEmbeddedDataDirectory?: boolean;
119
122
  isSocketListening?: (socketPathArg: string) => Promise<boolean>;
120
123
  relocateCredentialStore?: (
121
124
  intentArg: Readonly<IControllerCredentialRelocationIntent>,
@@ -729,6 +732,14 @@ const closeKernelStoreConfirmed = async (
729
732
 
730
733
  const retainedEmbeddedDatabaseMigrations = new Set<EmbeddedDatabaseLocationMigration>();
731
734
  const retainedMigratedLocalSmartDbs = new Set<plugins.smartdb.LocalSmartDb>();
735
+ const retainedCredentialKernelStores = new Set<plugins.smartsecret.SmartSecretKernelStore>();
736
+
737
+ const drainRetainedCredentialKernelStores = async (): Promise<void> => {
738
+ for (const kernelStore of retainedCredentialKernelStores) {
739
+ await closeKernelStoreConfirmed(kernelStore);
740
+ retainedCredentialKernelStores.delete(kernelStore);
741
+ }
742
+ };
732
743
 
733
744
  const stopLocalSmartDbConfirmed = async (
734
745
  localDbArg: plugins.smartdb.LocalSmartDb,
@@ -829,6 +840,7 @@ const defaultRelocateCredentialStore = async (
829
840
  signalArg?: AbortSignal,
830
841
  ): Promise<void> => {
831
842
  signalArg?.throwIfAborted();
843
+ await drainRetainedCredentialKernelStores();
832
844
  let kernelStore: plugins.smartsecret.SmartSecretKernelStore | undefined;
833
845
  let sealedStore: plugins.smartsecret.SmartSecretSealedFileStore | undefined;
834
846
  let operationError: unknown;
@@ -836,6 +848,7 @@ const defaultRelocateCredentialStore = async (
836
848
  kernelStore = await plugins.smartsecret.SmartSecretKernelStore.create({
837
849
  service: intentArg.service,
838
850
  });
851
+ retainedCredentialKernelStores.add(kernelStore);
839
852
  signalArg?.throwIfAborted();
840
853
  sealedStore = await plugins.smartsecret.SmartSecretSealedFileStore.relocate({
841
854
  kernelStore,
@@ -864,6 +877,7 @@ const defaultRelocateCredentialStore = async (
864
877
  if (kernelStore) {
865
878
  try {
866
879
  await closeKernelStoreConfirmed(kernelStore);
880
+ retainedCredentialKernelStores.delete(kernelStore);
867
881
  } catch (errorArg) {
868
882
  operationError = operationError
869
883
  ? new AggregateError(
@@ -952,6 +966,56 @@ export class ControllerDataRootMigrationRunner {
952
966
  return { directoryPath: this.newRoot, createDirectory: true };
953
967
  }
954
968
 
969
+ /** Validates all authoritative v2 state without creating roots, locks, or journals. */
970
+ public async preflight(): Promise<void> {
971
+ this.options.signal?.throwIfAborted();
972
+ if (this.options.invokerPid !== process.pid) {
973
+ throw new Error('Controller data-root migration invoker PID is not the current process.');
974
+ }
975
+ if (!await this.validateCommonParent(false)) return;
976
+ const journal = await this.readJournal();
977
+ if (journal) {
978
+ if (journal.phase === 'target-committed') {
979
+ this.assertJournalRootBinding(journal);
980
+ await this.assertTargetIdentity(journal);
981
+ await this.assertTargetMarker(journal.targetCreationNonce);
982
+ return;
983
+ }
984
+ this.assertJournalMatches(journal);
985
+ await this.assertSourceIdentity(journal);
986
+ await this.inspectLegacyRootInventory(journal);
987
+ await this.validateCredentialLocations(journal, false);
988
+ if (journal.phase !== 'legacy-authoritative') {
989
+ await this.inspectTargetStagingInventory(journal);
990
+ }
991
+ return;
992
+ }
993
+ const sourceIdentity = await readDirectoryIdentity(this.oldRoot, 'Legacy controller root');
994
+ const targetIdentity = await readDirectoryIdentity(
995
+ this.newRoot,
996
+ 'Target controller root',
997
+ 0o700,
998
+ );
999
+ const targetContainsPreservedDatabase = targetIdentity
1000
+ ? await this.targetContainsOnlyPreservedDatabase()
1001
+ : false;
1002
+ if (sourceIdentity && targetIdentity && !targetContainsPreservedDatabase) {
1003
+ throw new Error('Both unjournaled controller product roots exist; refusing to choose authority.');
1004
+ }
1005
+ if (sourceIdentity) {
1006
+ await this.inspectLegacyRootInventory();
1007
+ await this.initialCredentialTuples(true);
1008
+ await this.initialDatabaseJournal();
1009
+ return;
1010
+ }
1011
+ if (targetIdentity) {
1012
+ const entries = await this.readBoundedDirectory(this.newRoot, 'Target controller root');
1013
+ if (entries.length !== 0 && !targetContainsPreservedDatabase) {
1014
+ throw new Error('An unjournaled target controller root is not empty.');
1015
+ }
1016
+ }
1017
+ }
1018
+
955
1019
  public async run(): Promise<IControllerDataRootMigrationResult> {
956
1020
  this.options.signal?.throwIfAborted();
957
1021
  if (this.options.invokerPid !== process.pid) {
@@ -1029,6 +1093,23 @@ export class ControllerDataRootMigrationRunner {
1029
1093
  requireString(value.legacyEmbeddedDataDirectory, 'Historical embedded database path'),
1030
1094
  'Historical embedded database path',
1031
1095
  );
1096
+ if (this.options.preserveEmbeddedDataDirectory === true) {
1097
+ if (historicalDirectory !== null) {
1098
+ throw new Error('An explicit embedded database override must not include a historical path.');
1099
+ }
1100
+ return {
1101
+ mode: 'override',
1102
+ configurationDigestSha256: databaseConfigurationDigest({
1103
+ mode: 'override',
1104
+ mongoDbName,
1105
+ embeddedDataDirectory,
1106
+ }),
1107
+ oldDirectory: null,
1108
+ targetDirectory: null,
1109
+ historicalDirectory: null,
1110
+ effectiveConfig: { mongoDbName, embeddedDataDirectory },
1111
+ };
1112
+ }
1032
1113
  if (embeddedDataDirectory !== oldDirectory && embeddedDataDirectory !== targetDirectory) {
1033
1114
  if (historicalDirectory !== null) {
1034
1115
  throw new Error('An explicit embedded database override must not include a historical path.');
@@ -1318,6 +1399,24 @@ export class ControllerDataRootMigrationRunner {
1318
1399
  };
1319
1400
  }
1320
1401
 
1402
+ private preservedTargetDatabasePath(): string | undefined {
1403
+ if (
1404
+ this.databaseConfiguration.mode !== 'override'
1405
+ || this.databaseConfiguration.effectiveConfig.embeddedDataDirectory
1406
+ !== plugins.path.join(this.newRoot, 'smartdb')
1407
+ ) return undefined;
1408
+ return this.databaseConfiguration.effectiveConfig.embeddedDataDirectory;
1409
+ }
1410
+
1411
+ private async targetContainsOnlyPreservedDatabase(): Promise<boolean> {
1412
+ const databasePath = this.preservedTargetDatabasePath();
1413
+ if (!databasePath) return false;
1414
+ const names = await this.readBoundedDirectory(this.newRoot, 'Target controller root');
1415
+ if (names.length !== 1 || names[0] !== 'smartdb') return false;
1416
+ await this.requirePrivateNode(databasePath, 'directory', 'Explicit target database override');
1417
+ return true;
1418
+ }
1419
+
1321
1420
  private async assertNoConflictingWriters(
1322
1421
  invokerIdentityArg: IControllerProcessIdentity,
1323
1422
  ): Promise<void> {
@@ -1349,13 +1448,16 @@ export class ControllerDataRootMigrationRunner {
1349
1448
  private async createInitialJournal(): Promise<IControllerDataRootMigrationJournal> {
1350
1449
  const sourceIdentity = await readDirectoryIdentity(this.oldRoot, 'Legacy controller root');
1351
1450
  const targetIdentity = await readDirectoryIdentity(this.newRoot, 'Target controller root', 0o700);
1352
- if (sourceIdentity && targetIdentity) {
1451
+ const targetContainsPreservedDatabase = targetIdentity
1452
+ ? await this.targetContainsOnlyPreservedDatabase()
1453
+ : false;
1454
+ if (sourceIdentity && targetIdentity && !targetContainsPreservedDatabase) {
1353
1455
  throw new Error('Both unjournaled controller product roots exist; refusing to choose authority.');
1354
1456
  }
1355
1457
  if (sourceIdentity) await this.inspectLegacyRootInventory();
1356
1458
  if (targetIdentity) {
1357
1459
  const entries = await this.readBoundedDirectory(this.newRoot, 'Target controller root');
1358
- if (entries.length !== 0) {
1460
+ if (entries.length !== 0 && !targetContainsPreservedDatabase) {
1359
1461
  throw new Error('An unjournaled target controller root is not empty.');
1360
1462
  }
1361
1463
  }
@@ -1666,10 +1768,9 @@ export class ControllerDataRootMigrationRunner {
1666
1768
  if (!targetIdentity) throw new Error('Target controller root was not created.');
1667
1769
  }
1668
1770
  const entries = await this.readBoundedDirectory(this.newRoot, 'Target controller root');
1669
- if (
1670
- entries.length > 1
1671
- || (entries.length === 1 && entries[0] !== markerFileName)
1672
- ) {
1771
+ const allowedEntries = new Set([markerFileName]);
1772
+ if (this.preservedTargetDatabasePath()) allowedEntries.add('smartdb');
1773
+ if (entries.some((entry) => !allowedEntries.has(entry))) {
1673
1774
  throw new Error('Legacy-authoritative retry found an unexpected staged target entry.');
1674
1775
  }
1675
1776
  await this.ensureTargetMarker(journalArg.targetCreationNonce);
@@ -2321,26 +2422,83 @@ export class ControllerDataRootMigrationRunner {
2321
2422
  let directoryChanged = false;
2322
2423
  for (const name of artifacts.temporaries) {
2323
2424
  const path = plugins.path.join(this.commonParent, name);
2324
- const snapshot = await this.readLockSnapshot(path);
2325
- if (!snapshot || snapshot.owner.uid !== currentUid()) {
2326
- throw new Error('Controller migration lock temporary artifact is invalid.');
2425
+ const match = lockTemporarySuffixPattern.exec(name.slice(plugins.path.basename(this.lockPath).length));
2426
+ if (!match) throw new Error(`Controller migration lock temporary name is invalid: ${name}`);
2427
+ const uid = Number(match[1]);
2428
+ const pid = Number(match[2]);
2429
+ if (!Number.isSafeInteger(uid) || uid !== currentUid() || !Number.isSafeInteger(pid)) {
2430
+ throw new Error(`Controller migration lock temporary binding is invalid: ${name}`);
2327
2431
  }
2328
- if (!await this.lockOwnerIsLive(snapshot.owner)) {
2329
- await unlinkExactPath(path, snapshot.identity);
2432
+ let stats: plugins.fs.BigIntStats;
2433
+ try {
2434
+ stats = await plugins.fs.promises.lstat(path, { bigint: true });
2435
+ } catch (errorArg) {
2436
+ if (isMissingError(errorArg)) continue;
2437
+ throw errorArg;
2438
+ }
2439
+ if (
2440
+ !stats.isFile()
2441
+ || stats.isSymbolicLink()
2442
+ || stats.uid !== BigInt(currentUid())
2443
+ || (stats.nlink !== 1n && stats.nlink !== 2n)
2444
+ || Number(stats.mode & 0o777n) !== 0o600
2445
+ || stats.size > BigInt(maximumLockBytes)
2446
+ ) throw new Error(`Controller migration lock temporary artifact is unsafe: ${path}`);
2447
+ const identity = pathIdentity(stats);
2448
+ let snapshot: ILockSnapshot | undefined;
2449
+ try {
2450
+ snapshot = await this.readLockSnapshot(path);
2451
+ } catch (errorArg) {
2452
+ if (await readControllerProcessIdentity(pid)) continue;
2453
+ await unlinkExactPath(path, identity);
2330
2454
  directoryChanged = true;
2455
+ continue;
2456
+ }
2457
+ if (!snapshot) continue;
2458
+ if (!identitiesEqual(snapshot.identity, identity)) {
2459
+ throw new Error(`Controller migration lock temporary changed during inspection: ${path}`);
2460
+ }
2461
+ if (
2462
+ snapshot.owner.uid !== uid
2463
+ || snapshot.owner.pid !== pid
2464
+ || snapshot.owner.nonce !== match[3]
2465
+ ) {
2466
+ throw new Error(`Controller migration lock temporary owner binding is invalid: ${path}`);
2331
2467
  }
2468
+ if (await this.lockOwnerIsLive(snapshot.owner)) continue;
2469
+ await unlinkExactPath(path, snapshot.identity);
2470
+ directoryChanged = true;
2332
2471
  }
2333
2472
  for (const name of artifacts.guards) {
2334
2473
  const path = plugins.path.join(this.commonParent, name);
2474
+ let stats: plugins.fs.BigIntStats;
2475
+ try {
2476
+ stats = await plugins.fs.promises.lstat(path, { bigint: true });
2477
+ } catch (errorArg) {
2478
+ if (isMissingError(errorArg)) continue;
2479
+ throw errorArg;
2480
+ }
2481
+ if (
2482
+ !stats.isFile()
2483
+ || stats.isSymbolicLink()
2484
+ || stats.uid !== BigInt(currentUid())
2485
+ || (stats.nlink !== 1n && stats.nlink !== 2n)
2486
+ || Number(stats.mode & 0o777n) !== 0o600
2487
+ || stats.size < 2n
2488
+ || stats.size > BigInt(maximumLockBytes)
2489
+ ) throw new Error(`Controller migration lock guard is unsafe: ${path}`);
2335
2490
  const guard = await this.readLockSnapshot(path);
2336
- if (!guard) throw new Error('Controller migration lock guard disappeared during inspection.');
2491
+ if (!guard) continue;
2492
+ if (!identitiesEqual(guard.identity, pathIdentity(stats))) {
2493
+ throw new Error('Controller migration lock guard changed during inspection.');
2494
+ }
2337
2495
  if (guard.owner.uid !== currentUid()) {
2338
2496
  throw new Error('Controller migration lock guard belongs to another user.');
2339
2497
  }
2340
- if (!await this.lockOwnerIsLive(guard.owner)) {
2341
- await unlinkExactPath(path, guard.identity);
2342
- directoryChanged = true;
2343
- }
2498
+ if (await this.lockOwnerIsLive(guard.owner)) continue;
2499
+ if (Date.now() - Number(stats.ctimeMs) < lockGuardInitializationGraceMs) continue;
2500
+ await unlinkExactPath(path, guard.identity);
2501
+ directoryChanged = true;
2344
2502
  }
2345
2503
  if (directoryChanged) {
2346
2504
  await syncDirectory(this.commonParent);
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: 'agl',
6
- version: '21.0.2',
6
+ version: '22.0.0',
7
7
  description: 'Agent Gateway Layer for OpenCode and FlexHarness sessions'
8
8
  }