@amalgm/shell 0.1.39 → 0.1.41

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,14 +1,14 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
3
3
  import { open } from "node:fs/promises";
4
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
6
  import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, deriveUploadManifest, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
9
  import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
- import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
11
+ import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
12
12
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
13
13
  import { inspectGitRegistration, } from "./git-registration-host.js";
14
14
  import { projectMaterializedGraph } from "./materialized-graph.js";
@@ -57,6 +57,11 @@ export class UserGroundHost {
57
57
  cloud: this.cloudPort(this.activeIdentity),
58
58
  local,
59
59
  });
60
+ await this.resumeWorkspaceAdds(this.activeIdentity);
61
+ const watchHealth = this.watchHost.health();
62
+ if (watchHealth.state !== "healthy") {
63
+ throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
64
+ }
60
65
  const evidence = {
61
66
  userId: result.userId,
62
67
  userEmail: result.userEmail,
@@ -64,9 +69,9 @@ export class UserGroundHost {
64
69
  localRoot: result.localRoot,
65
70
  cloudHead: result.cloudHead,
66
71
  mode: result.mode,
67
- entityCount: result.entityCount,
68
- watcherRoots: result.watcherRoots,
69
- watching: result.watching,
72
+ entityCount: readRows(this.databasePath(this.activeIdentity)).length,
73
+ watcherRoots: watchHealth.contentHandles,
74
+ watching: true,
70
75
  };
71
76
  this.converged = true;
72
77
  return evidence;
@@ -140,18 +145,80 @@ export class UserGroundHost {
140
145
  references: input.references,
141
146
  records: input.records,
142
147
  destinationParent: input.destinationParent,
148
+ cloudHead: input.cloudHead,
143
149
  readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
150
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
151
+ workspaceId: input.workspace.uuid,
152
+ stage,
153
+ }),
144
154
  });
145
- this.scheduleRescan();
146
155
  return installed;
147
156
  },
148
157
  coverWorkspace: async ({ workspaceId, baseline }) => {
149
- const health = this.ensureWatchers(identity, baseline === "authoritative-install" ? [workspaceId] : []);
158
+ const pending = readWorkspaceAddIntent(this.databasePath(identity), workspaceId);
159
+ const health = this.ensureWatchers(identity, pending || baseline === "authoritative-install" ? [workspaceId] : []);
160
+ assertHealthyWatch(this.watchHost.evidence(), workspaceId);
161
+ if (pending) {
162
+ await this.options.onWorkspaceAddStage?.({ workspaceId, stage: "watching" });
163
+ deleteWorkspaceAddIntent(this.databasePath(identity), workspaceId);
164
+ }
150
165
  return { active: true, roots: health.contentHandles };
151
166
  },
152
167
  };
153
168
  return { register, add };
154
169
  }
