@amalgm/shell 0.1.142 → 0.1.144

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,8 +1,8 @@
1
1
  /** Node filesystem, immutable-cache, Git, and Verify effects for Live Apply. */
2
2
  import { createHash } from "node:crypto";
3
- import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
4
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
- import { applyRepositoryIdentity, buildEntityApplyEffects, createUserGroundEnrollmentPolicy, indexRepositoryTerritory, repositoryIdentityHash, } from "@amalgm/live";
3
+ import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { applyRepositoryIdentity, buildEntityApplyEffects, createUserGroundEnrollmentPolicy, indexRepositoryTerritory, materializedSlotNames, portableFileMode, repositoryIdentityHash, } from "@amalgm/live";
6
6
  import Database from "better-sqlite3";
7
7
  import { ensurePrivateDir, ensureUserDir } from "./filesystem.js";
8
8
  import { applyRepositoryFiles, hasGitMarker, inspectRepositoryTransportFile, markRepositoryReveal, nestedRepositoryPaths, observeRepository, repositoryObservationMatches, repositoryRevealInProgress, } from "./git-repository-host.js";
@@ -31,6 +31,7 @@ const portable = (row) => ({
31
31
  version: row.version,
32
32
  payloadVersion: row.payloadVersion,
33
33
  transportVersion: row.transportVersion,
34
+ fileMode: portableFileMode(row.type, row.filesystemMode),
34
35
  });
