@superblocksteam/workspace-sync 0.0.0-stage → 2.0.0-SNAPSHOT.e3d0ac5dd38ba62e
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/LICENSE.txt +87 -0
- package/README.md +51 -2
- package/dist/atomic-write.d.ts +10 -0
- package/dist/atomic-write.d.ts.map +1 -0
- package/dist/atomic-write.js +35 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/branch-write-guard.d.ts +32 -0
- package/dist/branch-write-guard.d.ts.map +1 -0
- package/dist/branch-write-guard.js +38 -0
- package/dist/branch-write-guard.js.map +1 -0
- package/dist/control.d.ts +237 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/control.js +242 -0
- package/dist/control.js.map +1 -0
- package/dist/dbfs-transport.d.ts +54 -0
- package/dist/dbfs-transport.d.ts.map +1 -0
- package/dist/dbfs-transport.js +21 -0
- package/dist/dbfs-transport.js.map +1 -0
- package/dist/download.d.ts +54 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +475 -0
- package/dist/download.js.map +1 -0
- package/dist/draft-helpers.d.ts +127 -0
- package/dist/draft-helpers.d.ts.map +1 -0
- package/dist/draft-helpers.js +840 -0
- package/dist/draft-helpers.js.map +1 -0
- package/dist/draft-journal-store.d.ts +30 -0
- package/dist/draft-journal-store.d.ts.map +1 -0
- package/dist/draft-journal-store.js +7 -0
- package/dist/draft-journal-store.js.map +1 -0
- package/dist/draft-state-report-metrics.d.ts +5 -0
- package/dist/draft-state-report-metrics.d.ts.map +1 -0
- package/dist/draft-state-report-metrics.js +36 -0
- package/dist/draft-state-report-metrics.js.map +1 -0
- package/dist/file-draft-journal-store.d.ts +26 -0
- package/dist/file-draft-journal-store.d.ts.map +1 -0
- package/dist/file-draft-journal-store.js +123 -0
- package/dist/file-draft-journal-store.js.map +1 -0
- package/dist/flush-scheduler.d.ts +24 -0
- package/dist/flush-scheduler.d.ts.map +1 -0
- package/dist/flush-scheduler.js +36 -0
- package/dist/flush-scheduler.js.map +1 -0
- package/dist/hash-cache.d.ts +16 -0
- package/dist/hash-cache.d.ts.map +1 -0
- package/dist/hash-cache.js +62 -0
- package/dist/hash-cache.js.map +1 -0
- package/dist/hash-dir-tree.d.ts +10 -0
- package/dist/hash-dir-tree.d.ts.map +1 -0
- package/dist/hash-dir-tree.js +53 -0
- package/dist/hash-dir-tree.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/list-dir.d.ts +31 -0
- package/dist/list-dir.d.ts.map +1 -0
- package/dist/list-dir.js +153 -0
- package/dist/list-dir.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +16 -0
- package/dist/logger.js.map +1 -0
- package/dist/native-draft-session.d.ts +28 -0
- package/dist/native-draft-session.d.ts.map +1 -0
- package/dist/native-draft-session.js +154 -0
- package/dist/native-draft-session.js.map +1 -0
- package/dist/rpc-client.d.ts +7 -0
- package/dist/rpc-client.d.ts.map +1 -0
- package/dist/rpc-client.js +2 -0
- package/dist/rpc-client.js.map +1 -0
- package/dist/safe-directory-reader.d.ts +28 -0
- package/dist/safe-directory-reader.d.ts.map +1 -0
- package/dist/safe-directory-reader.js +136 -0
- package/dist/safe-directory-reader.js.map +1 -0
- package/dist/server-tree.d.ts +56 -0
- package/dist/server-tree.d.ts.map +1 -0
- package/dist/server-tree.js +179 -0
- package/dist/server-tree.js.map +1 -0
- package/dist/session-draft-journal-store.d.ts +42 -0
- package/dist/session-draft-journal-store.d.ts.map +1 -0
- package/dist/session-draft-journal-store.js +177 -0
- package/dist/session-draft-journal-store.js.map +1 -0
- package/dist/snapshot/take-snapshot.d.ts +41 -0
- package/dist/snapshot/take-snapshot.d.ts.map +1 -0
- package/dist/snapshot/take-snapshot.js +244 -0
- package/dist/snapshot/take-snapshot.js.map +1 -0
- package/dist/snapshot/types.d.ts +25 -0
- package/dist/snapshot/types.d.ts.map +1 -0
- package/dist/snapshot/types.js +3 -0
- package/dist/snapshot/types.js.map +1 -0
- package/dist/snapshot/upload-snapshot.d.ts +33 -0
- package/dist/snapshot/upload-snapshot.d.ts.map +1 -0
- package/dist/snapshot/upload-snapshot.js +113 -0
- package/dist/snapshot/upload-snapshot.js.map +1 -0
- package/dist/socket-dbfs-transport.d.ts +32 -0
- package/dist/socket-dbfs-transport.d.ts.map +1 -0
- package/dist/socket-dbfs-transport.js +129 -0
- package/dist/socket-dbfs-transport.js.map +1 -0
- package/dist/sync-credential-provider.d.ts +27 -0
- package/dist/sync-credential-provider.d.ts.map +1 -0
- package/dist/sync-credential-provider.js +22 -0
- package/dist/sync-credential-provider.js.map +1 -0
- package/dist/sync-engine.d.ts +141 -0
- package/dist/sync-engine.d.ts.map +1 -0
- package/dist/sync-engine.js +447 -0
- package/dist/sync-engine.js.map +1 -0
- package/dist/sync-paths.d.ts +61 -0
- package/dist/sync-paths.d.ts.map +1 -0
- package/dist/sync-paths.js +107 -0
- package/dist/sync-paths.js.map +1 -0
- package/dist/sync-telemetry.d.ts +12 -0
- package/dist/sync-telemetry.d.ts.map +1 -0
- package/dist/sync-telemetry.js +33 -0
- package/dist/sync-telemetry.js.map +1 -0
- package/dist/testing.d.ts +81 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +192 -0
- package/dist/testing.js.map +1 -0
- package/dist/workspace-state-store.d.ts +52 -0
- package/dist/workspace-state-store.d.ts.map +1 -0
- package/dist/workspace-state-store.js +45 -0
- package/dist/workspace-state-store.js.map +1 -0
- package/package.json +60 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The credential the sync engine's transport authenticates DBFS calls with. */
|
|
2
|
+
export interface SyncCredential {
|
|
3
|
+
token: string;
|
|
4
|
+
/** Epoch milliseconds after which the token is no longer accepted. */
|
|
5
|
+
expiresAt?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Supplies the current sync credential. The interim implementation is a narrow
|
|
9
|
+
* server token the edge pushes before expiry; AR-1074 hardens or replaces it.
|
|
10
|
+
*/
|
|
11
|
+
export interface SyncCredentialProvider {
|
|
12
|
+
/** The latest credential, or undefined before the first one arrives. */
|
|
13
|
+
current(): SyncCredential | undefined;
|
|
14
|
+
/** Calls `listener` with each new credential; returns an unsubscribe. */
|
|
15
|
+
onRefresh(listener: (credential: SyncCredential) => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
/** A provider the host feeds, e.g. from the edge's state snapshots. */
|
|
18
|
+
export declare class PushedSyncCredentialProvider implements SyncCredentialProvider {
|
|
19
|
+
private credential;
|
|
20
|
+
private readonly listeners;
|
|
21
|
+
constructor(initial?: SyncCredential);
|
|
22
|
+
current(): SyncCredential | undefined;
|
|
23
|
+
onRefresh(listener: (credential: SyncCredential) => void): () => void;
|
|
24
|
+
/** Replaces the credential and notifies subscribers. */
|
|
25
|
+
push(credential: SyncCredential): void;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=sync-credential-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-credential-provider.d.ts","sourceRoot":"","sources":["../src/sync-credential-provider.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,wEAAwE;IACxE,OAAO,IAAI,cAAc,GAAG,SAAS,CAAC;IACtC,yEAAyE;IACzE,SAAS,CAAC,QAAQ,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CACvE;AAED,uEAAuE;AACvE,qBAAa,4BAA6B,YAAW,sBAAsB;IACzE,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmD;IAE7E,YAAY,OAAO,CAAC,EAAE,cAAc,EAEnC;IAED,OAAO,IAAI,cAAc,GAAG,SAAS,CAEpC;IAED,SAAS,CAAC,QAAQ,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI,CAGpE;IAED,wDAAwD;IACxD,IAAI,CAAC,UAAU,EAAE,cAAc,GAAG,IAAI,CAGrC;CACF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A provider the host feeds, e.g. from the edge's state snapshots. */
|
|
2
|
+
export class PushedSyncCredentialProvider {
|
|
3
|
+
credential;
|
|
4
|
+
listeners = new Set();
|
|
5
|
+
constructor(initial) {
|
|
6
|
+
this.credential = initial;
|
|
7
|
+
}
|
|
8
|
+
current() {
|
|
9
|
+
return this.credential;
|
|
10
|
+
}
|
|
11
|
+
onRefresh(listener) {
|
|
12
|
+
this.listeners.add(listener);
|
|
13
|
+
return () => this.listeners.delete(listener);
|
|
14
|
+
}
|
|
15
|
+
/** Replaces the credential and notifies subscribers. */
|
|
16
|
+
push(credential) {
|
|
17
|
+
this.credential = credential;
|
|
18
|
+
for (const listener of this.listeners)
|
|
19
|
+
listener(credential);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=sync-credential-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-credential-provider.js","sourceRoot":"","sources":["../src/sync-credential-provider.ts"],"names":[],"mappings":"AAkBA,uEAAuE;AACvE,MAAM,OAAO,4BAA4B;IAC/B,UAAU,CAA6B;IAC9B,SAAS,GAAG,IAAI,GAAG,EAAwC,CAAC;IAE7E,YAAY,OAAwB;QAClC,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,SAAS,CAAC,QAA8C;QACtD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,wDAAwD;IACxD,IAAI,CAAC,UAA0B;QAC7B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS;YAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC9D,CAAC;CACF"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Meter, Tracer } from "@opentelemetry/api";
|
|
2
|
+
import type { ApplicationHashChangeSource, CheckpointSkipReason } from "@superblocksteam/shared";
|
|
3
|
+
import { type BranchWriteGuard } from "./branch-write-guard.js";
|
|
4
|
+
import type { DbfsTransport } from "./dbfs-transport.js";
|
|
5
|
+
import { HashCache } from "./hash-cache.js";
|
|
6
|
+
import type { NativeDraftJournal } from "./native-draft-session.js";
|
|
7
|
+
import { type DownloadStats } from "./server-tree.js";
|
|
8
|
+
import type { WorkspaceStateStore } from "./workspace-state-store.js";
|
|
9
|
+
/**
|
|
10
|
+
* Where a test can stop the engine to simulate the VM dying. A hook that
|
|
11
|
+
* throws aborts the operation at that point, leaving disk, DBFS and the state
|
|
12
|
+
* store exactly as a kill would.
|
|
13
|
+
*/
|
|
14
|
+
export type FaultPoint =
|
|
15
|
+
/** A write landed in the tree; nothing has been flushed or committed. */
|
|
16
|
+
"after-write-before-flush"
|
|
17
|
+
/** Blobs are in DBFS; the branch hash has not moved. */
|
|
18
|
+
| "after-put-before-set"
|
|
19
|
+
/** The branch hash moved; the state store still holds the old hash. */
|
|
20
|
+
| "after-set-before-state";
|
|
21
|
+
export type FaultPoints = Partial<Record<FaultPoint, () => void | Promise<void>>>;
|
|
22
|
+
export interface WorkspaceSyncEngineOptions {
|
|
23
|
+
/** The application tree. */
|
|
24
|
+
root: string;
|
|
25
|
+
transport: DbfsTransport;
|
|
26
|
+
state: WorkspaceStateStore;
|
|
27
|
+
/** The native draft journal, when the host runs native sessions. */
|
|
28
|
+
journal?: NativeDraftJournal;
|
|
29
|
+
guard: BranchWriteGuard;
|
|
30
|
+
tracer?: Tracer;
|
|
31
|
+
meter?: Meter;
|
|
32
|
+
faults?: FaultPoints;
|
|
33
|
+
/** Seeded on rehydrate; a host may share one with other uploaders. */
|
|
34
|
+
hashCache?: HashCache;
|
|
35
|
+
/** Whether the tree holds unaccepted drafts. Defaults to its swap files. */
|
|
36
|
+
hasDraft?: () => Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
export interface CommitOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Finalize the draft journal before the snapshot (the turn ended). Pass
|
|
41
|
+
* false for a read-your-writes commit while a native session is still
|
|
42
|
+
* running; the journal then stays open. Defaults to true.
|
|
43
|
+
*/
|
|
44
|
+
finalizeJournal?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Send the hash even when it matches the stored one, and report the draft
|
|
47
|
+
* state even when it was already reported. The server records an accept or
|
|
48
|
+
* reject against its last checkpoint and drafts, not the branch hash: an
|
|
49
|
+
* accept of drafts the turn-end commit already uploaded must still reach it.
|
|
50
|
+
*/
|
|
51
|
+
checkpoint?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Settles the draft markers (accept, reject or clear) inside the commit
|
|
54
|
+
* queue, after the branch guard passes and the draft journal is dropped,
|
|
55
|
+
* and before the snapshot. No
|
|
56
|
+
* other commit can then reconcile the journal or report draft state between
|
|
57
|
+
* the markers changing and this commit.
|
|
58
|
+
*/
|
|
59
|
+
settle?: () => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export interface CommitResult {
|
|
62
|
+
dbfsHash: string;
|
|
63
|
+
commitId?: string;
|
|
64
|
+
/** Why the server made no checkpoint, when it skipped one by design. */
|
|
65
|
+
checkpointSkipped?: CheckpointSkipReason;
|
|
66
|
+
/** False when the tree already matched the stored hash. */
|
|
67
|
+
moved: boolean;
|
|
68
|
+
/** Paths the journal reconcile found changed, when a journal is active. */
|
|
69
|
+
journalChangedPaths?: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface FlushResult {
|
|
72
|
+
/** Hash of the tree whose blobs were uploaded. */
|
|
73
|
+
hash: string;
|
|
74
|
+
/** putMany items sent; zero when DBFS already had everything. */
|
|
75
|
+
uploadedItems: number;
|
|
76
|
+
}
|
|
77
|
+
export type RehydrateKind = "fresh" | "resumed" | "replayed" | "reloaded";
|
|
78
|
+
export interface RehydrateOutcome {
|
|
79
|
+
outcome: RehydrateKind;
|
|
80
|
+
/** The hash the state store holds afterwards. */
|
|
81
|
+
dbfsHash: string;
|
|
82
|
+
/** True when the local tree was replaced from DBFS. */
|
|
83
|
+
filesChanged: boolean;
|
|
84
|
+
/** `reloaded`: where the local diff that lost to the server was kept. */
|
|
85
|
+
orphanedPath?: string;
|
|
86
|
+
/**
|
|
87
|
+
* `replayed`: false when the guard refused the replay commit (typically the
|
|
88
|
+
* lock is not held yet at boot). The workspace stays unsynced and the next
|
|
89
|
+
* `commit` replays it.
|
|
90
|
+
*/
|
|
91
|
+
replayCommitted?: boolean;
|
|
92
|
+
/** Set when a running journal (a killed native session) was reconciled. */
|
|
93
|
+
interruptedJournal?: {
|
|
94
|
+
changedPaths: string[];
|
|
95
|
+
};
|
|
96
|
+
/** The files written from DBFS and their bytes; zero when none were. */
|
|
97
|
+
downloaded: DownloadStats;
|
|
98
|
+
}
|
|
99
|
+
export interface RehydrateOptions {
|
|
100
|
+
/** Hash-change source for the replay commit. */
|
|
101
|
+
replaySource?: ApplicationHashChangeSource;
|
|
102
|
+
}
|
|
103
|
+
export interface WorkspaceSyncEvents {
|
|
104
|
+
committed: {
|
|
105
|
+
dbfsHash: string;
|
|
106
|
+
commitId?: string;
|
|
107
|
+
};
|
|
108
|
+
"unsynced-changed": {
|
|
109
|
+
unsynced: boolean;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export interface WorkspaceSyncEngine {
|
|
113
|
+
/** Reconciles the local tree, the stored hash and the server on boot. */
|
|
114
|
+
rehydrate(options?: RehydrateOptions): Promise<RehydrateOutcome>;
|
|
115
|
+
/** Uploads blobs for the current tree. Never moves the branch hash. */
|
|
116
|
+
interimFlush(): Promise<FlushResult>;
|
|
117
|
+
/** The turn-end commit. */
|
|
118
|
+
commit(source: ApplicationHashChangeSource, options?: CommitOptions): Promise<CommitResult>;
|
|
119
|
+
/**
|
|
120
|
+
* Reports whether the tree holds drafts, even when that was already
|
|
121
|
+
* reported. For a draft change that moves no hash, such as clearing markers.
|
|
122
|
+
*/
|
|
123
|
+
resendDraftState(options?: Pick<CommitOptions, "settle">): Promise<void>;
|
|
124
|
+
/** True while the tree has changes the branch hash does not include. */
|
|
125
|
+
readonly unsynced: boolean;
|
|
126
|
+
/** The hash last committed or loaded, once known. */
|
|
127
|
+
readonly dbfsHash: string | undefined;
|
|
128
|
+
/** Cheap write notification for file watchers; no hashing. */
|
|
129
|
+
markDirty(): void;
|
|
130
|
+
readonly hashCache: HashCache;
|
|
131
|
+
on<E extends keyof WorkspaceSyncEvents>(event: E, listener: (payload: WorkspaceSyncEvents[E]) => void): () => void;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Creates the sync engine for one workspace. DBFS is the commit point: the
|
|
135
|
+
* engine moves the branch hash only in {@link WorkspaceSyncEngine.commit} (and
|
|
136
|
+
* the rehydrate replay), and the state store is a cache of the last hash it
|
|
137
|
+
* committed or loaded. Every operation is serialized, and each step is
|
|
138
|
+
* ordered so a kill at any point is recovered by `rehydrate`.
|
|
139
|
+
*/
|
|
140
|
+
export declare function createWorkspaceSyncEngine(options: WorkspaceSyncEngineOptions): WorkspaceSyncEngine;
|
|
141
|
+
//# sourceMappingURL=sync-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import * as fsp from "node:fs/promises";
|
|
2
|
+
import { BranchWriteRejectedError, } from "./branch-write-guard.js";
|
|
3
|
+
import { hasDraftsFailClosed } from "./draft-helpers.js";
|
|
4
|
+
import { HashCache } from "./hash-cache.js";
|
|
5
|
+
import { hashLocalDirectory } from "./hash-dir-tree.js";
|
|
6
|
+
import { downloadServerTree, flattenTree, readServerTree, } from "./server-tree.js";
|
|
7
|
+
import { snapshotLocalDirectory, snapshotLocalDirectoryWithDrafts, } from "./snapshot/take-snapshot.js";
|
|
8
|
+
import { collectSnapshotUploadItems, uploadSnapshotItems, } from "./snapshot/upload-snapshot.js";
|
|
9
|
+
import { createSyncTelemetry, inSpan, } from "./sync-telemetry.js";
|
|
10
|
+
const BLOB_BATCH = 64;
|
|
11
|
+
const NOTHING_DOWNLOADED = { files: 0, bytes: 0 };
|
|
12
|
+
/**
|
|
13
|
+
* Creates the sync engine for one workspace. DBFS is the commit point: the
|
|
14
|
+
* engine moves the branch hash only in {@link WorkspaceSyncEngine.commit} (and
|
|
15
|
+
* the rehydrate replay), and the state store is a cache of the last hash it
|
|
16
|
+
* committed or loaded. Every operation is serialized, and each step is
|
|
17
|
+
* ordered so a kill at any point is recovered by `rehydrate`.
|
|
18
|
+
*/
|
|
19
|
+
export function createWorkspaceSyncEngine(options) {
|
|
20
|
+
return new SyncEngine(options);
|
|
21
|
+
}
|
|
22
|
+
class SyncEngine {
|
|
23
|
+
options;
|
|
24
|
+
hashCache;
|
|
25
|
+
telemetry;
|
|
26
|
+
listeners = new Map();
|
|
27
|
+
queue = Promise.resolve();
|
|
28
|
+
committedHash;
|
|
29
|
+
localHash;
|
|
30
|
+
writes = 0;
|
|
31
|
+
committedWrites = 0;
|
|
32
|
+
lastUnsynced = false;
|
|
33
|
+
reportedDraft;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.hashCache = options.hashCache ?? new HashCache();
|
|
37
|
+
this.telemetry = createSyncTelemetry(options.tracer, options.meter);
|
|
38
|
+
this.telemetry.unsynced.record(0);
|
|
39
|
+
}
|
|
40
|
+
get unsynced() {
|
|
41
|
+
return (this.writes !== this.committedWrites ||
|
|
42
|
+
(this.localHash !== undefined && this.localHash !== this.committedHash));
|
|
43
|
+
}
|
|
44
|
+
get dbfsHash() {
|
|
45
|
+
return this.committedHash;
|
|
46
|
+
}
|
|
47
|
+
markDirty() {
|
|
48
|
+
this.writes++;
|
|
49
|
+
this.updateUnsynced();
|
|
50
|
+
}
|
|
51
|
+
on(event, listener) {
|
|
52
|
+
let set = this.listeners.get(event);
|
|
53
|
+
if (!set)
|
|
54
|
+
this.listeners.set(event, (set = new Set()));
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return () => set.delete(listener);
|
|
57
|
+
}
|
|
58
|
+
interimFlush() {
|
|
59
|
+
return this.serialize(() => inSpan(this.telemetry.tracer, "workspace.flush", async (span) => {
|
|
60
|
+
await this.fault("after-write-before-flush");
|
|
61
|
+
const snapshot = await snapshotLocalDirectoryWithDrafts(this.options.root);
|
|
62
|
+
const uploadedItems = await this.upload(snapshot);
|
|
63
|
+
this.localHash = snapshot.hash;
|
|
64
|
+
this.updateUnsynced();
|
|
65
|
+
span.setAttribute("workspace.sync.uploaded_items", uploadedItems);
|
|
66
|
+
return { hash: snapshot.hash, uploadedItems };
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
commit(source, options = {}) {
|
|
70
|
+
const { settle } = options;
|
|
71
|
+
return this.serialize(async () => {
|
|
72
|
+
if (settle)
|
|
73
|
+
await this.settleNow(settle);
|
|
74
|
+
return this.commitNow(source, settle
|
|
75
|
+
? "skip"
|
|
76
|
+
: options.finalizeJournal === false
|
|
77
|
+
? "keep"
|
|
78
|
+
: "finalize", options.checkpoint ?? false);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
resendDraftState(options = {}) {
|
|
82
|
+
const { settle } = options;
|
|
83
|
+
return this.serialize(async () => {
|
|
84
|
+
if (settle)
|
|
85
|
+
await this.settleNow(settle);
|
|
86
|
+
await this.reportDraftState({
|
|
87
|
+
force: true,
|
|
88
|
+
beforeSend: () => this.assertOwnsBranch(),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
rehydrate(options = {}) {
|
|
93
|
+
return this.serialize(() => inSpan(this.telemetry.tracer, "workspace.rehydrate", async (span) => {
|
|
94
|
+
const outcome = await this.rehydrateNow(options);
|
|
95
|
+
span.setAttribute("workspace.sync.outcome", outcome.outcome);
|
|
96
|
+
span.setAttribute("workspace.sync.files_downloaded", outcome.downloaded.files);
|
|
97
|
+
span.setAttribute("workspace.sync.bytes_downloaded", outcome.downloaded.bytes);
|
|
98
|
+
this.telemetry.rehydrateOutcome.add(1, { outcome: outcome.outcome });
|
|
99
|
+
return outcome;
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
async rehydrateNow({ replaySource = "ai:generate:post", }) {
|
|
103
|
+
const { root, transport, state, journal } = this.options;
|
|
104
|
+
const serverHash = await transport.getServerHash();
|
|
105
|
+
const storedHash = (await state.read())?.dbfsHash;
|
|
106
|
+
const local = await this.hashLocalTree();
|
|
107
|
+
const writesAtStart = this.writes;
|
|
108
|
+
let result;
|
|
109
|
+
if (local === undefined) {
|
|
110
|
+
const tree = await readServerTree(transport, serverHash);
|
|
111
|
+
const downloaded = await downloadServerTree({
|
|
112
|
+
transport,
|
|
113
|
+
tree,
|
|
114
|
+
local: undefined,
|
|
115
|
+
root,
|
|
116
|
+
});
|
|
117
|
+
await this.storeLoaded(serverHash, tree);
|
|
118
|
+
result = {
|
|
119
|
+
outcome: "fresh",
|
|
120
|
+
dbfsHash: serverHash,
|
|
121
|
+
filesChanged: true,
|
|
122
|
+
downloaded,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
else if (local.hash === serverHash) {
|
|
126
|
+
this.hashCache.add_directory_recursive(await readServerTree(transport, serverHash));
|
|
127
|
+
if (storedHash !== serverHash)
|
|
128
|
+
await state.write({ dbfsHash: serverHash });
|
|
129
|
+
this.committedHash = serverHash;
|
|
130
|
+
this.localHash = serverHash;
|
|
131
|
+
result = {
|
|
132
|
+
outcome: "resumed",
|
|
133
|
+
dbfsHash: serverHash,
|
|
134
|
+
filesChanged: false,
|
|
135
|
+
downloaded: NOTHING_DOWNLOADED,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
else if (storedHash === serverHash) {
|
|
139
|
+
// The kill landed after a write and before the commit finished: the
|
|
140
|
+
// server never moved past this workspace, so the local tree wins.
|
|
141
|
+
this.hashCache.add_directory_recursive(await readServerTree(transport, serverHash));
|
|
142
|
+
this.committedHash = serverHash;
|
|
143
|
+
this.localHash = local.hash;
|
|
144
|
+
let replayCommitted = true;
|
|
145
|
+
try {
|
|
146
|
+
await this.commitNow(replaySource, "skip");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!(error instanceof BranchWriteRejectedError) ||
|
|
150
|
+
error.reason !== "lock_not_held")
|
|
151
|
+
throw error;
|
|
152
|
+
replayCommitted = false;
|
|
153
|
+
}
|
|
154
|
+
result = {
|
|
155
|
+
outcome: "replayed",
|
|
156
|
+
dbfsHash: this.committedHash ?? serverHash,
|
|
157
|
+
filesChanged: false,
|
|
158
|
+
replayCommitted,
|
|
159
|
+
downloaded: NOTHING_DOWNLOADED,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// The server moved (or this workspace never recorded a hash): keep the
|
|
164
|
+
// local difference where an operator or agent can find it, then load
|
|
165
|
+
// the server tree.
|
|
166
|
+
const tree = await readServerTree(transport, serverHash);
|
|
167
|
+
const orphanedPath = await this.keepOrphan(serverHash, storedHash, tree);
|
|
168
|
+
const downloaded = await downloadServerTree({
|
|
169
|
+
transport,
|
|
170
|
+
tree,
|
|
171
|
+
local,
|
|
172
|
+
root,
|
|
173
|
+
});
|
|
174
|
+
await this.storeLoaded(serverHash, tree);
|
|
175
|
+
result = {
|
|
176
|
+
outcome: "reloaded",
|
|
177
|
+
dbfsHash: serverHash,
|
|
178
|
+
filesChanged: true,
|
|
179
|
+
orphanedPath,
|
|
180
|
+
downloaded,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (journal && (await journal.phase()) === "running") {
|
|
184
|
+
const reconciled = await journal.reconcile(false);
|
|
185
|
+
if (reconciled) {
|
|
186
|
+
result.interruptedJournal = { changedPaths: reconciled.changedPaths };
|
|
187
|
+
// Without the branch the flag is not this workspace's to set; the
|
|
188
|
+
// next commit reports it once the guard passes.
|
|
189
|
+
if (reconciled.changedPaths.length > 0)
|
|
190
|
+
await this.reportDraftState({
|
|
191
|
+
only: true,
|
|
192
|
+
beforeSend: () => this.assertOwnsBranch(),
|
|
193
|
+
}).catch((error) => {
|
|
194
|
+
if (!(error instanceof BranchWriteRejectedError))
|
|
195
|
+
throw error;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (result.outcome !== "replayed" || result.replayCommitted)
|
|
200
|
+
this.committedWrites = writesAtStart;
|
|
201
|
+
this.updateUnsynced();
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
async commitNow(source, journalMode, checkpoint = false) {
|
|
205
|
+
const { root, transport, state, journal, guard } = this.options;
|
|
206
|
+
const started = performance.now();
|
|
207
|
+
let outcome = "error";
|
|
208
|
+
try {
|
|
209
|
+
return await inSpan(this.telemetry.tracer, "workspace.commit", async (span) => {
|
|
210
|
+
span.setAttribute("workspace.sync.source", source);
|
|
211
|
+
span.setAttribute("workspace.sync.checkpoint", checkpoint);
|
|
212
|
+
const writesAtStart = this.writes;
|
|
213
|
+
let journalChangedPaths;
|
|
214
|
+
if (journal && journalMode !== "skip") {
|
|
215
|
+
const reconciled = await journal.reconcile(journalMode === "finalize");
|
|
216
|
+
journalChangedPaths = reconciled?.changedPaths;
|
|
217
|
+
}
|
|
218
|
+
const baseHash = (await state.read())?.dbfsHash;
|
|
219
|
+
const assertCanCommit = async () => {
|
|
220
|
+
try {
|
|
221
|
+
await guard.assertCanCommit(baseHash);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (error instanceof BranchWriteRejectedError) {
|
|
225
|
+
outcome = "rejected";
|
|
226
|
+
span.setAttribute("workspace.sync.rejection", error.reason);
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
// Drafts go before the hash: the server files an
|
|
232
|
+
// `ai:generate:post` as a draft only while it holds isDraft=true.
|
|
233
|
+
// Reported after, a turn's first drafts become a manual checkpoint,
|
|
234
|
+
// and the accept that follows finds nothing new to checkpoint. The
|
|
235
|
+
// guard runs first, so a workspace that lost the branch cannot
|
|
236
|
+
// flip the shared flag.
|
|
237
|
+
await this.reportDraftState({
|
|
238
|
+
only: true,
|
|
239
|
+
force: checkpoint,
|
|
240
|
+
beforeSend: assertCanCommit,
|
|
241
|
+
});
|
|
242
|
+
await this.fault("after-write-before-flush");
|
|
243
|
+
const snapshot = await snapshotLocalDirectoryWithDrafts(root);
|
|
244
|
+
const hash = snapshot.hash;
|
|
245
|
+
this.localHash = hash;
|
|
246
|
+
if (hash === baseHash && !checkpoint) {
|
|
247
|
+
this.committedHash = baseHash;
|
|
248
|
+
await journal?.retireUploaded(hash);
|
|
249
|
+
this.committedWrites = writesAtStart;
|
|
250
|
+
this.updateUnsynced();
|
|
251
|
+
outcome = "unchanged";
|
|
252
|
+
span.setAttribute("workspace.sync.outcome", outcome);
|
|
253
|
+
return {
|
|
254
|
+
dbfsHash: hash,
|
|
255
|
+
moved: false,
|
|
256
|
+
...(journalChangedPaths && { journalChangedPaths }),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const uploadedItems = await this.upload(snapshot);
|
|
260
|
+
span.setAttribute("workspace.sync.uploaded_items", uploadedItems);
|
|
261
|
+
await this.fault("after-put-before-set");
|
|
262
|
+
await assertCanCommit();
|
|
263
|
+
const result = await transport.setHash({ hash, baseHash, source });
|
|
264
|
+
if (result.checkpointSkipped)
|
|
265
|
+
span.setAttribute("workspace.sync.checkpoint_skipped", result.checkpointSkipped);
|
|
266
|
+
await this.fault("after-set-before-state");
|
|
267
|
+
await state.write({ dbfsHash: hash });
|
|
268
|
+
this.committedHash = hash;
|
|
269
|
+
await journal?.retireUploaded(hash);
|
|
270
|
+
this.committedWrites = writesAtStart;
|
|
271
|
+
this.updateUnsynced();
|
|
272
|
+
// Only an accept or reject checkpoint clears the flag, and only
|
|
273
|
+
// once the server has its hash. A plain commit never does: after a
|
|
274
|
+
// failed accept the markers are gone, and clearing then would make
|
|
275
|
+
// the AI tree publishable without its checkpoint.
|
|
276
|
+
if (checkpoint)
|
|
277
|
+
await this.reportDraftState({
|
|
278
|
+
only: false,
|
|
279
|
+
force: true,
|
|
280
|
+
beforeSend: () => this.assertOwnsBranch(),
|
|
281
|
+
});
|
|
282
|
+
outcome = "committed";
|
|
283
|
+
span.setAttribute("workspace.sync.outcome", outcome);
|
|
284
|
+
this.emit("committed", {
|
|
285
|
+
dbfsHash: hash,
|
|
286
|
+
...(result.commitId && { commitId: result.commitId }),
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
dbfsHash: hash,
|
|
290
|
+
moved: hash !== baseHash,
|
|
291
|
+
...(result.commitId && { commitId: result.commitId }),
|
|
292
|
+
...(result.checkpointSkipped && {
|
|
293
|
+
checkpointSkipped: result.checkpointSkipped,
|
|
294
|
+
}),
|
|
295
|
+
...(journalChangedPaths && { journalChangedPaths }),
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
this.telemetry.commitDuration.record((performance.now() - started) / 1000, { outcome });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Puts what DBFS lacks for `snapshot`; returns the items sent. */
|
|
304
|
+
async upload(snapshot) {
|
|
305
|
+
const { transport } = this.options;
|
|
306
|
+
const sent = await uploadSnapshotItems({
|
|
307
|
+
validateMany: (items) => transport.validateMany(items),
|
|
308
|
+
putMany: async (items) => {
|
|
309
|
+
await transport.putMany(items);
|
|
310
|
+
},
|
|
311
|
+
}, collectSnapshotUploadItems(snapshot, this.hashCache));
|
|
312
|
+
this.hashCache.add_directory_recursive(snapshot);
|
|
313
|
+
return sent;
|
|
314
|
+
}
|
|
315
|
+
/** The local tree's hashed state, or undefined when it has no synced entries. */
|
|
316
|
+
async hashLocalTree() {
|
|
317
|
+
try {
|
|
318
|
+
await fsp.access(this.options.root);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
const local = await hashLocalDirectory(this.options.root);
|
|
324
|
+
return local.contents.length === 0 ? undefined : local;
|
|
325
|
+
}
|
|
326
|
+
async storeLoaded(hash, tree) {
|
|
327
|
+
await this.options.state.write({ dbfsHash: hash });
|
|
328
|
+
this.hashCache.add_directory_recursive(tree);
|
|
329
|
+
this.committedHash = hash;
|
|
330
|
+
this.localHash = hash;
|
|
331
|
+
}
|
|
332
|
+
async keepOrphan(serverHash, storedHash, tree) {
|
|
333
|
+
const snapshot = await snapshotLocalDirectory(this.options.root);
|
|
334
|
+
const serverFiles = flattenTree(tree);
|
|
335
|
+
const localFiles = flattenTree(snapshot);
|
|
336
|
+
const contents = new Map();
|
|
337
|
+
const collect = (entries, prefix = "") => {
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
if (entry.type === "d")
|
|
340
|
+
collect(entry.contents, `${prefix}${entry.name}/`);
|
|
341
|
+
else if (entry.type === "-")
|
|
342
|
+
contents.set(entry.hash, entry.contents);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
collect(snapshot.contents);
|
|
346
|
+
const changed = [];
|
|
347
|
+
for (const [entryPath, entry] of localFiles) {
|
|
348
|
+
const server = serverFiles.get(entryPath);
|
|
349
|
+
const same = server !== undefined &&
|
|
350
|
+
(entry.type === "l"
|
|
351
|
+
? server.type === "l" && server.target === entry.target
|
|
352
|
+
: server.type === "-" &&
|
|
353
|
+
server.hash === entry.hash &&
|
|
354
|
+
server.executable === entry.executable);
|
|
355
|
+
if (!same)
|
|
356
|
+
changed.push({ path: entryPath, ...entry });
|
|
357
|
+
}
|
|
358
|
+
const deleted = [...serverFiles.keys()].filter((entryPath) => !localFiles.has(entryPath));
|
|
359
|
+
const blobs = [];
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
for (const entry of changed) {
|
|
362
|
+
if (entry.type !== "-" || seen.has(entry.hash))
|
|
363
|
+
continue;
|
|
364
|
+
seen.add(entry.hash);
|
|
365
|
+
if (this.hashCache.check_file_content(entry.hash))
|
|
366
|
+
continue;
|
|
367
|
+
blobs.push({
|
|
368
|
+
type: "fileContents",
|
|
369
|
+
contents: contents.get(entry.hash).toString("base64"),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
for (let idx = 0; idx < blobs.length; idx += BLOB_BATCH)
|
|
373
|
+
await this.options.transport.putMany(blobs.slice(idx, idx + BLOB_BATCH));
|
|
374
|
+
for (const hash of seen)
|
|
375
|
+
this.hashCache.add_file_content(hash);
|
|
376
|
+
return this.options.state.writeOrphan({
|
|
377
|
+
createdAt: new Date().toISOString(),
|
|
378
|
+
serverHash,
|
|
379
|
+
storedHash,
|
|
380
|
+
localHash: snapshot.hash,
|
|
381
|
+
changed,
|
|
382
|
+
deleted,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Runs `settle` once the guard lets this workspace write the branch, so a
|
|
387
|
+
* refused settle leaves the markers and the server's draft flag as they
|
|
388
|
+
* were. A running journal (its turn-end reconcile failed) is reconciled
|
|
389
|
+
* first, so every marker is there to accept or restore. The journal is then
|
|
390
|
+
* dropped, since its next reconcile would rebuild the markers `settle`
|
|
391
|
+
* removes. Callers hold the queue.
|
|
392
|
+
*/
|
|
393
|
+
async settleNow(settle) {
|
|
394
|
+
await this.assertOwnsBranch();
|
|
395
|
+
const { journal } = this.options;
|
|
396
|
+
if (journal && (await journal.phase()) === "running")
|
|
397
|
+
await journal.reconcile(false);
|
|
398
|
+
await journal?.clear();
|
|
399
|
+
await settle();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The server's draft flag is shared by the branch, so only the workspace
|
|
403
|
+
* the guard lets write the branch may change it: the lock holder, on the
|
|
404
|
+
* hash it last committed.
|
|
405
|
+
*/
|
|
406
|
+
async assertOwnsBranch() {
|
|
407
|
+
await this.options.guard.assertCanCommit((await this.options.state.read())?.dbfsHash);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Sends the tree's draft state when it changed, or always with `force`.
|
|
411
|
+
* With `only`, sends it only when the tree's state is that value;
|
|
412
|
+
* `beforeSend` runs only when it is about to be sent.
|
|
413
|
+
*/
|
|
414
|
+
async reportDraftState(options = {}) {
|
|
415
|
+
const { force = false, only, beforeSend } = options;
|
|
416
|
+
const hasDraft = await (this.options.hasDraft?.() ??
|
|
417
|
+
hasDraftsFailClosed(this.options.root));
|
|
418
|
+
if (only !== undefined && hasDraft !== only)
|
|
419
|
+
return;
|
|
420
|
+
if (!force && hasDraft === this.reportedDraft)
|
|
421
|
+
return;
|
|
422
|
+
await beforeSend?.();
|
|
423
|
+
await this.options.transport.setDraftState(hasDraft);
|
|
424
|
+
this.reportedDraft = hasDraft;
|
|
425
|
+
}
|
|
426
|
+
async fault(point) {
|
|
427
|
+
await this.options.faults?.[point]?.();
|
|
428
|
+
}
|
|
429
|
+
updateUnsynced() {
|
|
430
|
+
const unsynced = this.unsynced;
|
|
431
|
+
if (unsynced === this.lastUnsynced)
|
|
432
|
+
return;
|
|
433
|
+
this.lastUnsynced = unsynced;
|
|
434
|
+
this.telemetry.unsynced.record(unsynced ? 1 : 0);
|
|
435
|
+
this.emit("unsynced-changed", { unsynced });
|
|
436
|
+
}
|
|
437
|
+
emit(event, payload) {
|
|
438
|
+
for (const listener of this.listeners.get(event) ?? [])
|
|
439
|
+
listener(payload);
|
|
440
|
+
}
|
|
441
|
+
serialize(run) {
|
|
442
|
+
const result = this.queue.then(run, run);
|
|
443
|
+
this.queue = result.catch(() => undefined);
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
//# sourceMappingURL=sync-engine.js.map
|