@symbols-cli/cli 0.0.1
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 +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- package/package.json +44 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// The sweep. **Reconcile is the guarantee; events are the optimisation.**
|
|
6
|
+
//
|
|
7
|
+
// Everything the watcher produces is a hint. This file is what makes the two
|
|
8
|
+
// sides agree, and it must produce the same answer whether it was woken by an
|
|
9
|
+
// event, by the 60s timer, or by `symbols sync` after three days offline.
|
|
10
|
+
//
|
|
11
|
+
// ## The order of operations is the safety property
|
|
12
|
+
//
|
|
13
|
+
// Read it as a gauntlet — each gate must pass before the next one is even
|
|
14
|
+
// consulted, and every gate's failure mode is "do nothing", never "assume":
|
|
15
|
+
//
|
|
16
|
+
// F1 the project root exists and is the directory we bound (else OFFLINE)
|
|
17
|
+
// F4 the notebook answers and is ours (else OFFLINE)
|
|
18
|
+
// F3 no two server paths collide under casefold+NFC (else FREEZE the pair)
|
|
19
|
+
// no server path escapes the root (else FREEZE the path)
|
|
20
|
+
// nothing local writes is in the diff at all (`isCliManaged`)
|
|
21
|
+
// the local scan, where a FAILED READ IS NOT AN ABSENCE (else FREEZE the path)
|
|
22
|
+
// F2 the bulk-delete circuit breaker (else BLOCK all deletes)
|
|
23
|
+
// ...and only now, `decide()` per path.
|
|
24
|
+
// S2 the deny-list, enforced at `atomicWrite` — the only place server bytes
|
|
25
|
+
// reach the disk. It is a rule about WRITING, so it lives at the write.
|
|
26
|
+
//
|
|
27
|
+
// F1 and F4 both come before anything can produce a delete, and that ordering is
|
|
28
|
+
// the whole point: an unplugged drive and a deleted notebook each look exactly
|
|
29
|
+
// like "every file is gone" to a client that starts from the diff. The >20
|
|
30
|
+
// breaker does not save you — it sees a mass delete that looks legitimate.
|
|
31
|
+
//
|
|
32
|
+
// ## Ported verbatim, each from an incident
|
|
33
|
+
//
|
|
34
|
+
// * `content_hash` equality as the loop breaker, in BOTH directions — lives in
|
|
35
|
+
// `decide()` (`odin_notebook_writeback.rs:1603-1605`).
|
|
36
|
+
// * **Delete only on an affirmative "missing"** (`settle_vanish:513-519`). Here
|
|
37
|
+
// that is `scanLocal`: an `ENOENT` yields `local = null`, and ANY other error
|
|
38
|
+
// freezes the path instead. `PathStat::Error` kept the row; so do we.
|
|
39
|
+
// * `is_editor_temp` and `IGNORE_SEGMENTS` — `paths.ts`, applied in the local
|
|
40
|
+
// walk and again in the watcher.
|
|
41
|
+
// * The watcher starts BEFORE the initial materialize (`start():1746-1780`) —
|
|
42
|
+
// see `watch.ts`, which is the caller that sequences it.
|
|
43
|
+
//
|
|
44
|
+
// ## Atomic local writes
|
|
45
|
+
//
|
|
46
|
+
// Temp-file-then-rename, always, with `O_NOFOLLOW` on the temp file's DIRECTORY.
|
|
47
|
+
// The apparent contradiction in the plan ("O_NOFOLLOW on the final open" reads as
|
|
48
|
+
// write-in-place) resolves because `rename(2)` **does not follow a symlink at the
|
|
49
|
+
// destination** — it replaces the link itself. So the final path is never opened
|
|
50
|
+
// for writing at all, and the only handle we open with `O_NOFOLLOW` is the
|
|
51
|
+
// directory the temp file goes into.
|
|
52
|
+
//
|
|
53
|
+
// Without this a `kill -9` mid-write leaves a truncated file, and a truncated
|
|
54
|
+
// file has a hash that matches neither side — so the three-way table classifies
|
|
55
|
+
// it as "edited locally" and FREEZES it as the winner. That is silent data loss
|
|
56
|
+
// wearing a conflict's clothes, which is why the plan calls it out separately
|
|
57
|
+
// from the security rules.
|
|
58
|
+
import { promises as fs, constants as C } from "node:fs";
|
|
59
|
+
import { createHash } from "node:crypto";
|
|
60
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
61
|
+
import { decide, baseAfter, baseAfterPush, deleteGuard, conflictSidecarPath, } from "./diff.js";
|
|
62
|
+
import { shouldIgnore, resolveInRoot, isWritableTarget, assertNoSymlinkedParents, findCollisions, isCliManaged, safeDirname, dedupeDirnames, } from "./paths.js";
|
|
63
|
+
import { snapshot, createFile, updateFile, deleteFile, MAX_FILE_BYTES, NotebookGoneError, CasConflictError, ProtectedFileError, RateLimitedError, TransportError, } from "./api.js";
|
|
64
|
+
/** Absent, affirmatively. The ONLY value that may lead to a delete. */
|
|
65
|
+
const ABSENT = { hash: null, size: 0, mtimeMs: 0, freeze: null, bytes: null };
|
|
66
|
+
function sha256(buf) {
|
|
67
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
/** Strict UTF-8. A lossy decode would re-encode to different bytes forever. */
|
|
70
|
+
function decodeUtf8(buf) {
|
|
71
|
+
try {
|
|
72
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read one local path into a `LocalFile`.
|
|
80
|
+
*
|
|
81
|
+
* ⚠ THE PORTED RULE LIVES HERE. `ENOENT` — and only `ENOENT` — produces
|
|
82
|
+
* `hash: null`, which is what the three-way table needs before it will emit a
|
|
83
|
+
* delete. Every other error (`EACCES`, `EIO`, a dead network mount) produces a
|
|
84
|
+
* FROZEN path, exactly as `PathStat::Error` kept the row in `settle_vanish`
|
|
85
|
+
* (:513-519). A client that mapped "I could not read it" to "it is gone" would
|
|
86
|
+
* delete a user's files whenever a permission bit or a disk hiccup said so.
|
|
87
|
+
*
|
|
88
|
+
* ## The `(size, mtime)` fast path is what makes a 60s sweep affordable
|
|
89
|
+
*
|
|
90
|
+
* The container defaults its reconcile OFF because every settle was a `docker
|
|
91
|
+
* exec`. Locally the plan turns it ON by default, and the whole justification is
|
|
92
|
+
* that a no-op sweep of 5,000 files is **one `stat` each** — not 5,000 file
|
|
93
|
+
* reads. So when the ledger's cached `(size, mtimeMs)` still match, the cached
|
|
94
|
+
* hash is reused and the file is never opened.
|
|
95
|
+
*
|
|
96
|
+
* This is also why `ledger.ts` stores `mtime_ms` as REAL and has a test for the
|
|
97
|
+
* fractional round trip: truncating it to a whole second makes every sweep miss
|
|
98
|
+
* the cache and re-hash the entire project.
|
|
99
|
+
*
|
|
100
|
+
* On a cache hit `bytes` is `null` — the caller reads the file only when it
|
|
101
|
+
* actually needs the content, which is the push path and nothing else.
|
|
102
|
+
*/
|
|
103
|
+
async function statLocal(root, rel, cache) {
|
|
104
|
+
const abs = join(root, rel);
|
|
105
|
+
let st;
|
|
106
|
+
try {
|
|
107
|
+
st = await fs.lstat(abs);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err.code;
|
|
111
|
+
if (code === "ENOENT")
|
|
112
|
+
return ABSENT;
|
|
113
|
+
return { ...ABSENT, freeze: `cannot stat '${rel}': ${code ?? String(err)}` };
|
|
114
|
+
}
|
|
115
|
+
if (st.isSymbolicLink()) {
|
|
116
|
+
// A symlink is not content we can round-trip: pushing it would upload the
|
|
117
|
+
// link target's bytes under the link's name, and pulling would replace the
|
|
118
|
+
// link with a regular file. Neither is what the user meant.
|
|
119
|
+
return { ...ABSENT, freeze: `'${rel}' is a symlink; not synced` };
|
|
120
|
+
}
|
|
121
|
+
if (st.isDirectory())
|
|
122
|
+
return ABSENT;
|
|
123
|
+
if (!st.isFile())
|
|
124
|
+
return { ...ABSENT, freeze: `'${rel}' is not a regular file` };
|
|
125
|
+
// ⚠ Strict equality on BOTH, and only when a hash was actually cached. A file
|
|
126
|
+
// rewritten with identical size within the same mtime tick is the known blind
|
|
127
|
+
// spot of every (size, mtime) cache; the 60s sweep does not close it, and the
|
|
128
|
+
// WATCHER is what does — an event marks the path dirty regardless of what stat
|
|
129
|
+
// says, and `syncProject` is called with it in `only`.
|
|
130
|
+
if (cache &&
|
|
131
|
+
cache.hash !== null &&
|
|
132
|
+
cache.size === st.size &&
|
|
133
|
+
cache.mtimeMs === st.mtimeMs) {
|
|
134
|
+
return { hash: cache.hash, size: st.size, mtimeMs: st.mtimeMs, freeze: null, bytes: null };
|
|
135
|
+
}
|
|
136
|
+
let bytes;
|
|
137
|
+
try {
|
|
138
|
+
bytes = await fs.readFile(abs);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
const code = err.code;
|
|
142
|
+
// A race with a delete IS an affirmative absence; anything else is not.
|
|
143
|
+
if (code === "ENOENT")
|
|
144
|
+
return ABSENT;
|
|
145
|
+
return { ...ABSENT, freeze: `cannot read '${rel}': ${code ?? String(err)}` };
|
|
146
|
+
}
|
|
147
|
+
const base = { size: st.size, mtimeMs: st.mtimeMs, bytes };
|
|
148
|
+
if (bytes.byteLength > MAX_FILE_BYTES) {
|
|
149
|
+
// Loud, not silent. This is precisely the container's worst property —
|
|
150
|
+
// `writeback_one:1563-1568` warns that such a file "lives only on the
|
|
151
|
+
// container" and then returns Ok(()), so weeks of plots and parquet exist
|
|
152
|
+
// nowhere else with nothing in the UI saying so. A frozen path shows up in
|
|
153
|
+
// `symbols status`.
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
hash: sha256(bytes),
|
|
157
|
+
freeze: `'${rel}' is ${bytes.byteLength} bytes, over the ${MAX_FILE_BYTES}-byte server limit — not synced`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (decodeUtf8(bytes) === null) {
|
|
161
|
+
// Mirrors `ReadResult::Binary` (:1557-1559), which skips deliberately —
|
|
162
|
+
// `project_files.content` is a TEXT column and cannot hold these bytes.
|
|
163
|
+
return { ...base, hash: sha256(bytes), freeze: `'${rel}' is not valid UTF-8 — not synced` };
|
|
164
|
+
}
|
|
165
|
+
return { ...base, hash: sha256(bytes), freeze: null };
|
|
166
|
+
}
|
|
167
|
+
/** Every syncable path under `root`, project-relative, POSIX-separated. */
|
|
168
|
+
async function walk(root) {
|
|
169
|
+
const out = [];
|
|
170
|
+
const recurse = async (dir) => {
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = await fs.readdir(dir, { withFileTypes: true, encoding: "utf8" });
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// An unreadable directory is not an empty one. Skipping it means its
|
|
177
|
+
// files never appear as `local = null`, so they can never be deleted on
|
|
178
|
+
// the strength of a failure to read.
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
const abs = join(dir, e.name);
|
|
183
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
184
|
+
if (shouldIgnore(rel))
|
|
185
|
+
continue;
|
|
186
|
+
if (e.isDirectory())
|
|
187
|
+
await recurse(abs);
|
|
188
|
+
else if (e.isFile() || e.isSymbolicLink())
|
|
189
|
+
out.push(rel);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
await recurse(root);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
// ── the atomic writer ────────────────────────────────────────────────────────
|
|
196
|
+
const O_DIRECTORY = C["O_DIRECTORY"] ?? 0;
|
|
197
|
+
const O_NOFOLLOW = C["O_NOFOLLOW"] ?? 0;
|
|
198
|
+
/**
|
|
199
|
+
* Write `content` to `<root>/<rel>` atomically, or refuse.
|
|
200
|
+
*
|
|
201
|
+
* Refuses on every class `paths.ts` names, then writes temp-and-renames. The
|
|
202
|
+
* caller must treat a `false` return as "the path is frozen", never as "the
|
|
203
|
+
* write failed, retry".
|
|
204
|
+
*/
|
|
205
|
+
export async function atomicWrite(root, rel, content) {
|
|
206
|
+
const resolved = resolveInRoot(root, rel);
|
|
207
|
+
if (!resolved.ok || !resolved.abs)
|
|
208
|
+
return { ok: false, reason: resolved.reason ?? "unsafe path" };
|
|
209
|
+
const writable = isWritableTarget(rel);
|
|
210
|
+
if (!writable.ok)
|
|
211
|
+
return { ok: false, reason: writable.reason ?? "refused by the deny-list" };
|
|
212
|
+
const abs = resolved.abs;
|
|
213
|
+
const dir = dirname(abs);
|
|
214
|
+
await fs.mkdir(dir, { recursive: true });
|
|
215
|
+
const parents = await assertNoSymlinkedParents(root, abs);
|
|
216
|
+
if (!parents.ok)
|
|
217
|
+
return { ok: false, reason: parents.reason ?? "symlinked parent" };
|
|
218
|
+
// O_NOFOLLOW ON THE DIRECTORY. Fails with ELOOP if `dir` is a symlink, which
|
|
219
|
+
// narrows the window `assertNoSymlinkedParents` (a lexical pass over lstat
|
|
220
|
+
// results) leaves open. Full TOCTOU-proofing is explicitly not required: the
|
|
221
|
+
// only process that can win that race is the same-uid agent, which can write
|
|
222
|
+
// anywhere directly. The parties this defends against are the server and
|
|
223
|
+
// imported content.
|
|
224
|
+
let dirFd = null;
|
|
225
|
+
try {
|
|
226
|
+
dirFd = await fs.open(dir, C.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
|
|
227
|
+
const viaFd = await dirFd.stat();
|
|
228
|
+
const viaPath = await fs.lstat(dir);
|
|
229
|
+
if (viaFd.dev !== viaPath.dev || viaFd.ino !== viaPath.ino) {
|
|
230
|
+
return { ok: false, reason: `'${dir}' changed identity mid-write` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const code = err.code;
|
|
235
|
+
return { ok: false, reason: `cannot open '${dir}' safely (${code ?? String(err)})` };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await dirFd?.close().catch(() => { });
|
|
239
|
+
}
|
|
240
|
+
// A distinctive prefix so a crashed run's leftovers are identifiable, and so
|
|
241
|
+
// `shouldIgnore` has something to match if one is ever left behind.
|
|
242
|
+
const tmp = join(dir, `.symbols-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
243
|
+
let handle = null;
|
|
244
|
+
try {
|
|
245
|
+
// O_EXCL means this cannot land on an existing symlink; O_NOFOLLOW is belt
|
|
246
|
+
// and braces for the same.
|
|
247
|
+
handle = await fs.open(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | O_NOFOLLOW, 0o644);
|
|
248
|
+
await handle.writeFile(content, "utf8");
|
|
249
|
+
// Durability before the rename. Without it a power loss can expose the
|
|
250
|
+
// renamed name with unflushed (zero-length) content — the "never a prefix"
|
|
251
|
+
// property the kill-9 row asserts.
|
|
252
|
+
await handle.sync();
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
await handle?.close().catch(() => { });
|
|
256
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
257
|
+
return { ok: false, reason: `write failed: ${err.message}` };
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await handle?.close().catch(() => { });
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
// ⚠ `rename(2)` REPLACES A SYMLINK AT THE DESTINATION rather than following
|
|
264
|
+
// it, so even a hostile `<root>/notes.md -> /etc/passwd` is overwritten as a
|
|
265
|
+
// link, not written through. That is why the final path is never opened.
|
|
266
|
+
await fs.rename(tmp, abs);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
270
|
+
return { ok: false, reason: `rename failed: ${err.message}` };
|
|
271
|
+
}
|
|
272
|
+
return { ok: true };
|
|
273
|
+
}
|
|
274
|
+
// ── the sweep ────────────────────────────────────────────────────────────────
|
|
275
|
+
function offline(project, reason) {
|
|
276
|
+
return {
|
|
277
|
+
project: project.name,
|
|
278
|
+
offline: true,
|
|
279
|
+
offlineReason: reason,
|
|
280
|
+
pulled: 0,
|
|
281
|
+
pushed: 0,
|
|
282
|
+
localDeletes: 0,
|
|
283
|
+
serverDeletes: 0,
|
|
284
|
+
conflicts: 0,
|
|
285
|
+
frozen: [],
|
|
286
|
+
apiWrites: 0,
|
|
287
|
+
deletesBlocked: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* F1 — the project-root existence gate.
|
|
292
|
+
*
|
|
293
|
+
* An absent root means the project is OFFLINE, not emptied. This is the guard
|
|
294
|
+
* that stops an unplugged external drive or an evicted iCloud folder from wiping
|
|
295
|
+
* an account, and it has to run before the diff because the diff cannot tell the
|
|
296
|
+
* difference: "no local files" is what both look like.
|
|
297
|
+
*
|
|
298
|
+
* The inode check is the second half. `project.json` is agent-writable and
|
|
299
|
+
* Finder-copyable, so a directory that exists at the bound path is not
|
|
300
|
+
* necessarily the directory we bound.
|
|
301
|
+
*/
|
|
302
|
+
export async function rootGate(project) {
|
|
303
|
+
let st;
|
|
304
|
+
try {
|
|
305
|
+
st = await fs.lstat(project.root);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const code = err.code;
|
|
309
|
+
return code === "ENOENT"
|
|
310
|
+
? `project directory ${project.root} is not present — treating the project as OFFLINE. ` +
|
|
311
|
+
`No file was deleted. If you moved it, run \`symbols project detach\` and \`symbols up\` there.`
|
|
312
|
+
: `project directory ${project.root} is unreadable (${code ?? "unknown error"}) — treating the project as OFFLINE`;
|
|
313
|
+
}
|
|
314
|
+
if (!st.isDirectory()) {
|
|
315
|
+
return `${project.root} is not a directory — treating the project as OFFLINE`;
|
|
316
|
+
}
|
|
317
|
+
if (project.inode !== null && (st.ino !== project.inode || st.dev !== project.deviceNo)) {
|
|
318
|
+
return (`${project.root} is a DIFFERENT directory than the one bound to this notebook ` +
|
|
319
|
+
`(inode ${st.ino} vs ${project.inode}). Sync is frozen rather than guessing which is real — ` +
|
|
320
|
+
`run \`symbols project detach\` if you replaced it deliberately.`);
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
export async function syncProject(opts) {
|
|
325
|
+
const { ledger, project } = opts;
|
|
326
|
+
const log = opts.log ?? (() => { });
|
|
327
|
+
const root = project.root;
|
|
328
|
+
// ── F1 ─────────────────────────────────────────────────────────────────────
|
|
329
|
+
if (project.frozenReason)
|
|
330
|
+
return offline(project, project.frozenReason);
|
|
331
|
+
const rootProblem = await rootGate(project);
|
|
332
|
+
if (rootProblem) {
|
|
333
|
+
ledger.freezeProject(project.id, rootProblem);
|
|
334
|
+
return offline(project, rootProblem);
|
|
335
|
+
}
|
|
336
|
+
// ── F4 ─────────────────────────────────────────────────────────────────────
|
|
337
|
+
//
|
|
338
|
+
// A notebook 404 or ownership failure reads NAIVELY as "all files missing",
|
|
339
|
+
// and the >20 circuit breaker is blind to it — it sees a mass delete that
|
|
340
|
+
// looks like a legitimate cleanup. So it never reaches the breaker: the
|
|
341
|
+
// project freezes offline and zero deletes are computed.
|
|
342
|
+
let server;
|
|
343
|
+
try {
|
|
344
|
+
server = await snapshot(project.notebookId);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (err instanceof NotebookGoneError) {
|
|
348
|
+
const reason = err.message;
|
|
349
|
+
ledger.freezeProject(project.id, reason);
|
|
350
|
+
return offline(project, reason);
|
|
351
|
+
}
|
|
352
|
+
if (err instanceof RateLimitedError) {
|
|
353
|
+
return offline(project, `${err.message} — no files were touched`);
|
|
354
|
+
}
|
|
355
|
+
if (err instanceof TransportError) {
|
|
356
|
+
// A failed read is never an absence. Same rule, one layer up.
|
|
357
|
+
return offline(project, `could not reach the server (${err.status}) — no files were touched`);
|
|
358
|
+
}
|
|
359
|
+
return offline(project, `could not reach the server: ${err.message}`);
|
|
360
|
+
}
|
|
361
|
+
// A project that WAS frozen and now answers is unfrozen — otherwise a
|
|
362
|
+
// transient 404 would strand the project forever.
|
|
363
|
+
ledger.unfreezeProject(project.id);
|
|
364
|
+
const serverByPath = new Map();
|
|
365
|
+
for (const f of server.files)
|
|
366
|
+
serverByPath.set(f.path, f);
|
|
367
|
+
const localPaths = await walk(root);
|
|
368
|
+
const ledgerRows = ledger.files(project.id);
|
|
369
|
+
const universe = new Set([
|
|
370
|
+
...serverByPath.keys(),
|
|
371
|
+
...localPaths,
|
|
372
|
+
...ledgerRows.map((r) => r.path),
|
|
373
|
+
]);
|
|
374
|
+
const frozen = [];
|
|
375
|
+
const freeze = (path, reason) => {
|
|
376
|
+
frozen.push({ path, reason });
|
|
377
|
+
if (!opts.dryRun)
|
|
378
|
+
ledger.freezeFile(project.id, path, reason);
|
|
379
|
+
};
|
|
380
|
+
// ── F3 ─────────────────────────────────────────────────────────────────────
|
|
381
|
+
//
|
|
382
|
+
// Postgres holds `A.py` and `a.py` as two rows; default APFS collapses them to
|
|
383
|
+
// one file, and the survivor's content is then pushed back to BOTH rows. No
|
|
384
|
+
// delete is produced, so the circuit breaker never sees it. Freeze the group;
|
|
385
|
+
// never pick a winner.
|
|
386
|
+
const collisions = findCollisions([...universe]);
|
|
387
|
+
const collided = new Set();
|
|
388
|
+
for (const c of collisions) {
|
|
389
|
+
for (const p of c.paths) {
|
|
390
|
+
collided.add(p);
|
|
391
|
+
freeze(p, `collides with ${c.paths.filter((o) => o !== p).join(", ")} on this filesystem ` +
|
|
392
|
+
`(case/Unicode-insensitive). Both are frozen; rename one on the server to resolve.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// A watcher batch narrows what we ACT on; it never narrows the guards above,
|
|
396
|
+
// which all need the whole picture to be meaningful.
|
|
397
|
+
const scope = opts.only ? new Set(opts.only) : null;
|
|
398
|
+
const planned = [];
|
|
399
|
+
let localDeleteCount = 0;
|
|
400
|
+
let serverDeleteCount = 0;
|
|
401
|
+
for (const path of universe) {
|
|
402
|
+
if (collided.has(path))
|
|
403
|
+
continue;
|
|
404
|
+
if (shouldIgnore(path))
|
|
405
|
+
continue;
|
|
406
|
+
// ── escape ─────────────────────────────────────────────────────────────
|
|
407
|
+
//
|
|
408
|
+
// Purely lexical, so it is cheap enough to run on every path of a 5,000-file
|
|
409
|
+
// project. A server path that escapes the root can never be acted on in
|
|
410
|
+
// either direction.
|
|
411
|
+
const resolved = resolveInRoot(root, path);
|
|
412
|
+
if (!resolved.ok) {
|
|
413
|
+
freeze(path, `refused: ${resolved.reason}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// ── files we own ───────────────────────────────────────────────────────
|
|
417
|
+
//
|
|
418
|
+
// ⚠ THE S2 DENY-LIST IS NOT APPLIED HERE, DELIBERATELY. It is a rule about
|
|
419
|
+
// WRITING server content, and it is enforced where that happens
|
|
420
|
+
// (`atomicWrite`). Running it over the whole universe froze `Makefile`,
|
|
421
|
+
// `package.json` and `conftest.py` — the user's OWN project files — so they
|
|
422
|
+
// could never be pushed, and froze `<root>/.symbols/project.json`, a file
|
|
423
|
+
// this CLI writes itself, on every sweep of every project.
|
|
424
|
+
//
|
|
425
|
+
// `isCliManaged` is the right question for this loop: files something local
|
|
426
|
+
// writes are excluded from the diff in both directions. A SERVER row at one
|
|
427
|
+
// of those paths is still surfaced — the plan's answer to its own
|
|
428
|
+
// "`CLAUDE.md` is also a legal `project_files` row" gap.
|
|
429
|
+
if (isCliManaged(path)) {
|
|
430
|
+
if (serverByPath.has(path)) {
|
|
431
|
+
freeze(path, `the server has a file at '${path}', which this CLI manages locally. It is NOT ` +
|
|
432
|
+
`written to disk (it would become configuration a tool executes) and the local ` +
|
|
433
|
+
`copy is NOT pushed. Rename the server row to sync it.`);
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const row = ledgerRows.find((r) => r.path === path);
|
|
438
|
+
const srv = serverByPath.get(path);
|
|
439
|
+
// The ledger's cache. ⚠ Only the FIRST stat of a path may use it — the
|
|
440
|
+
// re-reads below (after a write, before a delete) exist precisely to learn
|
|
441
|
+
// what changed, and handing them a cache would answer with what we already
|
|
442
|
+
// believed.
|
|
443
|
+
const local = await statLocal(root, path, row ? { size: row.size, mtimeMs: row.mtimeMs, hash: row.localHash } : undefined);
|
|
444
|
+
if (local.freeze) {
|
|
445
|
+
freeze(path, local.freeze);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const base = row ? row.base : null;
|
|
449
|
+
const action = decide({ path, server: srv?.contentHash ?? null, local: local.hash, base });
|
|
450
|
+
if (action.kind === "pull-delete")
|
|
451
|
+
localDeleteCount += 1;
|
|
452
|
+
if (action.kind === "push-delete")
|
|
453
|
+
serverDeleteCount += 1;
|
|
454
|
+
if (scope && !scope.has(path))
|
|
455
|
+
continue;
|
|
456
|
+
planned.push({
|
|
457
|
+
path,
|
|
458
|
+
action,
|
|
459
|
+
local,
|
|
460
|
+
server: srv,
|
|
461
|
+
base,
|
|
462
|
+
wasFrozen: row?.frozenReason != null,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// ── F2 ─────────────────────────────────────────────────────────────────────
|
|
466
|
+
//
|
|
467
|
+
// Counted over the WHOLE project, not the scoped subset — a watcher batch that
|
|
468
|
+
// happens to contain 19 deletes must not slip under a limit the full picture
|
|
469
|
+
// would fail. `rm -rf`, a bad `git clean`, and a real cleanup are
|
|
470
|
+
// indistinguishable from here.
|
|
471
|
+
const guard = deleteGuard({
|
|
472
|
+
totalTracked: universe.size,
|
|
473
|
+
deletions: localDeleteCount + serverDeleteCount,
|
|
474
|
+
confirmed: opts.confirmDeletes ?? false,
|
|
475
|
+
});
|
|
476
|
+
let pulled = 0;
|
|
477
|
+
let pushed = 0;
|
|
478
|
+
let localDeletes = 0;
|
|
479
|
+
let serverDeletes = 0;
|
|
480
|
+
let conflicts = 0;
|
|
481
|
+
let apiWrites = 0;
|
|
482
|
+
for (const p of planned) {
|
|
483
|
+
const { path, action, local, server: srv } = p;
|
|
484
|
+
const abs = join(root, path);
|
|
485
|
+
if ((action.kind === "pull-delete" || action.kind === "push-delete") && !guard.allowed) {
|
|
486
|
+
freeze(path, `delete withheld: ${guard.reason}`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
switch (action.kind) {
|
|
490
|
+
case "none": {
|
|
491
|
+
// ⚠ THE LOOP BREAKER'S OTHER HALF. `S === L` means nothing to transfer,
|
|
492
|
+
// but B may still disagree (a crash lost the confirm, or the same edit
|
|
493
|
+
// was made on both sides). Advancing B here is the repair, and it is
|
|
494
|
+
// what makes a second sweep issue ZERO API writes.
|
|
495
|
+
if (!opts.dryRun && local.hash !== null && p.base !== local.hash) {
|
|
496
|
+
ledger.confirm({
|
|
497
|
+
projectId: project.id,
|
|
498
|
+
path,
|
|
499
|
+
base: local.hash,
|
|
500
|
+
fileId: srv?.id ?? null,
|
|
501
|
+
size: local.size,
|
|
502
|
+
mtimeMs: local.mtimeMs,
|
|
503
|
+
localHash: local.hash,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// ⚠ AND THE FREEZE CLEARS HERE. Without this a conflict is PERMANENT:
|
|
507
|
+
// the user merges the two copies, both sides agree, and `symbols status`
|
|
508
|
+
// still reports the path as frozen forever. A status that never goes
|
|
509
|
+
// green is a status nobody reads.
|
|
510
|
+
//
|
|
511
|
+
// This is also why a frozen path is re-decided on every sweep rather
|
|
512
|
+
// than skipped — skipping it would mean nothing ever notices that it
|
|
513
|
+
// was resolved. Re-deciding costs one hash and issues no writes, because
|
|
514
|
+
// an unresolved conflict returns `conflict` again and a resolved one
|
|
515
|
+
// returns `none`.
|
|
516
|
+
if (!opts.dryRun && p.wasFrozen)
|
|
517
|
+
ledger.unfreezeFile(project.id, path);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case "pull": {
|
|
521
|
+
if (!srv)
|
|
522
|
+
break;
|
|
523
|
+
if (srv.skipped || srv.content === null) {
|
|
524
|
+
// We know it changed (the hash says so) and we cannot fetch it. Say so
|
|
525
|
+
// rather than skipping forever, which is the failure `skipped`'s hash
|
|
526
|
+
// was added to make visible in the first place.
|
|
527
|
+
freeze(path, `server copy is ${srv.size ?? "?"} bytes, over the ${MAX_FILE_BYTES}-byte transfer limit — not pulled`);
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (opts.dryRun) {
|
|
531
|
+
pulled += 1;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
const w = await atomicWrite(root, path, srv.content);
|
|
535
|
+
if (!w.ok) {
|
|
536
|
+
freeze(path, w.reason);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
pulled += 1;
|
|
540
|
+
// Re-read AFTER the write. If something moved under us the write is
|
|
541
|
+
// still on disk, but B must not record a state we cannot vouch for.
|
|
542
|
+
const after = await statLocal(root, path);
|
|
543
|
+
const next = baseAfter({
|
|
544
|
+
hashAtDecision: srv.contentHash,
|
|
545
|
+
hashAfter: after.hash,
|
|
546
|
+
confirmed: true,
|
|
547
|
+
});
|
|
548
|
+
if (next !== "hold") {
|
|
549
|
+
ledger.confirm({
|
|
550
|
+
projectId: project.id,
|
|
551
|
+
path,
|
|
552
|
+
base: next,
|
|
553
|
+
fileId: srv.id,
|
|
554
|
+
size: after.size,
|
|
555
|
+
mtimeMs: after.mtimeMs,
|
|
556
|
+
localHash: after.hash,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case "push": {
|
|
562
|
+
if (local.hash === null)
|
|
563
|
+
break;
|
|
564
|
+
// ⚠ `bytes` is null on a `(size, mtime)` CACHE HIT — the fast path never
|
|
565
|
+
// opened the file. A push is the one action that needs the content, so
|
|
566
|
+
// this is where it is read, and it is re-verified against the hash we
|
|
567
|
+
// decided on: if the file moved between the stat and this read, pushing
|
|
568
|
+
// the new bytes under the old hash's decision would send content the
|
|
569
|
+
// diff never saw.
|
|
570
|
+
let bytes = local.bytes;
|
|
571
|
+
if (bytes === null) {
|
|
572
|
+
// The seam sits BEFORE the read: the guard's whole subject is bytes
|
|
573
|
+
// that moved between the scan and this read, so a hook firing after
|
|
574
|
+
// the read cannot express the race at all.
|
|
575
|
+
await opts.raceHook?.("before-push", path);
|
|
576
|
+
try {
|
|
577
|
+
bytes = await fs.readFile(join(root, path));
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
freeze(path, `cannot read '${path}' to push it: ${err.message}`);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
if (sha256(bytes) !== local.hash) {
|
|
584
|
+
log(` ~ ${path} changed between the scan and the push — re-diffing next sweep`);
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const text = decodeUtf8(bytes);
|
|
589
|
+
if (text === null) {
|
|
590
|
+
freeze(path, `'${path}' is not valid UTF-8 — not synced`);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
if (opts.dryRun) {
|
|
594
|
+
pushed += 1;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
let fileId = srv?.id ?? null;
|
|
598
|
+
try {
|
|
599
|
+
if (fileId === null) {
|
|
600
|
+
const created = await createFile(project.notebookId, path, text);
|
|
601
|
+
fileId = created.id;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
// ⚠ CAS ON EVERY PUSH. The base is what the SERVER had when we
|
|
605
|
+
// diffed, not our ledger base — those differ exactly when the server
|
|
606
|
+
// moved, which is the case the CAS exists to catch.
|
|
607
|
+
await updateFile(fileId, text, srv?.contentHash ?? null);
|
|
608
|
+
}
|
|
609
|
+
apiWrites += 1;
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
if (err instanceof CasConflictError) {
|
|
613
|
+
// The server moved between our read and our write. Do NOT retry: the
|
|
614
|
+
// next sweep re-diffs with fresh hashes and will produce a proper
|
|
615
|
+
// conflict if both sides really moved.
|
|
616
|
+
freeze(path, `server copy changed mid-push — will re-diff next sweep`);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
if (err instanceof ProtectedFileError) {
|
|
620
|
+
freeze(path, err.message);
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
if (err instanceof RateLimitedError) {
|
|
624
|
+
freeze(path, err.message);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
freeze(path, `push failed: ${err.message}`);
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
pushed += 1;
|
|
631
|
+
// ⚠ RE-READ L AFTER THE WRITE, and record what the SERVER confirmed.
|
|
632
|
+
//
|
|
633
|
+
// `baseAfterPush` — not `baseAfter` — because the two answer different
|
|
634
|
+
// questions and the pull rule produces a spurious conflict here. The
|
|
635
|
+
// server acknowledged `local.hash`, so `B` is that, whatever the local
|
|
636
|
+
// file did during the request. If it moved, the next sweep sees
|
|
637
|
+
// `S == B != L` and pushes again, which is the plan's stated outcome.
|
|
638
|
+
const after = await statLocal(root, path);
|
|
639
|
+
const next = baseAfterPush({ hashAtDecision: local.hash, confirmed: true });
|
|
640
|
+
if (next !== "hold") {
|
|
641
|
+
ledger.confirm({
|
|
642
|
+
projectId: project.id,
|
|
643
|
+
path,
|
|
644
|
+
base: next,
|
|
645
|
+
fileId,
|
|
646
|
+
size: after.size,
|
|
647
|
+
mtimeMs: after.mtimeMs,
|
|
648
|
+
localHash: after.hash,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (after.hash !== local.hash) {
|
|
652
|
+
log(` ~ ${path} changed while pushing — it will be pushed again next sweep`);
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
case "pull-delete": {
|
|
657
|
+
if (opts.dryRun) {
|
|
658
|
+
localDeletes += 1;
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
// Re-stat immediately before unlinking. Between the plan and here the
|
|
662
|
+
// user may have re-created the file, and deleting it then would destroy
|
|
663
|
+
// work that never entered the diff.
|
|
664
|
+
await opts.raceHook?.("before-unlink", path);
|
|
665
|
+
const now = await statLocal(root, path);
|
|
666
|
+
if (now.hash !== local.hash) {
|
|
667
|
+
log(` ~ ${path} changed since it was planned for deletion — skipped`);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
await fs.rm(abs, { force: true });
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
freeze(path, `could not delete '${path}': ${err.message}`);
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
ledger.tombstone(project.id, path);
|
|
678
|
+
localDeletes += 1;
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
case "push-delete": {
|
|
682
|
+
if (!srv) {
|
|
683
|
+
// No row to delete: it is already gone server-side. Tombstone so the
|
|
684
|
+
// path is not re-pulled — "no row" and "confirmed deleted" are
|
|
685
|
+
// different states, and only the second one stays deleted.
|
|
686
|
+
if (!opts.dryRun)
|
|
687
|
+
ledger.tombstone(project.id, path);
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
if (opts.dryRun) {
|
|
691
|
+
serverDeletes += 1;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
await deleteFile(srv.id);
|
|
696
|
+
apiWrites += 1;
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
if (err instanceof ProtectedFileError) {
|
|
700
|
+
// The file backs a live regime or widget. RESTORE the local copy —
|
|
701
|
+
// the user's delete cannot be honoured and leaving the local side
|
|
702
|
+
// missing would silently diverge from a server that still has it.
|
|
703
|
+
if (srv.content !== null) {
|
|
704
|
+
const w = await atomicWrite(root, path, srv.content);
|
|
705
|
+
if (w.ok)
|
|
706
|
+
log(` + ${path} restored (${err.message})`);
|
|
707
|
+
}
|
|
708
|
+
freeze(path, err.message);
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
freeze(path, `server delete failed: ${err.message}`);
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
ledger.tombstone(project.id, path);
|
|
715
|
+
serverDeletes += 1;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case "conflict": {
|
|
719
|
+
conflicts += 1;
|
|
720
|
+
// KEEP LOCAL. Write the server copy BESIDE it and freeze the path. Never
|
|
721
|
+
// last-writer-wins: on a laptop "offline three days" is normal, and LWW
|
|
722
|
+
// there silently eats three days of work.
|
|
723
|
+
const sidecar = conflictSidecarPath(path);
|
|
724
|
+
let sidecarNote = ` Your file is untouched; the server copy is at ${sidecar}.`;
|
|
725
|
+
if (!opts.dryRun && srv?.content != null) {
|
|
726
|
+
const w = await atomicWrite(root, sidecar, srv.content);
|
|
727
|
+
if (!w.ok) {
|
|
728
|
+
// ⚠ A FAILED SIDECAR WRITE MUST CHANGE THE MESSAGE, not just log.
|
|
729
|
+
//
|
|
730
|
+
// An earlier version logged and froze with the standard text — so on
|
|
731
|
+
// a full disk, or a permission problem, the user was told "the server
|
|
732
|
+
// copy is at f.server.py" about a file that had never been written.
|
|
733
|
+
// Sending someone to a path that does not exist during a conflict is
|
|
734
|
+
// worse than saying nothing, and the soak surfaced it (the log
|
|
735
|
+
// callback is optional, so on most callers it went nowhere at all).
|
|
736
|
+
sidecarNote =
|
|
737
|
+
` Your file is untouched, but the server copy could NOT be written to ` +
|
|
738
|
+
`${sidecar} (${w.reason}) — it is still on the server.`;
|
|
739
|
+
log(` ! could not write ${sidecar}: ${w.reason}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else if (srv?.content == null) {
|
|
743
|
+
sidecarNote =
|
|
744
|
+
` Your file is untouched. The server copy was not written beside it ` +
|
|
745
|
+
`(its content is not available — it may be over the size limit).`;
|
|
746
|
+
}
|
|
747
|
+
freeze(path, `${action.reason}.${sidecarNote} Resolve, then \`symbols sync\`.`);
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case "freeze": {
|
|
751
|
+
freeze(path, action.reason);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
project: project.name,
|
|
758
|
+
offline: false,
|
|
759
|
+
offlineReason: null,
|
|
760
|
+
pulled,
|
|
761
|
+
pushed,
|
|
762
|
+
localDeletes,
|
|
763
|
+
serverDeletes,
|
|
764
|
+
conflicts,
|
|
765
|
+
frozen,
|
|
766
|
+
apiWrites,
|
|
767
|
+
deletesBlocked: guard.allowed ? null : (guard.reason ?? "deletes withheld"),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/** `<root>/.symbols/project.json` — the identity anchor. */
|
|
771
|
+
export function projectJsonPath(root) {
|
|
772
|
+
return join(root, ".symbols", "project.json");
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Create/bind the local directory for every notebook the server lists.
|
|
776
|
+
*
|
|
777
|
+
* The local analogue of `materialize_all`, minus the content — the sweep pulls
|
|
778
|
+
* that. Three decisions here are load-bearing:
|
|
779
|
+
*
|
|
780
|
+
* **1. The DIRNAME COMES FROM THE SERVER, always.** `safeDirname` and
|
|
781
|
+
* `dedupeDirnames` exist in `paths.ts` to VERIFY that answer, not to replace it.
|
|
782
|
+
* Two clients that disagree by one character produce two directories for one
|
|
783
|
+
* notebook, each with its own watcher, each pushing over the other. A mismatch is
|
|
784
|
+
* reported, never acted on.
|
|
785
|
+
*
|
|
786
|
+
* **2. AN EXISTING BINDING WINS OVER THE SERVER'S CURRENT DIRNAME.** The server's
|
|
787
|
+
* dedupe iterates `ORDER BY created_at, id`, so deleting one notebook RENAMES its
|
|
788
|
+
* same-named siblings' directories (`cli_projects.rs:39-45`). Following that by
|
|
789
|
+
* renaming the user's directory would move a folder out from under an open editor,
|
|
790
|
+
* a shell, and a project-scope plugin install whose `projectPath` is a literal
|
|
791
|
+
* string. Identity is the notebook id plus the inode, not the name — so the drift
|
|
792
|
+
* is REPORTED and the directory stays put.
|
|
793
|
+
*
|
|
794
|
+
* **3. Ambiguity freezes.** `ledger.bindProject` throws when a second directory
|
|
795
|
+
* claims a notebook (a Finder duplicate, or an edited `project.json`). Caught
|
|
796
|
+
* here and surfaced; never resolved by picking one.
|
|
797
|
+
*/
|
|
798
|
+
export async function ensureProjects(ledger, projects, workspace) {
|
|
799
|
+
const out = [];
|
|
800
|
+
// The verification copy. Computed over the whole list because the dedupe is
|
|
801
|
+
// positional — checking one name in isolation cannot reproduce `-2`/`-3`.
|
|
802
|
+
const expected = dedupeDirnames(projects.map((p) => safeDirname(p.name, p.id)));
|
|
803
|
+
await fs.mkdir(workspace, { recursive: true });
|
|
804
|
+
for (let i = 0; i < projects.length; i += 1) {
|
|
805
|
+
const p = projects[i];
|
|
806
|
+
const mine = expected[i];
|
|
807
|
+
const drift = mine !== undefined && mine !== p.dirname
|
|
808
|
+
? `the server calls this project's directory '${p.dirname}'; this client computes ` +
|
|
809
|
+
`'${mine}'. Using the server's answer — report this, it means the two ` +
|
|
810
|
+
`safe_dirname implementations have diverged.`
|
|
811
|
+
: null;
|
|
812
|
+
const existing = ledger.project(p.id);
|
|
813
|
+
const root = existing && (await rootGate(existing)) === null ? existing.root : join(workspace, p.dirname);
|
|
814
|
+
const nameDrift = existing && existing.root !== join(workspace, p.dirname)
|
|
815
|
+
? `the server now calls this directory '${p.dirname}' but it is bound at ` +
|
|
816
|
+
`'${existing.root}'. The directory is NOT renamed — identity is the notebook id, ` +
|
|
817
|
+
`not the name, and deleting a same-named sibling renames these server-side.`
|
|
818
|
+
: null;
|
|
819
|
+
try {
|
|
820
|
+
await fs.mkdir(root, { recursive: true });
|
|
821
|
+
const st = await fs.stat(root);
|
|
822
|
+
const bound = ledger.bindProject({
|
|
823
|
+
id: existing?.id ?? p.id,
|
|
824
|
+
notebookId: p.id,
|
|
825
|
+
name: p.name,
|
|
826
|
+
dirname: p.dirname,
|
|
827
|
+
root,
|
|
828
|
+
inode: st.ino,
|
|
829
|
+
deviceNo: st.dev,
|
|
830
|
+
});
|
|
831
|
+
// The identity anchor. Written every time so a deleted or edited one is
|
|
832
|
+
// repaired — it is ours, and `.symbols/**` is on the deny-list precisely so
|
|
833
|
+
// no server content can ever land here.
|
|
834
|
+
await fs.mkdir(dirname(projectJsonPath(root)), { recursive: true });
|
|
835
|
+
await fs.writeFile(projectJsonPath(root), `${JSON.stringify({ notebook_id: p.id, name: p.name }, null, 2)}\n`, "utf8");
|
|
836
|
+
out.push({
|
|
837
|
+
project: bound,
|
|
838
|
+
notebookId: p.id,
|
|
839
|
+
name: p.name,
|
|
840
|
+
problem: null,
|
|
841
|
+
dirnameDrift: drift ?? nameDrift,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
out.push({
|
|
846
|
+
project: null,
|
|
847
|
+
notebookId: p.id,
|
|
848
|
+
name: p.name,
|
|
849
|
+
problem: err.message,
|
|
850
|
+
dirnameDrift: drift ?? nameDrift,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Every project in the ledger, swept in sequence.
|
|
858
|
+
*
|
|
859
|
+
* Serial on purpose. Concurrency here buys little (the cost is one `combined`
|
|
860
|
+
* per project) and costs a lot: the per-device rate limit is 600/min across the
|
|
861
|
+
* whole device, and parallel sweeps of 20 projects are exactly how a client
|
|
862
|
+
* discovers that as a 429 storm rather than a queue.
|
|
863
|
+
*/
|
|
864
|
+
export async function syncAll(ledger, opts = {}) {
|
|
865
|
+
const out = [];
|
|
866
|
+
for (const project of ledger.projects()) {
|
|
867
|
+
out.push(await syncProject({ ...opts, ledger, project }));
|
|
868
|
+
}
|
|
869
|
+
return out;
|
|
870
|
+
}
|