@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.
Files changed (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +103 -0
  3. package/dist/auth/client.js +531 -0
  4. package/dist/auth/credentials.js +293 -0
  5. package/dist/auth/hosts.js +85 -0
  6. package/dist/auth/loopback.js +108 -0
  7. package/dist/auth/pkce.js +33 -0
  8. package/dist/auth/wire.js +40 -0
  9. package/dist/commands/arm.js +154 -0
  10. package/dist/commands/curl.js +101 -0
  11. package/dist/commands/doctor.js +217 -0
  12. package/dist/commands/login.js +113 -0
  13. package/dist/commands/logout.js +78 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/project.js +145 -0
  16. package/dist/commands/status.js +78 -0
  17. package/dist/commands/sync.js +94 -0
  18. package/dist/commands/uninstall.js +149 -0
  19. package/dist/commands/up.js +176 -0
  20. package/dist/commands/update.js +120 -0
  21. package/dist/commands/watch.js +155 -0
  22. package/dist/commands/whoami.js +103 -0
  23. package/dist/index.js +147 -0
  24. package/dist/mcp/scopes.js +215 -0
  25. package/dist/mcp/server.js +366 -0
  26. package/dist/mcp/tools.js +646 -0
  27. package/dist/skills/bundle.js +441 -0
  28. package/dist/skills/claude-md.js +135 -0
  29. package/dist/skills/install.js +188 -0
  30. package/dist/skills/settings-merge.js +107 -0
  31. package/dist/sync/api.js +380 -0
  32. package/dist/sync/diff.js +172 -0
  33. package/dist/sync/ledger.js +319 -0
  34. package/dist/sync/paths.js +447 -0
  35. package/dist/sync/protect.js +108 -0
  36. package/dist/sync/reconcile.js +870 -0
  37. package/dist/sync/watcher.js +206 -0
  38. package/dist/util/log.js +58 -0
  39. package/dist/util/platform.js +79 -0
  40. package/dist/util/version.js +24 -0
  41. 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
+ }