170
+ /** Resume the same host effect used by the SDK command before readiness.
171
+ * Staged ground has no binding, so recovery cannot race Watch or Detect. */
172
+ async resumeWorkspaceAdds(identity) {
173
+ const database = this.databasePath(identity);
174
+ const userRoot = this.userRoot(identity);
175
+ const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
176
+ for (const intent of readWorkspaceAddIntents(database)) {
177
+ const selection = workspaceAddSelection(intent);
178
+ const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
179
+ const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
180
+ if (stagedAcrossBlindInterval && readRows(database).some((row) => row.resourceId === intent.resourceId && row.rootUUID === intent.workspaceId)) {
181
+ // Rows prove that staging was verified before the crash, not that its
182
+ // bytes remained unchanged while no watcher existed. Return this
183
+ // private install to the intent-only stage and reproduce it from the
184
+ // verified immutable cache before making it visible.
185
+ deleteRootRows(database, intent.resourceId, intent.workspaceId);
186
+ }
187
+ await installCloudWorkspace({
188
+ identity,
189
+ userRoot,
190
+ database,
191
+ cacheDir: this.cacheDir(identity),
192
+ bindingDir,
193
+ resourceId: intent.resourceId,
194
+ workspace: selection.workspace,
195
+ reference: selection.reference,
196
+ references: intent.references,
197
+ records: intent.records,
198
+ destinationParent: dirname(intent.destinationPath),
199
+ cloudHead: intent.cloudHead,
200
+ readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
201
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
202
+ workspaceId: intent.workspaceId,
203
+ stage,
204
+ }),
205
+ });
206
+ this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
207
+ assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
208
+ if (visibleAcrossBlindInterval) {
209
+ // Ground that was visible while the runtime was absent may have been
210
+ // edited after its verified reveal. It is an ordinary startup blind
211
+ // interval, so Watch must retain complete suspicion and Detect must
212
+ // settle it before the Add responsibility can be cleared.
213
+ await this.flushObservedChanges();
214
+ }
215
+ await this.options.onWorkspaceAddStage?.({
216
+ workspaceId: intent.workspaceId,
217
+ stage: "watching",
218
+ });
219
+ deleteWorkspaceAddIntent(database, intent.workspaceId);
220
+ }
221
+ }
155
222
  /** Finish the user-visible register journey inside the one persistent Files
156
223
  * owner. The declaration remains durable if any later stage fails, and a
157
224
  * retry reuses its UUID through filesCommandPorts().register. */
@@ -179,7 +246,9 @@ export class UserGroundHost {
179
246
  watch,
180
247
  };
181
248
  }
182
- this.watchHost.suspectAll();
249
+ // Reconcile already made this newly declared root suspicious. Consume
250
+ // that evidence as-is: registration of one workspace must never widen
251
+ // uncertainty to every other root on the machine.
183
252
  this.watchDirty = false;
184
253
  try {
185
254
  await this.syncNow();
@@ -188,7 +257,6 @@ export class UserGroundHost {
188
257
  // declaration. Preserve the declared root as suspicion and give it
189
258
  // one scan of its own. Completion is evidence about this workspace,
190
259
  // never a demand that unrelated live Watch traffic become silent.
191
- this.watchHost.suspectAll();
192
260
  this.watchDirty = false;
193
261
  await this.syncNow();
194
262
  }
@@ -606,22 +674,6 @@ export class UserGroundHost {
606
674
  materialized: true,
607
675
  });
608
676
  }
609
- for (const row of readRows(this.databasePath(identity))) {
610
- if (row.parentUUID !== null || row.absolutePath === root)
611
- continue;
612
- const directory = directoryExists(row.absolutePath)
613
- ? realpathSync(row.absolutePath)
614
- : resolve(row.absolutePath);
615
- const prior = wanted.get(directory);
616
- if (prior && prior.rootId !== row.rootUUID) {
617
- throw new Error(`two logical roots claim the same Watch ground: ${directory}`);
618
- }
619
- wanted.set(directory, {
620
- rootId: row.rootUUID,
621
- directory,
622
- materialized: directoryExists(directory),
623
- });
624
- }
625
677
  const health = this.watchHost.reconcile([...wanted.values()], {
626
678
  authoritativeBaselineRootIds,
627
679
  });
@@ -895,6 +947,11 @@ function initializeDatabase(file) {
895
947
  snapshot_json TEXT NOT NULL,
896
948
  created_at TEXT NOT NULL
897
949
  );
950
+ CREATE TABLE IF NOT EXISTS workspace_add_intents (
951
+ workspace_uuid TEXT PRIMARY KEY,
952
+ destination_path TEXT NOT NULL UNIQUE,
953
+ intent_json TEXT NOT NULL
954
+ );
898
955
  CREATE INDEX IF NOT EXISTS entities_by_absolute_path
899
956
  ON entities(absolute_path);
900
957
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
@@ -936,6 +993,112 @@ function readRows(file) {
936
993
  database.close();
937
994
  }
938
995
  }
