@amalgm/shell 0.1.88 → 0.1.90

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.
@@ -15,6 +15,7 @@ import { ContentCacheDownload, captureContentFile, hashContentFile, sealCaptured
15
15
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
16
16
  import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
17
17
  import { AcceptedInboxResultIndex } from "./detection/accepted-result.js";
18
+ import { sameNotebookBasisRow } from "./detection/notebook-basis.js";
18
19
  import { NamedDetectRuntime } from "./detection/runtime.js";
19
20
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
20
21
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, readCachedRepositoryIdentity, } from "./git-repository-host.js";
@@ -227,6 +228,7 @@ export class UserGroundHost {
227
228
  findKnownWorkspaceId: ({ absolutePath, deviceNumber, inode }) => findKnownEntityId(this.databasePath(identity), absolutePath, deviceNumber, inode),
228
229
  });
229
230
  const add = {
231
+ sha256Hex,
230
232
  lookupRegistry: async () => {
231
233
  const started = performance.now();
232
234
  this.stage({
@@ -526,12 +528,16 @@ export class UserGroundHost {
526
528
  this.stage({
527
529
  primitive: "register", stage: "entity-sqlite-commit", status: "started", workspaceId,
528
530
  });
529
- commitDetectedState(this.databasePath(identity), {
531
+ const committed = commitDetectedState(this.databasePath(identity), {
532
+ notebookBasis: scanned.notebookBasis,
530
533
  acceptedMaterializedRows: scanned.acceptedMaterializedRows,
531
534
  materializedRemovals: scanned.materializedRemovals,
532
535
  acceptedNotebookRows: scanned.acceptedNotebookRows,
533
536
  notebookRemovals: scanned.notebookRemovals,
534
537
  });
538
+ if (!committed) {
539
+ throw new Error("verified ground advanced while registration was observing; retry");
540
+ }
535
541
  this.stage({
536
542
  primitive: "register",
537
543
  stage: "entity-sqlite-commit",
@@ -1577,6 +1583,7 @@ export class UserGroundHost {
1577
1583
  return;
1578
1584
  }
1579
1585
  let detection;
1586
+ let notebookBasis;
1580
1587
  let acceptedMaterializedRows;
1581
1588
  let materializedRemovals;
1582
1589
  let acceptedNotebookRows;
@@ -1592,6 +1599,7 @@ export class UserGroundHost {
1592
1599
  coreScan,
1593
1600
  });
1594
1601
  detection = scanned.detection;
1602
+ notebookBasis = scanned.notebookBasis;
1595
1603
  acceptedMaterializedRows = scanned.acceptedMaterializedRows;
1596
1604
  materializedRemovals = scanned.materializedRemovals;
1597
1605
  acceptedNotebookRows = scanned.acceptedNotebookRows;
@@ -1615,13 +1623,17 @@ export class UserGroundHost {
1615
1623
  }))
1616
1624
  : []);
1617
1625
  const recordCommitStarted = performance.now();
1618
- commitDetectedState(this.databasePath(identity), {
1626
+ const committed = commitDetectedState(this.databasePath(identity), {
1627
+ notebookBasis,
1619
1628
  acceptedMaterializedRows,
1620
1629
  materializedRemovals,
1621
1630
  acceptedNotebookRows,
1622
1631
  notebookRemovals,
1623
1632
  proposals: detectedRecords,
1624
1633
  });
1634
+ if (!committed) {
1635
+ throw pipelineStageError("detection", new Error("the verified notebook advanced while Detect was observing; retrying fresh truth"));
1636
+ }
1625
1637
  const recordCommitDuration = performance.now() - recordCommitStarted;
1626
1638
  if (detectedRecords.length === 0) {
1627
1639
  this.stage({
@@ -1666,7 +1678,8 @@ export class UserGroundHost {
1666
1678
  }
1667
1679
  }
1668
1680
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
1669
- const pending = readSnapshotPublications(this.databasePath(identity));
1681
+ const pending = readSnapshotPublications(this.databasePath(identity))
1682
+ .filter((entry) => entry.status === "pending");
1670
1683
  if (pending.length === 0)
1671
1684
  return;
1672
1685
  const first = pending[0];
@@ -1680,12 +1693,36 @@ export class UserGroundHost {
1680
1693
  checksum: "",
1681
1694
  records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
1682
1695
  };
1683
- await this.publishPendingSnapshots(identity);
1696
+ try {
1697
+ await this.publishPendingSnapshots(identity);
1698
+ }
1699
+ catch (error) {
1700
+ // A conclusive refusal settles the intent as rejected: the lookup that
1701
+ // follows fetches authoritative state and a later register supersedes
1702
+ // the settled row, so a refused publication never wedges startup. An
1703
+ // unknown outcome must keep blocking — the mutation may have landed.
1704
+ if (!conclusivelyRefused(error))
1705
+ throw error;
1706
+ this.cloudState = null;
1707
+ }
1684
1708
  }
1685
1709
  /** Register is the deliberate cold graph-publication boundary. Ordinary
1686
1710
  * Watch detection stops at a durable exact record and never rebuilds this
1687
1711
  * resource snapshot. */
1688
1712
  async publishMaterializedSnapshot(identity, publicationRootIds, trace) {
1713
+ // Any durable pending intent resolves before a new snapshot derives: an
1714
+ // acknowledged publication advances the head through its original
1715
+ // mutation id (the authority answers a known mutation id idempotently),
1716
+ // and a conclusive refusal marks the row rejected so the snapshot
1717
+ // derived below may supersede it. A transport failure keeps the row
1718
+ // pending — its outcome is unknown, so nothing may replace it.
1719
+ try {
1720
+ await this.publishPendingSnapshots(identity, trace);
1721
+ }
1722
+ catch (error) {
1723
+ if (!conclusivelyRefused(error))
1724
+ throw error;
1725
+ }
1689
1726
  const state = this.cloudState;
1690
1727
  if (!state)
1691
1728
  throw new Error("cloud state is unavailable for graph publication");
@@ -1693,7 +1730,7 @@ export class UserGroundHost {
1693
1730
  .map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
1694
1731
  .map(portableRecord));
1695
1732
  const merged = mergeMaterializedRoots(state.records, localRecords);
1696
- const traveling = travelingRecords(merged);
1733
+ const traveling = travelingRecords(merged, sha256Hex);
1697
1734
  const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
1698
1735
  && record.payloadVersion?.startsWith("git:"));
1699
1736
  if (impossibleGitLeaf) {
@@ -1705,16 +1742,6 @@ export class UserGroundHost {
1705
1742
  const checksum = sha256Hex(snapshotJson);
1706
1743
  if (checksum === state.checksum)
1707
1744
  return;
1708
- const pending = readSnapshotPublications(this.databasePath(identity));
1709
- const identical = pending.find((entry) => entry.resourceId === state.resourceId
1710
- && entry.snapshotChecksum === checksum);
1711
- if (identical) {
1712
- await this.publishPendingSnapshots(identity, trace);
1713
- return;
1714
- }
1715
- if (pending.length > 0) {
1716
- throw new Error("a different graph snapshot is already pending publication");
1717
- }
1718
1745
  const localIds = new Set(localRecords.map((record) => record.uuid));
1719
1746
  const replacementRootIds = localRecords
1720
1747
  .filter((record) => record.parentUUID === null)
@@ -1736,7 +1763,7 @@ export class UserGroundHost {
1736
1763
  const artifact = artifactForRecord(record);
1737
1764
  return artifact !== null && !publishedArtifacts.has(artifact.contentHash);
1738
1765
  });
1739
- appendSnapshotJournal(this.databasePath(identity), {
1766
+ supersedeSnapshotJournal(this.databasePath(identity), {
1740
1767
  mutationId: randomUUID(),
1741
1768
  resourceId: state.resourceId,
1742
1769
  authorityEpoch: state.authorityEpoch,
@@ -1746,11 +1773,17 @@ export class UserGroundHost {
1746
1773
  uploadRecords,
1747
1774
  replacementRootIds: replacement.rootIds,
1748
1775
  replacementRecords: replacement.records,
1776
+ status: "pending",
1749
1777
  });
1750
1778
  await this.publishPendingSnapshots(identity, trace);
1751
1779
  }
1752
1780
  async publishPendingSnapshots(identity, trace) {
1753
1781
  for (const pending of readSnapshotPublications(this.databasePath(identity))) {
1782
+ // A rejected row is a settled fact, not an intent: the authority
1783
+ // conclusively refused this exact mutation, and only a newer complete
1784
+ // snapshot superseding it can publish this resource again.
1785
+ if (pending.status === "rejected")
1786
+ continue;
1754
1787
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
1755
1788
  const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
1756
1789
  try {
@@ -1796,6 +1829,13 @@ export class UserGroundHost {
1796
1829
  durationMs: performance.now() - graphCommitStarted,
1797
1830
  });
1798
1831
  }
1832
+ // A non-retryable error frame is the authority's answer: this exact
1833
+ // mutation is refused forever. Settle the row so it is never
1834
+ // replayed and a newer snapshot may supersede it. Every other
1835
+ // failure leaves the outcome unknown and the row pending.
1836
+ if (error instanceof WireRequestError && error.retryable === false) {
1837
+ markSnapshotJournalRejected(this.databasePath(identity), pending.mutationId);
1838
+ }
1799
1839
  throw pipelineStageError("cloud graph commit", error);
1800
1840
  }
1801
1841
  if (trace?.journey === "register") {
@@ -2006,7 +2046,8 @@ function initializeDatabase(file) {
2006
2046
  mutation_id TEXT NOT NULL UNIQUE,
2007
2047
  resource_id TEXT NOT NULL,
2008
2048
  publication_json TEXT NOT NULL,
2009
- created_at INTEGER NOT NULL
2049
+ created_at INTEGER NOT NULL,
2050
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))
2010
2051
  );
2011
2052
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
2012
2053
  workspace_uuid TEXT PRIMARY KEY,
@@ -2041,6 +2082,10 @@ function initializeDatabase(file) {
2041
2082
  if (!inboxColumns.has("resolved_at")) {
2042
2083
  database.exec("ALTER TABLE record_inbox ADD COLUMN resolved_at INTEGER");
2043
2084
  }
2085
+ const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
2086
+ if (!publicationColumns.has("status")) {
2087
+ database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
2088
+ }
2044
2089
  database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
2045
2090
  database.exec("DROP TABLE IF EXISTS mutation_journal");
2046
2091
  migratedDatabases.add(file);
@@ -2414,7 +2459,7 @@ function readSnapshotPublications(file) {
2414
2459
  try {
2415
2460
  return database.prepare(`
2416
2461
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
2417
- publication_json AS publicationJson
2462
+ publication_json AS publicationJson, status
2418
2463
  FROM snapshot_publications ORDER BY sequence
2419
2464
  `).all().map((row) => {
2420
2465
  const publication = JSON.parse(row.publicationJson);
@@ -2435,6 +2480,7 @@ function readSnapshotPublications(file) {
2435
2480
  uploadRecords: snapshotFromRecords(publication.uploadRecords).records,
2436
2481
  replacementRootIds: publication.replacementRootIds.map(String),
2437
2482
  replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
2483
+ status: row.status === "rejected" ? "rejected" : "pending",
2438
2484
  };
2439
2485
  });
2440
2486
  }
@@ -2461,7 +2507,25 @@ function hasRecordsBeyondSnapshot(file) {
2461
2507
  function commitDetectedState(file, input) {
2462
2508
  const database = initializeDatabase(file);
2463
2509
  try {
2464
- database.transaction(() => {
2510
+ return database.transaction(() => {
2511
+ const basisRows = input.notebookBasis ?? [];
2512
+ const currentByUuid = new Map();
2513
+ for (let offset = 0; offset < basisRows.length; offset += 500) {
2514
+ const batch = basisRows.slice(offset, offset + 500);
2515
+ const placeholders = batch.map(() => "?").join(", ");
2516
+ const rows = database.prepare(`
2517
+ SELECT ${GROUND_ROW_PROJECTION} FROM detection_notebook
2518
+ WHERE uuid IN (${placeholders})
2519
+ `).all(...batch.map(({ uuid }) => uuid));
2520
+ for (const row of rows)
2521
+ currentByUuid.set(row.uuid, row);
2522
+ }
2523
+ for (const basis of basisRows) {
2524
+ const current = currentByUuid.get(basis.uuid);
2525
+ if (basis.row ? !sameNotebookBasisRow(basis.row, current) : current !== undefined) {
2526
+ return false;
2527
+ }
2528
+ }
2465
2529
  const acceptedInbox = new AcceptedInboxResultIndex(database);
2466
2530
  const append = database.prepare(`
2467
2531
  INSERT INTO record_outbox(
@@ -2503,33 +2567,75 @@ function commitDetectedState(file, input) {
2503
2567
  for (const row of input.acceptedNotebookRows) {
2504
2568
  runGroundUpsert(database, "detection_notebook", upsertNotebook, row);
2505
2569
  }
2570
+ return true;
2506
2571
  })();
2507
2572
  }
2508
2573
  finally {
2509
2574
  database.close();
2510
2575
  }
2511
2576
  }
2512
- function appendSnapshotJournal(file, entry) {
2577
+ /** Journal the newest snapshot publication for its resource. A journal row
2578
+ * has exactly two meanings: `pending` — the submit outcome is unknown, so
2579
+ * the same mutation id must retry until the authority answers (the mutation
2580
+ * may already be accepted with only the acknowledgement lost); `rejected` —
2581
+ * the authority conclusively refused it, so it is never replayed. Only a
2582
+ * rejected row may be superseded — last write wins — and supersession and
2583
+ * insertion share one transaction, so a crash leaves exactly one durable
2584
+ * intent. Content uploads are content-addressed and idempotent, so a
2585
+ * superseded row loses no uploaded work. */
2586
+ function supersedeSnapshotJournal(file, entry) {
2513
2587
  const database = initializeDatabase(file);
2514
2588
  try {
2515
- database.prepare(`
2516
- INSERT INTO snapshot_publications(
2517
- mutation_id, resource_id, publication_json, created_at
2518
- ) VALUES (?, ?, ?, ?)
2519
- `).run(entry.mutationId, entry.resourceId, stableJson({
2520
- authorityEpoch: entry.authorityEpoch,
2521
- baseVersion: entry.baseVersion,
2522
- snapshotChecksum: entry.snapshotChecksum,
2523
- snapshotJson: entry.snapshotJson,
2524
- uploadRecords: entry.uploadRecords,
2525
- replacementRootIds: entry.replacementRootIds,
2526
- replacementRecords: entry.replacementRecords,
2527
- }), Date.now());
2589
+ database.transaction(() => {
2590
+ const unresolved = database.prepare(`
2591
+ SELECT 1 AS present FROM snapshot_publications
2592
+ WHERE resource_id = ? AND status = 'pending' LIMIT 1
2593
+ `).get(entry.resourceId);
2594
+ if (unresolved) {
2595
+ throw new Error("a pending publication with an unknown outcome must resolve before supersession");
2596
+ }
2597
+ database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ? AND status = 'rejected'")
2598
+ .run(entry.resourceId);
2599
+ database.prepare(`
2600
+ INSERT INTO snapshot_publications(
2601
+ mutation_id, resource_id, publication_json, created_at
2602
+ ) VALUES (?, ?, ?, ?)
2603
+ `).run(entry.mutationId, entry.resourceId, stableJson({
2604
+ authorityEpoch: entry.authorityEpoch,
2605
+ baseVersion: entry.baseVersion,
2606
+ snapshotChecksum: entry.snapshotChecksum,
2607
+ snapshotJson: entry.snapshotJson,
2608
+ uploadRecords: entry.uploadRecords,
2609
+ replacementRootIds: entry.replacementRootIds,
2610
+ replacementRecords: entry.replacementRecords,
2611
+ }), Date.now());
2612
+ })();
2528
2613
  }
2529
2614
  finally {
2530
2615
  database.close();
2531
2616
  }
2532
2617
  }
2618
+ /** Settle a journal row as conclusively refused. It is never replayed again;
2619
+ * a newer complete snapshot supersedes it on the next publication. */
2620
+ function markSnapshotJournalRejected(file, mutationId) {
2621
+ const database = initializeDatabase(file);
2622
+ try {
2623
+ database.prepare("UPDATE snapshot_publications SET status = 'rejected' WHERE mutation_id = ?")
2624
+ .run(mutationId);
2625
+ }
2626
+ finally {
2627
+ database.close();
2628
+ }
2629
+ }
2630
+ /** True when the authority itself answered that this exact mutation is
2631
+ * refused (a non-retryable error frame), as opposed to a transport or
2632
+ * pipeline failure whose outcome is unknown. Stage wrappers carry the wire
2633
+ * error as their cause. */
2634
+ function conclusivelyRefused(error) {
2635
+ if (error instanceof WireRequestError)
2636
+ return error.retryable === false;
2637
+ return error instanceof Error && conclusivelyRefused(error.cause);
2638
+ }
2533
2639
  function deleteJournalEntry(file, mutationId) {
2534
2640
  const database = initializeDatabase(file);
2535
2641
  try {
@@ -2605,28 +2711,6 @@ function capturedGroundRow(resourceId, rootUUID, row) {
2605
2711
  contentVerifiedAtMs: row.contentVerifiedAtMs,
2606
2712
  };
2607
2713
  }
2608
- function sameGroundRow(left, right) {
2609
- return right !== undefined
2610
- && left.resourceId === right.resourceId
2611
- && left.rootUUID === right.rootUUID
2612
- && left.uuid === right.uuid
2613
- && left.type === right.type
2614
- && left.parentUUID === right.parentUUID
2615
- && left.name === right.name
2616
- && left.status === right.status
2617
- && left.version === right.version
2618
- && left.payloadVersion === right.payloadVersion
2619
- && left.transportVersion === right.transportVersion
2620
- && left.relativePath === right.relativePath
2621
- && left.absolutePath === right.absolutePath
2622
- && left.deviceNumber === right.deviceNumber
2623
- && left.inode === right.inode
2624
- && left.byteSize === right.byteSize
2625
- && left.modifiedTimeMs === right.modifiedTimeMs
2626
- && left.changedTimeMs === right.changedTimeMs
2627
- && left.filesystemMode === right.filesystemMode
2628
- && left.contentVerifiedAtMs === right.contentVerifiedAtMs;
2629
- }
2630
2714
  function groundRowValues(row) {
2631
2715
  return [
2632
2716
  row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
@@ -2975,7 +3059,8 @@ async function reconcileGround(input) {
2975
3059
  ? readMaterializedRowsWithoutNotebook(database, selectedRoots ?? undefined)
2976
3060
  : [];
2977
3061
  const existing = suspicions ? [...notebook, ...unverifiedIdentity] : materialized;
2978
- const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
3062
+ const observedNotebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
3063
+ const notebookByUuid = new Map(observedNotebookByUuid);
2979
3064
  const materializedBaseline = suspicions
2980
3065
  ? existing.filter((row) => materializedUUIDs.has(row.uuid))
2981
3066
  : materialized;
@@ -3034,11 +3119,11 @@ async function reconcileGround(input) {
3034
3119
  }
3035
3120
  for (const row of result.rows) {
3036
3121
  const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
3037
- if (!sameGroundRow(captured, materializedByUuid.get(captured.uuid))) {
3122
+ if (!sameNotebookBasisRow(captured, materializedByUuid.get(captured.uuid))) {
3038
3123
  acceptedMaterializedRows.push(captured);
3039
3124
  }
3040
3125
  if (result.transferable) {
3041
- if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
3126
+ if (sameNotebookBasisRow(captured, notebookByUuid.get(captured.uuid)))
3042
3127
  continue;
3043
3128
  acceptedNotebookRows.push(captured);
3044
3129
  notebookByUuid.set(captured.uuid, captured);
@@ -3122,8 +3207,21 @@ async function reconcileGround(input) {
3122
3207
  suspicion: observationFor(rootPath),
3123
3208
  });
3124
3209
  }
3210
+ const changedUUIDs = new Set([
3211
+ ...acceptedMaterializedRows.map((row) => row.uuid),
3212
+ ...materializedRemovals,
3213
+ ...acceptedNotebookRows.map((row) => row.uuid),
3214
+ ...notebookRemovals,
3215
+ ...detection.flatMap((plan) => plan.kind === "ready"
3216
+ ? plan.proposals.map((proposal) => proposal.entityId)
3217
+ : []),
3218
+ ]);
3125
3219
  return {
3126
3220
  detection,
3221
+ notebookBasis: [...changedUUIDs].sort().map((uuid) => ({
3222
+ uuid,
3223
+ row: observedNotebookByUuid.get(uuid) ?? null,
3224
+ })),
3127
3225
  acceptedMaterializedRows,
3128
3226
  materializedRemovals: [...materializedRemovals],
3129
3227
  acceptedNotebookRows,
@@ -4386,7 +4484,7 @@ async function installCloudWorkspace(input) {
4386
4484
  }
4387
4485
  const expectedRecords = pending?.records ?? records;
4388
4486
  const localRecords = existing.map(portableRecord);
4389
- if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
4487
+ if (!sameRecords(travelingRecords(localRecords, sha256Hex), expectedRecords)) {
4390
4488
  throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
4391
4489
  }
4392
4490
  if (pending && root.absolutePath !== pending.destinationPath) {