35
36
  function fsyncFile(path) {
36
37
  const fd = openSync(path, "r");
@@ -101,6 +102,14 @@ export class EntityApplyHost {
101
102
  #rowAtPath;
102
103
  #entityAtPath;
103
104
  #rowsUnderRoot;
105
+ #notebookUnderRoot;
106
+ #rowsByParent;
107
+ #entitiesByParent;
108
+ #relocateNotebookRow;
109
+ #relocateEntityRow;
110
+ #refreshNotebookCtime;
111
+ #refreshEntityCtime;
112
+ #onLawRelocationStage;
104
113
  #prepared = new Map();
105
114
  #groundReleases = new Map();
106
115
  /** Repository moments one intent has already read, keyed by intent then by
@@ -127,6 +136,14 @@ export class EntityApplyHost {
127
136
  this.#rowAtPath = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE absolute_path = ? AND status = 'active' LIMIT 1`);
128
137
  this.#entityAtPath = this.#database.prepare(`${groundSelect("entities")} WHERE absolute_path = ? AND status = 'active' LIMIT 1`);
129
138
  this.#rowsUnderRoot = this.#database.prepare(`${groundSelect("entities")} WHERE root_uuid = ? ORDER BY relative_path`);
139
+ this.#notebookUnderRoot = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE root_uuid = ? ORDER BY relative_path`);
140
+ this.#rowsByParent = this.#database.prepare(`${groundSelect("detection_notebook")} WHERE parent_uuid = ?`);
141
+ this.#entitiesByParent = this.#database.prepare(`${groundSelect("entities")} WHERE parent_uuid = ?`);
142
+ this.#relocateNotebookRow = this.#database.prepare("UPDATE detection_notebook SET relative_path = ?, absolute_path = ? WHERE uuid = ?");
143
+ this.#relocateEntityRow = this.#database.prepare("UPDATE entities SET relative_path = ?, absolute_path = ? WHERE uuid = ?");
144
+ this.#refreshNotebookCtime = this.#database.prepare("UPDATE detection_notebook SET changed_time_ms = ? WHERE uuid = ?");
145
+ this.#refreshEntityCtime = this.#database.prepare("UPDATE entities SET changed_time_ms = ? WHERE uuid = ?");
146
+ this.#onLawRelocationStage = options.onLawRelocationStage;
130
147
  const adapters = [
131
148
  "workspace", "folder", "repo.git", "file.text", "file.binary", "link", "reference",
132
149
  ].map((type) => this.#adapter(type));
@@ -337,6 +354,8 @@ export class EntityApplyHost {
337
354
  }
338
355
  if (current
339
356
  && exactRecord(portable(current), plan.target)
357
+ && context.beforeRelocations.length === 0
358
+ && context.afterRelocations.length === 0
340
359
  && (repositoryTargetMatches
341
360
  || await this.#matches(intent, plan.target, context.targetPath, plan, current))) {
342
361
  return { kind: "already-target" };
@@ -348,6 +367,8 @@ export class EntityApplyHost {
348
367
  // can advance the row directly. Equal bytes at a different address are
349
368
  // deliberately not identity evidence.
350
369
  if (current?.absolutePath === context.targetPath
370
+ && context.beforeRelocations.length === 0
371
+ && context.afterRelocations.length === 0
351
372
  && await this.#matches(intent, plan.target, context.targetPath, plan)) {
352
373
  return { kind: "already-target" };
353
374
  }
@@ -360,12 +381,24 @@ export class EntityApplyHost {
360
381
  if (await this.#matches(intent, plan.target, context.targetPath, plan)) {
361
382
  return { kind: "already-target" };
362
383
  }
384
+ if (context.collisionEligible
385
+ && await this.#lawRelocationsReady(intent, plan, context.beforeRelocations)) {
386
+ if (intent.phase === "declared")
387
+ this.#releaseGround(intent);
388
+ return { kind: "safe" };
389
+ }
363
390
  this.#releaseGround(intent);
364
391
  return this.#heldDestination(intent, context.targetPath);
365
392
  }
366
393
  // A move/rename may never erase a different thing already occupying the
367
394
  // accepted destination.
368
395
  if (current.absolutePath !== context.targetPath && tryLstat(context.targetPath)) {
396
+ if (context.collisionEligible
397
+ && await this.#lawRelocationsReady(intent, plan, context.beforeRelocations)) {
398
+ if (intent.phase === "declared")
399
+ this.#releaseGround(intent);
400
+ return { kind: "safe" };
401
+ }
369
402
  this.#releaseGround(intent);
370
403
  return this.#heldDestination(intent, context.targetPath);
371
404
  }
@@ -419,8 +452,10 @@ export class EntityApplyHost {
419
452
  try {
420
453
  const context = this.#context(plan.target);
421
454
  const prepared = await this.#ensurePrepared(intent, plan);
455
+ this.#relocateLawSiblings(context, context.beforeRelocations);
422
456
  if (plan.kind === "lifecycle") {
423
457
  this.#removeWithRecovery(intent, context);
458
+ this.#relocateAfterTarget(plan.target, context);
424
459
  return;
425
460
  }
426
461
  if (plan.kind === "repo.git") {
@@ -434,6 +469,7 @@ export class EntityApplyHost {
434
469
  markRepositoryReveal(context.current.absolutePath);
435
470
  }
436
471
  if (context.current && context.current.absolutePath !== context.targetPath
472
+ && !context.currentShared
437
473
  && tryLstat(context.current.absolutePath)) {
438
474
  this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
439
475
  }
@@ -443,10 +479,12 @@ export class EntityApplyHost {
443
479
  // ground, the same moment, no second capture.
444
480
  this.#observe(intent, context.targetPath, applied.identity, { stateId: applied.stateId, card: applied.card });
445
481
  this.#moveRepositoryRelocations(intent, context, repository.identity);
482
+ this.#relocateAfterTarget(plan.target, context);
446
483
  return;
447
484
  }
448
485
  if (plan.kind === "structure") {
449
- if (context.current && context.current.absolutePath !== context.targetPath) {
486
+ if (context.current && context.current.absolutePath !== context.targetPath
487
+ && !context.currentShared) {
450
488
  this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
451
489
  }
452
490
  else if (!tryLstat(context.targetPath)) {
@@ -464,6 +502,14 @@ export class EntityApplyHost {
464
502
  && hasGitMarker(context.targetPath)) {
465
503
  rmSync(join(context.targetPath, ".git"), { recursive: true, force: true });
466
504
  }
505
+ if (plan.target.type === "file.text" || plan.target.type === "file.binary") {
506
+ if (plan.target.fileMode === null) {
507
+ throw new Error(`ordinary file ${plan.target.uuid} has no portable mode`);
508
+ }
509
+ chmodSync(context.targetPath, plan.target.fileMode);
510
+ fsyncFile(context.targetPath);
511
+ }
512
+ this.#relocateAfterTarget(plan.target, context);
467
513
  return;
468
514
  }
469
515
  // Identity owns the address transition. A content or link result may
@@ -472,21 +518,25 @@ export class EntityApplyHost {
472
518
  // installing at the new address would leave the old address behind for
473
519
  // Detect to misidentify as a new local entity and echo back to cloud.
474
520
  if (context.current && context.current.absolutePath !== context.targetPath
521
+ && !context.currentShared
475
522
  && tryLstat(context.current.absolutePath)) {
476
523
  this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
477
524
  }
478
525
  if (plan.kind === "reference") {
479
526
  this.#installLink(intent, context, relative(dirname(context.targetPath), join(this.#bindingDir, plan.targetEntityId)));
527
+ this.#relocateAfterTarget(plan.target, context);
480
528
  return;
481
529
  }
482
530
  if (plan.kind === "link") {
483
531
  this.#installLink(intent, context, plan.targetText);
532
+ this.#relocateAfterTarget(plan.target, context);
484
533
  return;
485
534
  }
486
535
  const artifact = prepared.artifact;
487
536
  if (!artifact)
488
537
  throw new Error(`${plan.kind} Apply has no prepared immutable artifact`);
489
538
  this.#installArtifact(intent, context, plan.target, artifact);
539
+ this.#relocateAfterTarget(plan.target, context);
490
540
  }
491
541
  catch (error) {
492
542
  this.#releaseGround(intent);
@@ -558,6 +608,10 @@ export class EntityApplyHost {
558
608
  const addresses = new Set([context.targetPath]);
559
609
  if (context.current)
560
610
  addresses.add(context.current.absolutePath);
611
+ for (const relocation of [...context.beforeRelocations, ...context.afterRelocations]) {
612
+ addresses.add(relocation.source);
613
+ addresses.add(relocation.destination);
614
+ }
561
615
  if (plan.kind === "repo.git") {
562
616
  const repository = this.#prepared.get(this.#key(intent))?.repository;
563
617
  if (repository) {
@@ -577,6 +631,99 @@ export class EntityApplyHost {
577
631
  this.#groundReleases.delete(key);
578
632
  release();
579
633
  }
634
+ /** A collision relocation is safe only when every source still proves its
635
+ * own portable identity and every destination is either free or another
636
+ * relocation source. The final reveal then performs the whole finite chain
637
+ * while holding all source and destination cones. */
638
+ async #lawRelocationsReady(intent, plan, relocations) {
639
+ if (relocations.length === 0)
640
+ return false;
641
+ const sources = new Set(relocations.map(({ source }) => source));
642
+ for (const { row, source, destination } of relocations) {
643
+ if (tryLstat(destination) && !sources.has(destination))
644
+ return false;
645
+ if (!await this.#matches(intent, portable(row), source, plan, row))
646
+ return false;
647
+ }
648
+ return true;
649
+ }
650
+ /** Relocate a derived-name chain without ever overwriting ground. Each move
651
+ * whose destination is free commits row addresses first, then renames disk.
652
+ * A crash between those facts is repaired by Detect from the same portable
653
+ * identity; the derived suffix can never become a rename mutation. */
654
+ #relocateLawSiblings(_context, relocations) {
655
+ const pending = [...relocations];
656
+ while (pending.length > 0) {
657
+ const sources = new Set(pending.map(({ source }) => source));
658
+ const index = pending.findIndex(({ destination }) => !sources.has(destination) && !tryLstat(destination));
659
+ if (index < 0) {
660
+ throw new Error("derived address relocations have no free destination");
661
+ }
662
+ const relocation = pending.splice(index, 1)[0];
663
+ if (!relocation)
664
+ throw new Error("derived address relocation vanished");
665
+ const { row, source, destination } = relocation;
666
+ if (!tryLstat(source))
667
+ continue;
668
+ const destinationRelative = relative(this.#root(row).absolutePath, destination)
669
+ .split(sep).join("/");
670
+ const descendants = (rows) => rows.filter((candidate) => pathInside(source, candidate.absolutePath));
671
+ const entityRows = descendants(this.#rowsUnderRoot.all(row.rootUUID));
672
+ const notebookRows = descendants(this.#notebookUnderRoot.all(row.rootUUID));
673
+ this.#database.transaction(() => {
674
+ for (const candidate of entityRows) {
675
+ this.#relocateEntityRow.run(destinationRelative + candidate.relativePath.slice(row.relativePath.length), destination + candidate.absolutePath.slice(source.length), candidate.uuid);
676
+ }
677
+ for (const candidate of notebookRows) {
678
+ this.#relocateNotebookRow.run(destinationRelative + candidate.relativePath.slice(row.relativePath.length), destination + candidate.absolutePath.slice(source.length), candidate.uuid);
679
+ }
680
+ })();
681
+ this.#onLawRelocationStage?.("rows-committed", row.uuid, destination);
682
+ renameSync(source, destination);
683
+ this.#onLawRelocationStage?.("ground-moved", row.uuid, destination);
684
+ const moved = lstatSync(destination);
685
+ this.#refreshEntityCtime.run(moved.ctimeMs, row.uuid);
686
+ this.#refreshNotebookCtime.run(moved.ctimeMs, row.uuid);
687
+ }
688
+ }
689
+ /** Reveal has already moved or removed the target ground. Release its old
690
+ * SQLite address before a sibling takes that slot; final Verify will write
691
+ * the complete accepted row. The durable Apply intent owns any crash in
692
+ * this short interval. */
693
+ #relocateAfterTarget(target, context) {
694
+ if (context.afterRelocations.length === 0)
695
+ return;
696
+ const current = context.current;
697
+ if (current) {
698
+ const entityRows = this.#rowsUnderRoot.all(current.rootUUID)
699
+ .filter((row) => pathInside(current.absolutePath, row.absolutePath));
700
+ const notebookRows = this.#notebookUnderRoot.all(current.rootUUID)
701
+ .filter((row) => pathInside(current.absolutePath, row.absolutePath));
702
+ if (target.status === "active") {
703
+ const destinationRelative = relative(this.#root(current).absolutePath, context.targetPath)
704
+ .split(sep).join("/");
705
+ this.#database.transaction(() => {
706
+ for (const row of entityRows) {
707
+ this.#relocateEntityRow.run(destinationRelative + row.relativePath.slice(current.relativePath.length), context.targetPath + row.absolutePath.slice(current.absolutePath.length), row.uuid);
708
+ }
709
+ for (const row of notebookRows) {
710
+ this.#relocateNotebookRow.run(destinationRelative + row.relativePath.slice(current.relativePath.length), context.targetPath + row.absolutePath.slice(current.absolutePath.length), row.uuid);
711
+ }
712
+ })();
713
+ }
714
+ else {
715
+ const removeEntity = this.#database.prepare("DELETE FROM entities WHERE uuid = ?");
716
+ const removeNotebook = this.#database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
717
+ this.#database.transaction(() => {
718
+ for (const row of entityRows)
719
+ removeEntity.run(row.uuid);
720
+ for (const row of notebookRows)
721
+ removeNotebook.run(row.uuid);
722
+ })();
723
+ }
724
+ }
725
+ this.#relocateLawSiblings(context, context.afterRelocations);
726
+ }
580
727
  #emit(intent, plan, stage) {
