@amalgm/shell 0.1.87 → 0.1.89
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.
package/dist/user-ground-host.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
|
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
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
|
-
import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, repositoryIdentityHash, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
|
|
9
9
|
import { emitFilesStage, } from "./files-observability.js";
|
|
@@ -18,7 +18,7 @@ import { AcceptedInboxResultIndex } from "./detection/accepted-result.js";
|
|
|
18
18
|
import { NamedDetectRuntime } from "./detection/runtime.js";
|
|
19
19
|
import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
|
|
20
20
|
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, readCachedRepositoryIdentity, } from "./git-repository-host.js";
|
|
21
|
-
import { inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
|
|
21
|
+
import { gitEvidenceFingerprint, inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
|
|
22
22
|
import { projectMaterializedGraph } from "./materialized-graph.js";
|
|
23
23
|
import { NodeWatchHost, } from "./watching/index.js";
|
|
24
24
|
import { GroundCoordinator } from "./ground-coordination.js";
|
|
@@ -227,6 +227,7 @@ export class UserGroundHost {
|
|
|
227
227
|
findKnownWorkspaceId: ({ absolutePath, deviceNumber, inode }) => findKnownEntityId(this.databasePath(identity), absolutePath, deviceNumber, inode),
|
|
228
228
|
});
|
|
229
229
|
const add = {
|
|
230
|
+
sha256Hex,
|
|
230
231
|
lookupRegistry: async () => {
|
|
231
232
|
const started = performance.now();
|
|
232
233
|
this.stage({
|
|
@@ -1666,7 +1667,8 @@ export class UserGroundHost {
|
|
|
1666
1667
|
}
|
|
1667
1668
|
}
|
|
1668
1669
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
1669
|
-
const pending = readSnapshotPublications(this.databasePath(identity))
|
|
1670
|
+
const pending = readSnapshotPublications(this.databasePath(identity))
|
|
1671
|
+
.filter((entry) => entry.status === "pending");
|
|
1670
1672
|
if (pending.length === 0)
|
|
1671
1673
|
return;
|
|
1672
1674
|
const first = pending[0];
|
|
@@ -1680,12 +1682,36 @@ export class UserGroundHost {
|
|
|
1680
1682
|
checksum: "",
|
|
1681
1683
|
records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
|
|
1682
1684
|
};
|
|
1683
|
-
|
|
1685
|
+
try {
|
|
1686
|
+
await this.publishPendingSnapshots(identity);
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
// A conclusive refusal settles the intent as rejected: the lookup that
|
|
1690
|
+
// follows fetches authoritative state and a later register supersedes
|
|
1691
|
+
// the settled row, so a refused publication never wedges startup. An
|
|
1692
|
+
// unknown outcome must keep blocking — the mutation may have landed.
|
|
1693
|
+
if (!conclusivelyRefused(error))
|
|
1694
|
+
throw error;
|
|
1695
|
+
this.cloudState = null;
|
|
1696
|
+
}
|
|
1684
1697
|
}
|
|
1685
1698
|
/** Register is the deliberate cold graph-publication boundary. Ordinary
|
|
1686
1699
|
* Watch detection stops at a durable exact record and never rebuilds this
|
|
1687
1700
|
* resource snapshot. */
|
|
1688
1701
|
async publishMaterializedSnapshot(identity, publicationRootIds, trace) {
|
|
1702
|
+
// Any durable pending intent resolves before a new snapshot derives: an
|
|
1703
|
+
// acknowledged publication advances the head through its original
|
|
1704
|
+
// mutation id (the authority answers a known mutation id idempotently),
|
|
1705
|
+
// and a conclusive refusal marks the row rejected so the snapshot
|
|
1706
|
+
// derived below may supersede it. A transport failure keeps the row
|
|
1707
|
+
// pending — its outcome is unknown, so nothing may replace it.
|
|
1708
|
+
try {
|
|
1709
|
+
await this.publishPendingSnapshots(identity, trace);
|
|
1710
|
+
}
|
|
1711
|
+
catch (error) {
|
|
1712
|
+
if (!conclusivelyRefused(error))
|
|
1713
|
+
throw error;
|
|
1714
|
+
}
|
|
1689
1715
|
const state = this.cloudState;
|
|
1690
1716
|
if (!state)
|
|
1691
1717
|
throw new Error("cloud state is unavailable for graph publication");
|
|
@@ -1693,7 +1719,7 @@ export class UserGroundHost {
|
|
|
1693
1719
|
.map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
|
|
1694
1720
|
.map(portableRecord));
|
|
1695
1721
|
const merged = mergeMaterializedRoots(state.records, localRecords);
|
|
1696
|
-
const traveling = travelingRecords(merged);
|
|
1722
|
+
const traveling = travelingRecords(merged, sha256Hex);
|
|
1697
1723
|
const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
|
|
1698
1724
|
&& record.payloadVersion?.startsWith("git:"));
|
|
1699
1725
|
if (impossibleGitLeaf) {
|
|
@@ -1705,16 +1731,6 @@ export class UserGroundHost {
|
|
|
1705
1731
|
const checksum = sha256Hex(snapshotJson);
|
|
1706
1732
|
if (checksum === state.checksum)
|
|
1707
1733
|
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
1734
|
const localIds = new Set(localRecords.map((record) => record.uuid));
|
|
1719
1735
|
const replacementRootIds = localRecords
|
|
1720
1736
|
.filter((record) => record.parentUUID === null)
|
|
@@ -1736,7 +1752,7 @@ export class UserGroundHost {
|
|
|
1736
1752
|
const artifact = artifactForRecord(record);
|
|
1737
1753
|
return artifact !== null && !publishedArtifacts.has(artifact.contentHash);
|
|
1738
1754
|
});
|
|
1739
|
-
|
|
1755
|
+
supersedeSnapshotJournal(this.databasePath(identity), {
|
|
1740
1756
|
mutationId: randomUUID(),
|
|
1741
1757
|
resourceId: state.resourceId,
|
|
1742
1758
|
authorityEpoch: state.authorityEpoch,
|
|
@@ -1746,11 +1762,17 @@ export class UserGroundHost {
|
|
|
1746
1762
|
uploadRecords,
|
|
1747
1763
|
replacementRootIds: replacement.rootIds,
|
|
1748
1764
|
replacementRecords: replacement.records,
|
|
1765
|
+
status: "pending",
|
|
1749
1766
|
});
|
|
1750
1767
|
await this.publishPendingSnapshots(identity, trace);
|
|
1751
1768
|
}
|
|
1752
1769
|
async publishPendingSnapshots(identity, trace) {
|
|
1753
1770
|
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
1771
|
+
// A rejected row is a settled fact, not an intent: the authority
|
|
1772
|
+
// conclusively refused this exact mutation, and only a newer complete
|
|
1773
|
+
// snapshot superseding it can publish this resource again.
|
|
1774
|
+
if (pending.status === "rejected")
|
|
1775
|
+
continue;
|
|
1754
1776
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
1755
1777
|
const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
|
|
1756
1778
|
try {
|
|
@@ -1796,6 +1818,13 @@ export class UserGroundHost {
|
|
|
1796
1818
|
durationMs: performance.now() - graphCommitStarted,
|
|
1797
1819
|
});
|
|
1798
1820
|
}
|
|
1821
|
+
// A non-retryable error frame is the authority's answer: this exact
|
|
1822
|
+
// mutation is refused forever. Settle the row so it is never
|
|
1823
|
+
// replayed and a newer snapshot may supersede it. Every other
|
|
1824
|
+
// failure leaves the outcome unknown and the row pending.
|
|
1825
|
+
if (error instanceof WireRequestError && error.retryable === false) {
|
|
1826
|
+
markSnapshotJournalRejected(this.databasePath(identity), pending.mutationId);
|
|
1827
|
+
}
|
|
1799
1828
|
throw pipelineStageError("cloud graph commit", error);
|
|
1800
1829
|
}
|
|
1801
1830
|
if (trace?.journey === "register") {
|
|
@@ -2006,13 +2035,21 @@ function initializeDatabase(file) {
|
|
|
2006
2035
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
2007
2036
|
resource_id TEXT NOT NULL,
|
|
2008
2037
|
publication_json TEXT NOT NULL,
|
|
2009
|
-
created_at INTEGER NOT NULL
|
|
2038
|
+
created_at INTEGER NOT NULL,
|
|
2039
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))
|
|
2010
2040
|
);
|
|
2011
2041
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
2012
2042
|
workspace_uuid TEXT PRIMARY KEY,
|
|
2013
2043
|
destination_path TEXT NOT NULL UNIQUE,
|
|
2014
2044
|
intent_json TEXT NOT NULL
|
|
2015
2045
|
);
|
|
2046
|
+
CREATE TABLE IF NOT EXISTS repository_git_evidence (
|
|
2047
|
+
uuid TEXT PRIMARY KEY,
|
|
2048
|
+
payload_version TEXT NOT NULL,
|
|
2049
|
+
transport_version TEXT NOT NULL,
|
|
2050
|
+
identity_hash TEXT NOT NULL,
|
|
2051
|
+
fingerprint TEXT NOT NULL
|
|
2052
|
+
);
|
|
2016
2053
|
CREATE INDEX IF NOT EXISTS entities_by_absolute_path
|
|
2017
2054
|
ON entities(absolute_path);
|
|
2018
2055
|
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
@@ -2034,6 +2071,10 @@ function initializeDatabase(file) {
|
|
|
2034
2071
|
if (!inboxColumns.has("resolved_at")) {
|
|
2035
2072
|
database.exec("ALTER TABLE record_inbox ADD COLUMN resolved_at INTEGER");
|
|
2036
2073
|
}
|
|
2074
|
+
const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
|
|
2075
|
+
if (!publicationColumns.has("status")) {
|
|
2076
|
+
database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
|
|
2077
|
+
}
|
|
2037
2078
|
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
2038
2079
|
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
2039
2080
|
migratedDatabases.add(file);
|
|
@@ -2407,7 +2448,7 @@ function readSnapshotPublications(file) {
|
|
|
2407
2448
|
try {
|
|
2408
2449
|
return database.prepare(`
|
|
2409
2450
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
2410
|
-
publication_json AS publicationJson
|
|
2451
|
+
publication_json AS publicationJson, status
|
|
2411
2452
|
FROM snapshot_publications ORDER BY sequence
|
|
2412
2453
|
`).all().map((row) => {
|
|
2413
2454
|
const publication = JSON.parse(row.publicationJson);
|
|
@@ -2428,6 +2469,7 @@ function readSnapshotPublications(file) {
|
|
|
2428
2469
|
uploadRecords: snapshotFromRecords(publication.uploadRecords).records,
|
|
2429
2470
|
replacementRootIds: publication.replacementRootIds.map(String),
|
|
2430
2471
|
replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
|
|
2472
|
+
status: row.status === "rejected" ? "rejected" : "pending",
|
|
2431
2473
|
};
|
|
2432
2474
|
});
|
|
2433
2475
|
}
|
|
@@ -2502,27 +2544,68 @@ function commitDetectedState(file, input) {
|
|
|
2502
2544
|
database.close();
|
|
2503
2545
|
}
|
|
2504
2546
|
}
|
|
2505
|
-
|
|
2547
|
+
/** Journal the newest snapshot publication for its resource. A journal row
|
|
2548
|
+
* has exactly two meanings: `pending` — the submit outcome is unknown, so
|
|
2549
|
+
* the same mutation id must retry until the authority answers (the mutation
|
|
2550
|
+
* may already be accepted with only the acknowledgement lost); `rejected` —
|
|
2551
|
+
* the authority conclusively refused it, so it is never replayed. Only a
|
|
2552
|
+
* rejected row may be superseded — last write wins — and supersession and
|
|
2553
|
+
* insertion share one transaction, so a crash leaves exactly one durable
|
|
2554
|
+
* intent. Content uploads are content-addressed and idempotent, so a
|
|
2555
|
+
* superseded row loses no uploaded work. */
|
|
2556
|
+
function supersedeSnapshotJournal(file, entry) {
|
|
2506
2557
|
const database = initializeDatabase(file);
|
|
2507
2558
|
try {
|
|
2508
|
-
database.
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2559
|
+
database.transaction(() => {
|
|
2560
|
+
const unresolved = database.prepare(`
|
|
2561
|
+
SELECT 1 AS present FROM snapshot_publications
|
|
2562
|
+
WHERE resource_id = ? AND status = 'pending' LIMIT 1
|
|
2563
|
+
`).get(entry.resourceId);
|
|
2564
|
+
if (unresolved) {
|
|
2565
|
+
throw new Error("a pending publication with an unknown outcome must resolve before supersession");
|
|
2566
|
+
}
|
|
2567
|
+
database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ? AND status = 'rejected'")
|
|
2568
|
+
.run(entry.resourceId);
|
|
2569
|
+
database.prepare(`
|
|
2570
|
+
INSERT INTO snapshot_publications(
|
|
2571
|
+
mutation_id, resource_id, publication_json, created_at
|
|
2572
|
+
) VALUES (?, ?, ?, ?)
|
|
2573
|
+
`).run(entry.mutationId, entry.resourceId, stableJson({
|
|
2574
|
+
authorityEpoch: entry.authorityEpoch,
|
|
2575
|
+
baseVersion: entry.baseVersion,
|
|
2576
|
+
snapshotChecksum: entry.snapshotChecksum,
|
|
2577
|
+
snapshotJson: entry.snapshotJson,
|
|
2578
|
+
uploadRecords: entry.uploadRecords,
|
|
2579
|
+
replacementRootIds: entry.replacementRootIds,
|
|
2580
|
+
replacementRecords: entry.replacementRecords,
|
|
2581
|
+
}), Date.now());
|
|
2582
|
+
})();
|
|
2521
2583
|
}
|
|
2522
2584
|
finally {
|
|
2523
2585
|
database.close();
|
|
2524
2586
|
}
|
|
2525
2587
|
}
|
|
2588
|
+
/** Settle a journal row as conclusively refused. It is never replayed again;
|
|
2589
|
+
* a newer complete snapshot supersedes it on the next publication. */
|
|
2590
|
+
function markSnapshotJournalRejected(file, mutationId) {
|
|
2591
|
+
const database = initializeDatabase(file);
|
|
2592
|
+
try {
|
|
2593
|
+
database.prepare("UPDATE snapshot_publications SET status = 'rejected' WHERE mutation_id = ?")
|
|
2594
|
+
.run(mutationId);
|
|
2595
|
+
}
|
|
2596
|
+
finally {
|
|
2597
|
+
database.close();
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
/** True when the authority itself answered that this exact mutation is
|
|
2601
|
+
* refused (a non-retryable error frame), as opposed to a transport or
|
|
2602
|
+
* pipeline failure whose outcome is unknown. Stage wrappers carry the wire
|
|
2603
|
+
* error as their cause. */
|
|
2604
|
+
function conclusivelyRefused(error) {
|
|
2605
|
+
if (error instanceof WireRequestError)
|
|
2606
|
+
return error.retryable === false;
|
|
2607
|
+
return error instanceof Error && conclusivelyRefused(error.cause);
|
|
2608
|
+
}
|
|
2526
2609
|
function deleteJournalEntry(file, mutationId) {
|
|
2527
2610
|
const database = initializeDatabase(file);
|
|
2528
2611
|
try {
|
|
@@ -2666,6 +2749,45 @@ function runGroundUpsert(database, table, statement, row) {
|
|
|
2666
2749
|
+ `occupied by ${occupant?.uuid ?? "no retained row"} (${occupant?.absolutePath ?? "unknown"})`, { cause: error });
|
|
2667
2750
|
}
|
|
2668
2751
|
}
|
|
2752
|
+
/** The Git-evidence fingerprint proven by the last successful capture, bound
|
|
2753
|
+
* to the exact heads AND the exact identity map it certified. A stored
|
|
2754
|
+
* fingerprint only gates when the stored root row still carries the same
|
|
2755
|
+
* heads and the current rows still derive the same identity map, so neither a
|
|
2756
|
+
* capture whose detection never reached the table nor a raced base rollback
|
|
2757
|
+
* can silence a real change. */
|
|
2758
|
+
function readSealedRepositoryEvidence(file, uuid) {
|
|
2759
|
+
const database = initializeDatabase(file);
|
|
2760
|
+
try {
|
|
2761
|
+
return database.prepare(`
|
|
2762
|
+
SELECT payload_version AS payloadVersion,
|
|
2763
|
+
transport_version AS transportVersion,
|
|
2764
|
+
identity_hash AS identityHash,
|
|
2765
|
+
fingerprint
|
|
2766
|
+
FROM repository_git_evidence WHERE uuid = ?
|
|
2767
|
+
`).get(uuid) ?? null;
|
|
2768
|
+
}
|
|
2769
|
+
finally {
|
|
2770
|
+
database.close();
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
function writeSealedRepositoryEvidence(file, uuid, evidence) {
|
|
2774
|
+
const database = initializeDatabase(file);
|
|
2775
|
+
try {
|
|
2776
|
+
database.prepare(`
|
|
2777
|
+
INSERT INTO repository_git_evidence
|
|
2778
|
+
(uuid, payload_version, transport_version, identity_hash, fingerprint)
|
|
2779
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2780
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
2781
|
+
payload_version = excluded.payload_version,
|
|
2782
|
+
transport_version = excluded.transport_version,
|
|
2783
|
+
identity_hash = excluded.identity_hash,
|
|
2784
|
+
fingerprint = excluded.fingerprint
|
|
2785
|
+
`).run(uuid, evidence.payloadVersion, evidence.transportVersion, evidence.identityHash, evidence.fingerprint);
|
|
2786
|
+
}
|
|
2787
|
+
finally {
|
|
2788
|
+
database.close();
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2669
2791
|
function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
|
|
2670
2792
|
const database = initializeDatabase(file);
|
|
2671
2793
|
try {
|
|
@@ -3136,16 +3258,25 @@ async function reconcileRoot(input) {
|
|
|
3136
3258
|
let rootRepositoryEvidence = null;
|
|
3137
3259
|
let rootRepositoryInspectionFailed = false;
|
|
3138
3260
|
if (rootType === "repo.git") {
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3261
|
+
// On a complete pass whose Git state files are unchanged since the last
|
|
3262
|
+
// sealed capture, the up-front dirty-path evidence would only re-prove
|
|
3263
|
+
// what the stored rows already hold. The walk's lazy inspection still
|
|
3264
|
+
// asks Git the moment any entry is actually observed.
|
|
3265
|
+
const sealed = suspects === null
|
|
3266
|
+
? readSealedRepositoryEvidence(database, rootUUID)
|
|
3267
|
+
: null;
|
|
3268
|
+
if (sealed === null || sealed.fingerprint !== gitEvidenceFingerprint(rootPath)) {
|
|
3269
|
+
evidence && (evidence.gitInspections += 1);
|
|
3270
|
+
try {
|
|
3271
|
+
// A Git metadata ring can arrive before the corresponding worktree
|
|
3272
|
+
// ring. Git already knows every dirty tracked and untracked path, so
|
|
3273
|
+
// those paths join this observation and Card + Checkpoint cannot carry
|
|
3274
|
+
// stale identity.
|
|
3275
|
+
rootRepositoryEvidence = inspectGitRegistration(rootPath);
|
|
3276
|
+
}
|
|
3277
|
+
catch {
|
|
3278
|
+
rootRepositoryInspectionFailed = true;
|
|
3279
|
+
}
|
|
3149
3280
|
}
|
|
3150
3281
|
}
|
|
3151
3282
|
const normalizedSuspects = suspects === null ? null : suspects.map((path) => {
|
|
@@ -3401,7 +3532,25 @@ async function reconcileRoot(input) {
|
|
|
3401
3532
|
}
|
|
3402
3533
|
};
|
|
3403
3534
|
await visit(rootPath, rootUUID, rootType);
|
|
3404
|
-
|
|
3535
|
+
// When every entry sits at its stored address with its stored type and
|
|
3536
|
+
// physical fingerprint (honoring any fixed UUID), path-first claiming makes
|
|
3537
|
+
// the multi-pass reconciliation the identity map — resolve directly. Any
|
|
3538
|
+
// single mismatch falls back to the full algorithm.
|
|
3539
|
+
const stableIdentity = () => {
|
|
3540
|
+
const resolved = new Map();
|
|
3541
|
+
for (const entry of entries) {
|
|
3542
|
+
const row = existingByPath.get(entry.relativePath);
|
|
3543
|
+
if (!row
|
|
3544
|
+
|| row.type !== entry.type
|
|
3545
|
+
|| row.deviceNumber !== entry.deviceNumber
|
|
3546
|
+
|| row.inode !== entry.inode
|
|
3547
|
+
|| (entry.fixedUUID !== undefined && entry.fixedUUID !== row.uuid))
|
|
3548
|
+
return null;
|
|
3549
|
+
resolved.set(entry.relativePath, row.uuid);
|
|
3550
|
+
}
|
|
3551
|
+
return resolved;
|
|
3552
|
+
};
|
|
3553
|
+
const resolvedIdentity = stableIdentity() ?? reconcileGroundUUIDs(existingRows.map((row) => ({
|
|
3405
3554
|
uuid: row.uuid,
|
|
3406
3555
|
type: row.type,
|
|
3407
3556
|
relativePath: row.relativePath,
|
|
@@ -3435,22 +3584,36 @@ async function reconcileRoot(input) {
|
|
|
3435
3584
|
group.push({ uuid: entry.uuid, name: entry.name });
|
|
3436
3585
|
children.set(entry.parentUUID, group);
|
|
3437
3586
|
}
|
|
3587
|
+
// canonicalVersion is a pure function of these identity inputs, so a stored
|
|
3588
|
+
// leaf row whose inputs are unchanged already holds the exact version —
|
|
3589
|
+
// recomputing 70k hashes on a stable restart pass proved nothing. Container
|
|
3590
|
+
// versions still derive from live membership, which is never persisted.
|
|
3591
|
+
const existingVersionByUuid = new Map(existingRows.map((row) => [row.uuid, row]));
|
|
3438
3592
|
const rowsFromEntries = () => entries.map((entry) => {
|
|
3439
|
-
const
|
|
3440
|
-
|
|
3441
|
-
|
|
3593
|
+
const container = entry.type === "workspace" || entry.type === "folder";
|
|
3594
|
+
const stored = container ? undefined : existingVersionByUuid.get(entry.uuid);
|
|
3595
|
+
const storedVersion = stored !== undefined
|
|
3596
|
+
&& stored.type === entry.type
|
|
3597
|
+
&& stored.parentUUID === entry.parentUUID
|
|
3598
|
+
&& stored.name === entry.name
|
|
3599
|
+
&& stored.status === "active"
|
|
3600
|
+
&& stored.payloadVersion === entry.payloadVersion
|
|
3601
|
+
? stored.version
|
|
3602
|
+
: null;
|
|
3442
3603
|
const record = {
|
|
3443
3604
|
uuid: entry.uuid,
|
|
3444
3605
|
type: entry.type,
|
|
3445
3606
|
parentUUID: entry.parentUUID,
|
|
3446
3607
|
name: entry.name,
|
|
3447
3608
|
status: "active",
|
|
3448
|
-
version: canonicalVersion({
|
|
3609
|
+
version: storedVersion ?? canonicalVersion({
|
|
3449
3610
|
type: entry.type,
|
|
3450
3611
|
parentUUID: entry.parentUUID,
|
|
3451
3612
|
name: entry.name,
|
|
3452
3613
|
status: "active",
|
|
3453
|
-
payloadVersion:
|
|
3614
|
+
payloadVersion: container
|
|
3615
|
+
? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
|
|
3616
|
+
: entry.payloadVersion,
|
|
3454
3617
|
}, sha256Hex),
|
|
3455
3618
|
payloadVersion: entry.payloadVersion,
|
|
3456
3619
|
transportVersion: entry.transportVersion,
|
|
@@ -3552,15 +3715,51 @@ async function reconcileRoot(input) {
|
|
|
3552
3715
|
}))
|
|
3553
3716
|
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
3554
3717
|
};
|
|
3555
|
-
let repoIdentity = await refreshRepositoryIdentity();
|
|
3556
|
-
/* The rows and the transport identity are derived from the same fresh
|
|
3557
|
-
* worktree evidence. A later Watch ring for one of these dirty paths is
|
|
3558
|
-
* therefore ordinary equality, not a transport-only echo. */
|
|
3559
3718
|
const priorRow = existingByPath.get(repository.relativePath);
|
|
3560
3719
|
if (priorRow?.type === "repo.git") {
|
|
3561
3720
|
repository.payloadVersion = priorRow.payloadVersion;
|
|
3562
3721
|
repository.transportVersion = priorRow.transportVersion;
|
|
3563
3722
|
}
|
|
3723
|
+
// A complete pass re-proves a repository through Git unless Git's own
|
|
3724
|
+
// state files still match the fingerprint sealed with the exact heads
|
|
3725
|
+
// the stored row carries AND every owned entry sits unchanged at its
|
|
3726
|
+
// stored physical identity. Both together mean capture would derive the
|
|
3727
|
+
// rows this scan already holds; any single doubt asks Git in full.
|
|
3728
|
+
const unchangedSinceSeal = (entry) => {
|
|
3729
|
+
const row = existingByPath.get(entry.relativePath);
|
|
3730
|
+
return row !== undefined
|
|
3731
|
+
&& row.uuid === entry.uuid
|
|
3732
|
+
&& row.type === entry.type
|
|
3733
|
+
&& row.payloadVersion === entry.payloadVersion
|
|
3734
|
+
&& row.deviceNumber === entry.deviceNumber
|
|
3735
|
+
&& row.inode === entry.inode
|
|
3736
|
+
&& row.byteSize === entry.byteSize
|
|
3737
|
+
&& row.modifiedTimeMs === entry.modifiedTimeMs
|
|
3738
|
+
&& row.changedTimeMs === entry.changedTimeMs
|
|
3739
|
+
&& row.filesystemMode === entry.filesystemMode;
|
|
3740
|
+
};
|
|
3741
|
+
if (scanSuspects === null
|
|
3742
|
+
&& priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
3743
|
+
const sealed = readSealedRepositoryEvidence(database, repository.uuid);
|
|
3744
|
+
if (sealed !== null
|
|
3745
|
+
&& sealed.payloadVersion === priorRow.payloadVersion
|
|
3746
|
+
&& sealed.transportVersion === priorRow.transportVersion
|
|
3747
|
+
&& sealed.fingerprint === gitEvidenceFingerprint(repository.absolutePath)
|
|
3748
|
+
&& ownedEntries.every(unchangedSinceSeal)
|
|
3749
|
+
&& sealed.identityHash === repositoryIdentityHash(ownedEntries.map((entry) => ({
|
|
3750
|
+
path: repositoryPath(entry),
|
|
3751
|
+
uuid: entry.uuid,
|
|
3752
|
+
type: entry.type,
|
|
3753
|
+
payloadVersion: entry.type === "repo.git" ? null : entry.payloadVersion,
|
|
3754
|
+
})), sha256Hex)) {
|
|
3755
|
+
continue;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
const fingerprintBefore = gitEvidenceFingerprint(repository.absolutePath);
|
|
3759
|
+
let repoIdentity = await refreshRepositoryIdentity();
|
|
3760
|
+
/* The rows and the transport identity are derived from the same fresh
|
|
3761
|
+
* worktree evidence. A later Watch ring for one of these dirty paths is
|
|
3762
|
+
* therefore ordinary equality, not a transport-only echo. */
|
|
3564
3763
|
let prior = null;
|
|
3565
3764
|
let priorLayout = null;
|
|
3566
3765
|
if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
@@ -3637,6 +3836,18 @@ async function reconcileRoot(input) {
|
|
|
3637
3836
|
parentTransportVersion: currentLayout.parentTransportVersion,
|
|
3638
3837
|
transportVersion: captured.transportVersion,
|
|
3639
3838
|
});
|
|
3839
|
+
// The fingerprint is only sealed when Git's state files did not move
|
|
3840
|
+
// while capture ran; a concurrent ref or index write leaves no seal and
|
|
3841
|
+
// the next complete pass asks Git again.
|
|
3842
|
+
if (fingerprintBefore !== null
|
|
3843
|
+
&& fingerprintBefore === gitEvidenceFingerprint(repository.absolutePath)) {
|
|
3844
|
+
writeSealedRepositoryEvidence(database, repository.uuid, {
|
|
3845
|
+
payloadVersion: captured.stateId,
|
|
3846
|
+
transportVersion: captured.transportVersion,
|
|
3847
|
+
identityHash: repositoryIdentityHash(repoIdentity, sha256Hex),
|
|
3848
|
+
fingerprint: fingerprintBefore,
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3640
3851
|
}
|
|
3641
3852
|
}
|
|
3642
3853
|
catch {
|
|
@@ -4251,7 +4462,7 @@ async function installCloudWorkspace(input) {
|
|
|
4251
4462
|
}
|
|
4252
4463
|
const expectedRecords = pending?.records ?? records;
|
|
4253
4464
|
const localRecords = existing.map(portableRecord);
|
|
4254
|
-
if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
|
|
4465
|
+
if (!sameRecords(travelingRecords(localRecords, sha256Hex), expectedRecords)) {
|
|
4255
4466
|
throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
|
|
4256
4467
|
}
|
|
4257
4468
|
if (pending && root.absolutePath !== pending.destinationPath) {
|