996
+ function workspaceAddStagingPath(destinationPath, workspaceId) {
997
+ return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
998
+ }
999
+ function workspaceAddSelection(intent) {
1000
+ const workspace = intent.records.find((record) => record.uuid === intent.workspaceId);
1001
+ if (!workspace || !["workspace", "folder", "repo.git"].includes(workspace.type)) {
1002
+ throw new Error(`workspace Add intent ${intent.workspaceId} has no materialization boundary`);
1003
+ }
1004
+ const references = intent.references.filter((record) => record.type === "reference"
1005
+ && record.status === "active"
1006
+ && record.payloadVersion === intent.workspaceId);
1007
+ if (references.length !== 1) {
1008
+ throw new Error(`workspace Add intent ${intent.workspaceId} has no unique durable reference`);
1009
+ }
1010
+ return { workspace, reference: references[0] };
1011
+ }
1012
+ function parseWorkspaceAddIntent(json) {
1013
+ const value = JSON.parse(json);
1014
+ const workspaceId = String(value.workspaceId || "").toLowerCase();
1015
+ const resourceId = String(value.resourceId || "");
1016
+ const cloudHead = String(value.cloudHead || "");
1017
+ const destinationPath = String(value.destinationPath || "");
1018
+ const stagingPath = String(value.stagingPath || "");
1019
+ if (!UUID.test(workspaceId) || !resourceId || !/^[0-9a-f]{64}$/i.test(cloudHead)) {
1020
+ throw new Error("workspace Add intent identity is invalid");
1021
+ }
1022
+ if (!isAbsolute(destinationPath)
1023
+ || stagingPath !== workspaceAddStagingPath(destinationPath, workspaceId)) {
1024
+ throw new Error(`workspace Add intent ${workspaceId} has invalid placement`);
1025
+ }
1026
+ const intent = {
1027
+ workspaceId,
1028
+ resourceId,
1029
+ cloudHead: cloudHead.toLowerCase(),
1030
+ destinationPath,
1031
+ stagingPath,
1032
+ records: snapshotFromRecords(Array.isArray(value.records) ? value.records : []).records,
1033
+ references: snapshotFromRecords(Array.isArray(value.references) ? value.references : []).records,
1034
+ };
1035
+ const { workspace } = workspaceAddSelection(intent);
1036
+ if (basename(destinationPath) !== workspace.name) {
1037
+ throw new Error(`workspace Add intent ${workspaceId} destination differs from its graph`);
1038
+ }
1039
+ return intent;
1040
+ }
1041
+ function readWorkspaceAddIntent(file, workspaceId) {
1042
+ if (!existsSync(file))
1043
+ return null;
1044
+ const database = initializeDatabase(file);
1045
+ try {
1046
+ const row = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(workspaceId);
1047
+ return row ? parseWorkspaceAddIntent(row.intentJson) : null;
1048
+ }
1049
+ finally {
1050
+ database.close();
1051
+ }
1052
+ }
1053
+ function readWorkspaceAddIntents(file) {
1054
+ if (!existsSync(file))
1055
+ return [];
1056
+ const database = initializeDatabase(file);
1057
+ try {
1058
+ return database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents ORDER BY workspace_uuid").all().map(({ intentJson }) => parseWorkspaceAddIntent(intentJson));
1059
+ }
1060
+ finally {
1061
+ database.close();
1062
+ }
1063
+ }
1064
+ function persistWorkspaceAddIntent(file, proposed) {
1065
+ const database = initializeDatabase(file);
1066
+ try {
1067
+ let inserted;
1068
+ try {
1069
+ inserted = database.prepare(`
1070
+ INSERT INTO workspace_add_intents(workspace_uuid, destination_path, intent_json)
1071
+ VALUES (?, ?, ?)
1072
+ ON CONFLICT(workspace_uuid) DO NOTHING
1073
+ `).run(proposed.workspaceId, proposed.destinationPath, stableJson(proposed));
1074
+ }
1075
+ catch (error) {
1076
+ const destinationOwner = database.prepare("SELECT workspace_uuid AS workspaceId FROM workspace_add_intents WHERE destination_path = ?").get(proposed.destinationPath);
1077
+ if (destinationOwner) {
1078
+ throw new Error(`workspace Add destination belongs to pending workspace ${destinationOwner.workspaceId}`);
1079
+ }
1080
+ throw error;
1081
+ }
1082
+ if (inserted.changes === 1)
1083
+ return { intent: proposed, created: true };
1084
+ const existing = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(proposed.workspaceId);
1085
+ if (!existing)
1086
+ throw new Error(`workspace Add intent ${proposed.workspaceId} was not persisted`);
1087
+ return { intent: parseWorkspaceAddIntent(existing.intentJson), created: false };
1088
+ }
1089
+ finally {
1090
+ database.close();
1091
+ }
1092
+ }
1093
+ function deleteWorkspaceAddIntent(file, workspaceId) {
1094
+ const database = initializeDatabase(file);
1095
+ try {
1096
+ database.prepare("DELETE FROM workspace_add_intents WHERE workspace_uuid = ?").run(workspaceId);
1097
+ }
1098
+ finally {
1099
+ database.close();
1100
+ }
1101
+ }
939
1102
  /** Register identity lookup is indexed and bounded: the command never walks
940
1103
  * the complete entity table merely to discover that a directory is known. */