581
728
  this.#onStage?.({
582
729
  entityId: intent.entityId,
@@ -661,8 +808,10 @@ export class EntityApplyHost {
661
808
  const staged = join(control, "prepared");
662
809
  rmSync(staged, { recursive: true, force: true });
663
810
  copyFileSync(artifact.file, staged);
664
- const currentMode = context.current && tryLstat(context.current.absolutePath)?.mode;
665
- chmodSync(staged, currentMode ? currentMode & 0o777 : 0o644);
811
+ if (target.fileMode === null) {
812
+ throw new Error(`ordinary file ${target.uuid} has no portable mode`);
813
+ }
814
+ chmodSync(staged, target.fileMode);
666
815
  // The completion transaction is durable; the revealed bytes must be too,
667
816
  // or a power loss leaves the notebook naming a target whose file is
668
817
  // truncated on disk, and Detect would publish that truncation as head.
@@ -729,6 +878,7 @@ export class EntityApplyHost {
729
878
  if (record.type === "file.text" || record.type === "file.binary") {
730
879
  return stats.isFile() && !stats.isSymbolicLink()
731
880
  && record.payloadVersion !== null
881
+ && record.fileMode === portableFileMode(record.type, stats.mode)
732
882
  && await this.#hashFile(path) === record.payloadVersion;
733
883
  }
734
884
  if (record.type === "link") {
@@ -790,7 +940,7 @@ export class EntityApplyHost {
790
940
  record: target,
791
941
  relativePath: target.parentUUID === null
792
942
  ? prior.relativePath
793
- : [context.parent?.relativePath, target.name].filter(Boolean).join("/"),
943
+ : [context.parent?.relativePath, context.targetName].filter(Boolean).join("/"),
794
944
  absolutePath: context.targetPath,
795
945
  deviceNumber: "dev" in evidence ? evidence.dev : evidence.deviceNumber,
796
946
  inode: "ino" in evidence ? evidence.ino : evidence.inode,
@@ -931,10 +1081,124 @@ export class EntityApplyHost {
931
1081
  if (target.parentUUID !== null && !parent) {
932
1082
  throw new Error(`entity ${target.uuid} waits for materialized parent ${target.parentUUID}`);
933
1083
  }
934
- const targetPath = parent ? join(parent.absolutePath, target.name) : current?.absolutePath;
935
- if (!targetPath)
936
- throw new Error(`root entity ${target.uuid} has no machine binding`);
937
- return { current, parent, targetPath };
1084
+ if (!parent) {
1085
+ const targetPath = current?.absolutePath;
1086
+ if (!targetPath)
1087
+ throw new Error(`root entity ${target.uuid} has no machine binding`);
1088
+ return {
1089
+ current,
1090
+ parent,
1091
+ targetPath,
1092
+ targetName: target.name,
1093
+ beforeRelocations: [],
1094
+ afterRelocations: [],
1095
+ currentShared: false,
1096
+ collisionEligible: false,
1097
+ };
1098
+ }
1099
+ const finalByParent = new Map();
1100
+ const affectedParents = new Map();
1101
+ affectedParents.set(parent.uuid, parent);
1102
+ if (current?.parentUUID && current.parentUUID !== parent.uuid) {
1103
+ const oldParent = this.#current(current.parentUUID);
1104
+ if (!oldParent)
1105
+ throw new Error(`entity ${target.uuid} lost materialized parent ${current.parentUUID}`);
1106
+ affectedParents.set(oldParent.uuid, oldParent);
1107
+ }
1108
+ for (const parentRow of affectedParents.values()) {
1109
+ finalByParent.set(parentRow.uuid, [...this.#activeSiblings(parentRow.uuid)].filter((row) => row.uuid !== target.uuid));
1110
+ }
1111
+ if (target.status === "active") {
1112
+ finalByParent.get(parent.uuid)?.push({
1113
+ ...(current ?? parent),
1114
+ uuid: target.uuid,
1115
+ type: target.type,
1116
+ parentUUID: target.parentUUID,
1117
+ name: target.name,
1118
+ status: target.status,
1119
+ });
1120
+ }
1121
+ const derivedByUuid = new Map();
1122
+ for (const siblings of finalByParent.values()) {
1123
+ for (const [uuid, name] of materializedSlotNames(siblings))
1124
+ derivedByUuid.set(uuid, name);
1125
+ }
1126
+ const collisionEligible = current === null
1127
+ || (current.parentUUID === target.parentUUID && current.name === target.name);
1128
+ const targetName = target.status === "active"
1129
+ ? collisionEligible ? derivedByUuid.get(target.uuid) ?? target.name : target.name
1130
+ : current ? basename(current.absolutePath) : target.name;
1131
+ const targetPath = target.status === "active"
1132
+ ? join(parent.absolutePath, targetName)
1133
+ : current?.absolutePath ?? join(parent.absolutePath, targetName);
1134
+ const beforeRelocations = [];
1135
+ const afterRelocations = [];
1136
+ for (const [parentUUID, siblings] of finalByParent) {
1137
+ const parentRow = affectedParents.get(parentUUID);
1138
+ if (!parentRow)
1139
+ continue;
1140
+ for (const row of siblings) {
1141
+ if (row.uuid === target.uuid)
1142
+ continue;
1143
+ if (!collisionEligible
1144
+ && row.parentUUID === target.parentUUID
1145
+ && row.name === target.name)
1146
+ continue;
1147
+ const destination = join(parentRow.absolutePath, derivedByUuid.get(row.uuid) ?? row.name);
1148
+ const source = destination === row.absolutePath && !tryLstat(destination)
1149
+ ? this.#physicalSiblingPath(parentRow.absolutePath, row)
1150
+ : row.absolutePath;
1151
+ if (source === null || source === destination)
1152
+ continue;
1153
+ const relocation = { row, source, destination };
1154
+ const leavesOldSlot = current !== null
1155
+ && row.parentUUID === current.parentUUID
1156
+ && row.name === current.name
1157
+ && (target.status !== "active"
1158
+ || target.parentUUID !== current.parentUUID
1159
+ || target.name !== current.name);
1160
+ (leavesOldSlot ? afterRelocations : beforeRelocations).push(relocation);
1161
+ }
1162
+ }
1163
+ return {
1164
+ current,
1165
+ parent,
1166
+ targetPath,
1167
+ targetName,
1168
+ beforeRelocations,
1169
+ afterRelocations,
1170
+ currentShared: current !== null
1171
+ && [...finalByParent.values()].flat()
1172
+ .some((row) => row.uuid !== current.uuid && row.absolutePath === current.absolutePath),
1173
+ // A new identity can arrive concurrently in an already-held portable
1174
+ // slot. An existing identity renaming or moving into another identity's
1175
+ // slot is not concurrency; mutation-time law forbids it and Apply keeps
1176
+ // treating that accepted anomaly as a conflict.
1177
+ collisionEligible,
1178
+ };
1179
+ }
1180
+ /** Active children across accepted and Detect truth; the notebook row wins
1181
+ * for a UUID exactly as #current does. */
1182
+ #activeSiblings(parentUUID) {
1183
+ const rows = new Map();
1184
+ for (const row of this.#entitiesByParent.all(parentUUID)) {
1185
+ rows.set(row.uuid, row);
1186
+ }
1187
+ for (const row of this.#rowsByParent.all(parentUUID)) {
1188
+ rows.set(row.uuid, row);
1189
+ }
1190
+ return [...rows.values()].filter((row) => row.status === "active");
1191
+ }
1192
+ /** Rows are committed before a derived-address rename. After a crash, the
1193
+ * row therefore names the destination while the same inode can still stand
1194
+ * at the old sibling address. Recover that source without guessing by name. */
1195
+ #physicalSiblingPath(parent, row) {
1196
+ const matches = readdirSync(parent, { withFileTypes: true }).flatMap((entry) => {
1197
+ const path = join(parent, entry.name);
1198
+ const stats = tryLstat(path);
1199
+ return stats && stats.dev === row.deviceNumber && stats.ino === row.inode ? [path] : [];
1200
+ });
1201
+ return matches.length === 1 ? matches[0] : null;
938
1202
  }
939
1203
  #current(entityId) {
940
1204
  return this.#rowByUuid.get(entityId)