941
1104
  function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
@@ -1063,6 +1226,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1063
1226
  database.close();
1064
1227
  }
1065
1228
  }
1229
+ /** A same-filesystem rename preserves identity and bytes but may advance the
1230
+ * root directory's ctime. Refresh that one derived fingerprint before the
1231
+ * binding makes the committed graph observable. */
1232
+ function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
1233
+ const stats = statFingerprint(lstatSync(absolutePath));
1234
+ const database = initializeDatabase(file);
1235
+ try {
1236
+ const result = database.prepare(`
1237
+ UPDATE entities SET
1238
+ device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
1239
+ changed_time_ms = ?, filesystem_mode = ?
1240
+ WHERE uuid = ? AND absolute_path = ?
1241
+ `).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
1242
+ if (result.changes !== 1) {
1243
+ throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
1244
+ }
1245
+ }
1246
+ finally {
1247
+ database.close();
1248
+ }
1249
+ }
1066
1250
  function assertDatabaseIdentity(file, identity) {
1067
1251
  const database = new Database(file, { readonly: true });
1068
1252
  try {
@@ -1751,6 +1935,7 @@ function installCloudGround(input) {
1751
1935
  async function materializeRecords(input) {
1752
1936
  const { records, rootPath, cacheDir, bindingDir, readContent } = input;
1753
1937
  const repositories = [];
1938
+ const repositoriesByUuid = new Map();
1754
1939
  const fileArtifacts = new Map();
1755
1940
  for (const record of records) {
1756
1941
  if (record.type !== "file.text" && record.type !== "file.binary" && record.type !== "link")
@@ -1795,9 +1980,42 @@ async function materializeRecords(input) {
1795
1980
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
1796
1981
  }
1797
1982
  const applied = await applyRepositoryFiles(destination, chain);
1798
- repositories.push({ record, state: applied });
1983
+ const repository = { record, state: applied };
1984
+ repositories.push(repository);
1985
+ repositoriesByUuid.set(record.uuid, repository);
1799
1986
  };
1800
1987
  const paths = portablePaths(records, input.boundaryUUID ? new Set([input.boundaryUUID]) : new Set());
1988
+ const recordsByUuid = new Map(records.map((record) => [record.uuid, record]));
1989
+ const repositoryOwner = (record) => {
1990
+ let parentUUID = record.parentUUID;
1991
+ while (parentUUID !== null) {
1992
+ const repository = repositoriesByUuid.get(parentUUID);
1993
+ if (repository)
1994
+ return repository;
1995
+ const parent = recordsByUuid.get(parentUUID);
1996
+ if (!parent)
1997
+ throw new Error(`cloud entity ${record.uuid} has no parent`);
1998
+ parentUUID = parent.parentUUID;
1999
+ }
2000
+ return null;
2001
+ };
2002
+ const assertRepositoryBoundaryPath = (record, relativePath, destination) => {
2003
+ const owner = repositoryOwner(record);
2004
+ if (!owner)
2005
+ throw new Error(`cloud materialization conflicts with ${relativePath}`);
2006
+ const ownerPath = paths.get(owner.record.uuid);
2007
+ const repositoryPath = ownerPath
2008
+ ? relativePath.slice(ownerPath.length + 1)
2009
+ : relativePath;
2010
+ const identity = owner.state.identity.find((entry) => entry.path === repositoryPath);
2011
+ if (!identity || identity.uuid !== record.uuid || identity.type !== record.type) {
2012
+ throw new Error(`repository boundary spine conflicts with ${relativePath}`);
2013
+ }
2014
+ const stats = lstatSync(destination);
2015
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
2016
+ throw new Error(`repository boundary path is not a directory: ${relativePath}`);
2017
+ }
2018
+ };
1801
2019
  const ordered = [...records].sort((left, right) => {
1802
2020
  const leftPath = paths.get(left.uuid);
1803
2021
  const rightPath = paths.get(right.uuid);
@@ -1817,13 +2035,17 @@ async function materializeRecords(input) {
1817
2035
  ensurePrivateDir(destination);
1818
2036
  }
1819
2037
  else if (record.type === "folder") {
1820
- if (pathExists(destination))
1821
- throw new Error(`cloud materialization conflicts with ${relativePath}`);
1822
- ensurePrivateDir(destination);
2038
+ if (pathExists(destination)) {
2039
+ assertRepositoryBoundaryPath(record, relativePath, destination);
2040
+ }
2041
+ else {
2042
+ ensurePrivateDir(destination);
2043
+ }
1823
2044
  }
1824
2045
  else if (record.type === "repo.git") {
1825
- if (pathExists(destination))
1826
- throw new Error(`cloud materialization conflicts with ${relativePath}`);
2046
+ if (pathExists(destination)) {
2047
+ assertRepositoryBoundaryPath(record, relativePath, destination);
2048
+ }
1827
2049
  await materializeRepository(record, destination);
1828
2050
  }
1829
2051
  else if (record.type === "reference") {
@@ -1973,94 +2195,171 @@ function installCloudWorkspaceReferences(input) {
1973
2195
  return references;
1974
2196
  }
1975
2197
  async function installCloudWorkspace(input) {
1976
- const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, readContent, } = input;
2198
+ const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, cloudHead, readContent, onStage, } = input;
1977
2199
  const allLocalRows = readRows(database);
1978
2200
  const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
1979
2201
  const existing = existingBoundary
1980
2202
  ? descendantRows(allLocalRows, workspace.uuid)
1981
2203
  : [];
2204
+ const pending = readWorkspaceAddIntent(database, workspace.uuid);
1982
2205
  if (existing.length > 0) {
1983
2206
  const root = existing.find((row) => row.uuid === workspace.uuid);
1984
- if (!root || !pathExists(root.absolutePath)) {
2207
+ if (!root) {
1985
2208
  throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
1986
2209
  }
2210
+ const expectedRecords = pending?.records ?? records;
1987
2211
  const localRecords = existing.map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
1988
- if (!sameRecords(travelingRecords(localRecords), records)) {
2212
+ if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
1989
2213
  throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
1990
2214
  }
2215
+ if (pending && root.absolutePath !== pending.destinationPath) {
2216
+ throw new Error(`workspace ${workspace.uuid} rows differ from its pending destination`);
2217
+ }
2218
+ let revealed = false;
2219
+ if (!pathExists(root.absolutePath)) {
2220
+ if (!pending || !pathExists(pending.stagingPath)) {
2221
+ throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
2222
+ }
2223
+ renameSync(pending.stagingPath, pending.destinationPath);
2224
+ revealed = true;
2225
+ }
2226
+ else if (pending && pathExists(pending.stagingPath)) {
2227
+ throw new Error(`workspace ${workspace.uuid} has both staged and revealed ground`);
2228
+ }
2229
+ if (pending && pathExists(root.absolutePath)) {
2230
+ refreshRevealedRootEvidence(database, workspace.uuid, root.absolutePath);
2231
+ }
2232
+ if (revealed)
2233
+ await onStage?.("ground-revealed");
2234
+ ensureWorkspaceBinding({
2235
+ bindingDir,
2236
+ workspaceId: workspace.uuid,
2237
+ absolutePath: root.absolutePath,
2238
+ });
2239
+ if (pending)
2240
+ await onStage?.("binding-created");
2241
+ const activeReferences = pending?.references ?? references;
2242
+ const activeSelection = pending ? workspaceAddSelection(pending) : { workspace, reference };
1991
2243
  const installedReferences = installCloudWorkspaceReferences({
1992
2244
  identity,
1993
2245
  userRoot,
1994
2246
  database,
1995
2247
  bindingDir,
1996
2248
  resourceId,
1997
- workspace,
1998
- references,
2249
+ workspace: activeSelection.workspace,
2250
+ references: activeReferences,
1999
2251
  });
2000
2252
  return {
2001
2253
  path: root.absolutePath,
2002
2254
  rows: existing.length,
2003
2255
  records: localRecords,
2004
- reference,
2256
+ reference: activeSelection.reference,
2005
2257
  references: installedReferences,
2006
- alreadyMaterialized: true,
2258
+ alreadyMaterialized: pending === null,
2007
2259
  };
2008
2260
  }
2009
2261
  const parent = realpathSync(resolve(destinationParent));
2010
2262
  if (!statSync(parent).isDirectory())
2011
2263
  throw new Error("destination must be a directory");
2012
2264
  const destination = join(parent, workspace.name);
2265
+ if (dirname(destination) !== parent || basename(destination) !== workspace.name) {
2266
+ throw new Error("workspace name must be one path segment");
2267
+ }
2013
2268
  const canonicalUserRoot = realpathSync(userRoot);
2014
2269
  if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
2015
2270
  throw new Error("files add destination must be outside the Amalgm user ground");
2016
2271
  }
2017
- if (pathExists(destination))
2018
- throw new Error(`workspace destination already exists: ${destination}`);
2019
2272
  ensurePrivateDir(bindingDir);
2020
2273
  const binding = join(bindingDir, workspace.uuid);
2021
- if (pathExists(binding))
2022
- throw new Error(`workspace ${workspace.uuid} already has a local binding`);
2023
- try {
2024
- const materialized = await materializeRecords({
2025
- records,
2026
- rootPath: destination,
2027
- boundaryUUID: workspace.uuid,
2028
- cacheDir,
2029
- bindingDir,
2030
- readContent,
2031
- });
2032
- const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
2033
- record: repository.record,
2034
- identity: repository.state.identity,
2035
- })), sha256Hex);
2036
- symlinkSync(destination, binding, "dir");
2037
- persistRows(database, identity, resourceId, workspace.uuid, rows);
2038
- const installedReferences = installCloudWorkspaceReferences({
2039
- identity,
2040
- userRoot,
2041
- database,
2042
- bindingDir,
2274
+ let intent = pending;
2275
+ if (!intent) {
2276
+ const stagingPath = workspaceAddStagingPath(destination, workspace.uuid);
2277
+ if (pathExists(destination))
2278
+ throw new Error(`workspace destination already exists: ${destination}`);
2279
+ if (pathExists(stagingPath))
2280
+ throw new Error(`workspace staging path already exists: ${stagingPath}`);
2281
+ if (pathExists(binding))
2282
+ throw new Error(`workspace ${workspace.uuid} already has a local binding`);
2283
+ for (const bound of materializedWorkspaceBindings(bindingDir)) {
2284
+ if (pathWithin(bound.directory, destination) || pathWithin(destination, bound.directory)) {
2285
+ throw new Error(`files add destination overlaps materialized workspace ${bound.workspaceId}`);
2286
+ }
2287
+ }
2288
+ const written = persistWorkspaceAddIntent(database, {
2289
+ workspaceId: workspace.uuid,
2043
2290
  resourceId,
2044
- workspace,
2045
- references,
2291
+ cloudHead,
2292
+ destinationPath: destination,
2293
+ stagingPath,
2294
+ records: snapshotFromRecords(records).records,
2295
+ references: snapshotFromRecords(references).records,
2046
2296
  });
2047
- return {
2048
- path: destination,
2049
- rows: rows.length,
2050
- records: rows.map((row) => row.record),
2051
- reference,
2052
- references: installedReferences,
2053
- alreadyMaterialized: false,
2054
- };
2297
+ intent = written.intent;
2298
+ if (written.created)
2299
+ await onStage?.("intent-recorded");
2055
2300
  }
2056
- catch (error) {
2057
- deleteRootRows(database, resourceId, workspace.uuid);
2058
- if (pathExists(binding))
2059
- rmSync(binding, { force: true });
2060
- if (pathExists(destination))
2061
- rmSync(destination, { recursive: true, force: true });
2062
- throw error;
2301
+ if (intent.destinationPath !== destination) {
2302
+ throw new Error(`workspace ${workspace.uuid} already has a pending Add at ${intent.destinationPath}`);
2063
2303
  }
2304
+ if (intent.resourceId !== resourceId) {
2305
+ throw new Error(`workspace ${workspace.uuid} Add intent belongs to a different registry`);
2306
+ }
2307
+ if (pathExists(intent.destinationPath)) {
2308
+ throw new Error(`workspace ${workspace.uuid} is visible without committed entity rows`);
2309
+ }
2310
+ if (pathExists(binding)) {
2311
+ throw new Error(`workspace ${workspace.uuid} has a binding without committed entity rows`);
2312
+ }
2313
+ if (pathExists(intent.stagingPath)) {
2314
+ rmSync(intent.stagingPath, { recursive: true, force: true });
2315
+ }
2316
+ const selection = workspaceAddSelection(intent);
2317
+ const materialized = await materializeRecords({
2318
+ records: intent.records,
2319
+ rootPath: intent.stagingPath,
2320
+ boundaryUUID: intent.workspaceId,
2321
+ cacheDir,
2322
+ bindingDir,
2323
+ readContent,
2324
+ });
2325
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
2326
+ record: repository.record,
2327
+ identity: repository.state.identity,
2328
+ })), sha256Hex).map((row) => ({
2329
+ ...row,
2330
+ absolutePath: row.relativePath
2331
+ ? join(intent.destinationPath, ...row.relativePath.split("/"))
2332
+ : intent.destinationPath,
2333
+ }));
2334
+ await onStage?.("materialization-verified");
2335
+ persistRows(database, identity, intent.resourceId, intent.workspaceId, rows);
2336
+ await onStage?.("rows-committed");
2337
+ renameSync(intent.stagingPath, intent.destinationPath);
2338
+ refreshRevealedRootEvidence(database, intent.workspaceId, intent.destinationPath);
2339
+ await onStage?.("ground-revealed");
2340
+ ensureWorkspaceBinding({
2341
+ bindingDir,
2342
+ workspaceId: intent.workspaceId,
2343
+ absolutePath: intent.destinationPath,
2344
+ });
2345
+ await onStage?.("binding-created");
2346
+ const installedReferences = installCloudWorkspaceReferences({
2347
+ identity,
2348
+ userRoot,
2349
+ database,
2350
+ bindingDir,
2351
+ resourceId: intent.resourceId,
2352
+ workspace: selection.workspace,
2353
+ references: intent.references,
2354
+ });
2355
+ return {
2356
+ path: intent.destinationPath,
2357
+ rows: rows.length,
2358
+ records: rows.map((row) => row.record),
2359
+ reference: selection.reference,
2360
+ references: installedReferences,
2361
+ alreadyMaterialized: false,
2362
+ };
2064
2363
  }
2065
2364
  function readyFromFrame(frame) {
2066
2365
  const bytes = Buffer.from(String(frame.snapshot_b64 || ""), "base64");