@izagood/avcs 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api/repo.js CHANGED
@@ -5,9 +5,9 @@
5
5
  // agent workflow: intent → session → propose op → attach evidence → materialize →
6
6
  // decide → checkpoint.
7
7
  var _a;
8
- import { mkdir, writeFile, rm, readdir, readFile } from "node:fs/promises";
8
+ import { mkdir, writeFile, rm, readdir, readFile, lstat, readlink, symlink, cp } from "node:fs/promises";
9
9
  import { existsSync } from "node:fs";
10
- import { join, dirname, resolve, relative } from "node:path";
10
+ import { join, dirname, resolve, relative, isAbsolute, sep } from "node:path";
11
11
  import { Buffer } from "node:buffer";
12
12
  import { ObjectStore } from "../store/objectStore.js";
13
13
  import { LamportClock } from "../core/clock.js";
@@ -52,9 +52,12 @@ const GITIGNORE_COMMITTED = `# AVCS — committed mode.
52
52
  /oplog
53
53
  /objlog
54
54
  /pending/
55
+ /shared/
55
56
  *.lock
56
57
  *.tmp*
57
58
  `;
59
+ /** The committed-mode ignore entry for the shared-path cache tree (docs/21 §3.3). */
60
+ const SHARED_IGNORE_LINE = "/shared/";
58
61
  export class Repo {
59
62
  dir;
60
63
  store;
@@ -2203,7 +2206,18 @@ export class Repo {
2203
2206
  const root = resolve(workDir);
2204
2207
  const out2 = out; // keep the closure below reading naturally
2205
2208
  const avcsIgnore = await this.#loadAvcsIgnore(root);
2206
- const ignored = (rel) => avcsIgnore(rel) || (ignorePredicate?.(rel) ?? false);
2209
+ // Shared paths are folded in HERE, in the core, and not left to the user's `.avcsignore`
2210
+ // (docs/21 §3.5). If listing `node_modules` in an ignore file were the defence, forgetting
2211
+ // to list it would capture 50k files — so contamination is made structurally impossible
2212
+ // instead of merely configurable. Listing a shared path in `.avcsignore` too is harmless.
2213
+ //
2214
+ // A symlinked shared path is already safe without this: the walk below branches on
2215
+ // `Dirent`, whose predicates are lstat-based, so a symlink is neither isDirectory() nor
2216
+ // isFile() and is never entered or read (pinned by docs/21 S8). `mode: "copy"` is the
2217
+ // dangerous one — a real directory the walk CAN descend — and this composition is its
2218
+ // only defence (S8b).
2219
+ const sharedIgnore = await this.#loadSharedIgnore();
2220
+ const ignored = (rel) => avcsIgnore(rel) || sharedIgnore(rel) || (ignorePredicate?.(rel) ?? false);
2207
2221
  const walk = async (dir) => {
2208
2222
  for (const ent of await readdir(dir, { withFileTypes: true })) {
2209
2223
  const rel = relative(root, join(dir, ent.name)).split("\\").join("/");
@@ -2223,6 +2237,23 @@ export class Repo {
2223
2237
  await walk(root);
2224
2238
  return out;
2225
2239
  }
2240
+ /**
2241
+ * Build an ignore predicate from the configured shared paths (docs/21 §3.5).
2242
+ *
2243
+ * A shared path matches itself and everything under it, and nothing else — it is a path
2244
+ * PREFIX rule, deliberately narrower than `.avcsignore`'s basename and `*.ext` matching,
2245
+ * because a shared path names one place in the projection rather than a family of files.
2246
+ *
2247
+ * Unconfigured ⇒ `() => false`, the same object shape `#loadAvcsIgnore` returns for a
2248
+ * missing file, so capture is byte-identical to before this existed (S1).
2249
+ */
2250
+ async #loadSharedIgnore() {
2251
+ const entries = await this.readSharedPaths();
2252
+ if (!entries.length)
2253
+ return () => false;
2254
+ const paths = entries.map((e) => e.path);
2255
+ return (rel) => paths.some((p) => rel === p || rel.startsWith(p + "/"));
2256
+ }
2226
2257
  /**
2227
2258
  * Build an ignore predicate from a repo-root `.avcsignore`, kept git-independent so the core
2228
2259
  * stays standalone (issue #10). Pragmatic subset of .gitignore: blank/`#` lines ignored; a
@@ -2247,11 +2278,282 @@ export class Repo {
2247
2278
  : rel === p || rel.startsWith(p + "/") || base === p);
2248
2279
  };
2249
2280
  }
2250
- /** Write a view's materialized files into `workDir` (alongside .avcs, like git). */
2251
- async checkoutInto(workDir, view = "main", opts) {
2281
+ // ── shared paths (docs/21) ─────────────────────────────────────────────────
2282
+ // The other half of "ignore". `.avcsignore` says "do not record this as an op";
2283
+ // a shared path says "do not record it AND still have it in the directory". Without
2284
+ // the second half a projected workspace has no dependency tree, so it cannot build,
2285
+ // so real projects keep git worktrees for physical isolation — and docs/16 §2-1
2286
+ // ("물리 격리도 avcs가 제공한다") does not hold where it matters.
2287
+ //
2288
+ // Persisted in `.avcs/shared-paths.json`: an aux file like `remotes.json`, not an
2289
+ // object — where your build cache lives is per-replica configuration, not shared
2290
+ // history, and being under `.avcs/` keeps sidecar mode from exposing it to git.
2291
+ /**
2292
+ * Reject a `path` that could not be resolved under a projection root, or slugged into a
2293
+ * store-local directory name, without ambiguity or escape. Called on WRITE so a bad entry
2294
+ * never reaches the file, and again on USE so a hand-edited file cannot escape either.
2295
+ */
2296
+ static #assertSharedPath(path) {
2297
+ const bad = !path ||
2298
+ path !== path.trim() ||
2299
+ isAbsolute(path) ||
2300
+ path.startsWith("/") ||
2301
+ /(^|\/)\.\.(\/|$)/.test(path) ||
2302
+ path.split("/").some((seg) => seg === "" || seg === ".") ||
2303
+ path.startsWith(".avcs");
2304
+ if (bad)
2305
+ throw new Error(`shared path must be a relative path inside the projection, with no '..': ${JSON.stringify(path)}`);
2306
+ }
2307
+ /** Normalize one entry: forward slashes, no trailing slash, mode defaulted, keyFrom copied. */
2308
+ static #normalizeSharedEntry(entry) {
2309
+ const path = entry.path.replace(/\\/g, "/").replace(/\/+$/, "");
2310
+ _a.#assertSharedPath(path);
2311
+ const mode = entry.mode === "copy" ? "copy" : "symlink";
2312
+ return { path, keyFrom: [...(entry.keyFrom ?? [])], mode };
2313
+ }
2314
+ /**
2315
+ * The configured shared paths, or `[]` when nothing is configured. A torn/undecodable
2316
+ * file reads as empty, exactly like `remotes.json` and `config.json`: an unreadable
2317
+ * cache configuration must never make a projection fail.
2318
+ *
2319
+ * Reading must not CREATE the file — "no `shared-paths.json`" is the backward-compatible
2320
+ * state (docs/21 S1) and the absence of the file is itself the signal.
2321
+ */
2322
+ async readSharedPaths() {
2323
+ const raw = await this.store.readAux("shared-paths.json");
2324
+ if (!raw)
2325
+ return [];
2326
+ try {
2327
+ const parsed = JSON.parse(raw.toString("utf8"));
2328
+ if (!Array.isArray(parsed?.shared))
2329
+ return [];
2330
+ const out = [];
2331
+ for (const e of parsed.shared) {
2332
+ if (!e || typeof e.path !== "string")
2333
+ continue;
2334
+ try {
2335
+ out.push(_a.#normalizeSharedEntry(e));
2336
+ }
2337
+ catch { /* a hand-edited escape: drop it, never honour it */ }
2338
+ }
2339
+ return out;
2340
+ }
2341
+ catch {
2342
+ return [];
2343
+ }
2344
+ }
2345
+ /** Replace the shared-path configuration wholesale. */
2346
+ async setSharedPaths(entries) {
2347
+ const shared = entries.map((e) => _a.#normalizeSharedEntry(e));
2348
+ const file = { version: 1, shared };
2349
+ await this.store.writeAux("shared-paths.json", JSON.stringify(file, null, 2) + "\n");
2350
+ this.logger.info("shared.set", { count: shared.length });
2351
+ }
2352
+ /** Add (or replace, by `path`) one shared path. Read-modify-write, like `setTrunk`. */
2353
+ async addSharedPath(entry) {
2354
+ const normalized = _a.#normalizeSharedEntry(entry);
2355
+ const entries = (await this.readSharedPaths()).filter((e) => e.path !== normalized.path);
2356
+ entries.push(normalized);
2357
+ await this.setSharedPaths(entries);
2358
+ }
2359
+ /** Remove one shared path. Returns whether it existed. The CACHE is left alone — that is
2360
+ * `gc --shared`'s call to make, because re-installing is expensive (docs/21 §3.6). */
2361
+ async removeSharedPath(path) {
2362
+ const want = path.replace(/\\/g, "/").replace(/\/+$/, "");
2363
+ const entries = await this.readSharedPaths();
2364
+ const kept = entries.filter((e) => e.path !== want);
2365
+ if (kept.length === entries.length)
2366
+ return false;
2367
+ await this.setSharedPaths(kept);
2368
+ return true;
2369
+ }
2370
+ /**
2371
+ * Derive a cache key from the PROJECTED content of the declared files (docs/21 §3.2):
2372
+ *
2373
+ * key = sha256( canonical( [[path, blobOidOfProjectedContent] for path in sorted(keyFrom)] ) )[:32]
2374
+ *
2375
+ * Projected content, not what is on disk. A tree entry is `path → blobOid`, and a blob
2376
+ * object is `{type,data,encoding}` — content and nothing else — so the oid IS the content
2377
+ * hash. Two workspaces that project the same view therefore get the SAME key by
2378
+ * construction, with no disk read and no clock in the way: determinism buys cache
2379
+ * correctness for free (S15). Conversely a declared file whose content changes moves the
2380
+ * key (S4), and an undeclared file cannot move it however much it changes.
2381
+ *
2382
+ * Pure and static: a key that decides which cache a workspace links to must be checkable
2383
+ * without a store, a projection, or a filesystem.
2384
+ *
2385
+ * - A declared file ABSENT from the view (a lockfile nobody has written yet) participates
2386
+ * as EMPTY content and is reported in `missing` — never silently keyed differently,
2387
+ * which would split the cache and leave nobody able to explain the extra install (S9).
2388
+ * - `keyFrom: []` (or absent) is the named constant `"unkeyed"`: the explicit choice that
2389
+ * every workspace shares one cache. Dangerous, and the user's to make (S10).
2390
+ */
2391
+ static deriveSharedKey(keyFrom, tree) {
2392
+ const declared = [...new Set(keyFrom ?? [])].sort();
2393
+ if (!declared.length)
2394
+ return { key: "unkeyed", missing: [], unkeyed: true };
2395
+ const missing = [];
2396
+ const entries = declared.map((p) => {
2397
+ const oid = tree.get(p);
2398
+ if (oid === undefined)
2399
+ missing.push(p);
2400
+ return [p, oid ?? ""];
2401
+ });
2402
+ return { key: sha256hex(canonicalize(entries)).slice(0, 32), missing, unkeyed: false };
2403
+ }
2404
+ /**
2405
+ * Keep the cache tree out of git in COMMITTED mode.
2406
+ *
2407
+ * Sidecar mode ignores all of `.avcs/` (its `*` covers this), but committed mode
2408
+ * deliberately TRACKS `.avcs/` except for a named list of rebuildable caches — and a build
2409
+ * environment is emphatically one of those. Without the entry, `git add` would sweep tens
2410
+ * of thousands of dependency files into the history AVCS exists to keep clean.
2411
+ *
2412
+ * A repo that flipped to committed mode before shared paths existed has the older file, so
2413
+ * this repairs it at the moment the cache tree first comes into being. Cheap: it reads the
2414
+ * small ignore file and writes only when the entry is genuinely missing, and it never
2415
+ * touches a sidecar repo (nothing there needs it).
2416
+ */
2417
+ async #ensureSharedCacheIgnored() {
2418
+ if ((await this.getGitMode()) !== "committed")
2419
+ return;
2420
+ const raw = (await this.store.readAux(".gitignore"))?.toString("utf8") ?? "";
2421
+ if (raw.split(/\r?\n/).some((l) => l.trim() === SHARED_IGNORE_LINE))
2422
+ return;
2423
+ await this.#writeGitignore("committed");
2424
+ }
2425
+ /**
2426
+ * Root of the store-local shared-cache tree (docs/21 §3.3).
2427
+ *
2428
+ * Store-local, not `$HOME`: cleanup is then one `.avcs` away, the home directory stays
2429
+ * clean, and two unrelated projects can never collide on a lock hash. `store.root` already
2430
+ * follows a linked working tree's `.avcs` POINTER file, so a linked worktree shares the
2431
+ * MAIN store's caches for free — which is exactly where sharing across workspaces starts
2432
+ * to pay (docs/21 S12, homomorphic to docs/14's one-store model).
2433
+ */
2434
+ #sharedRoot() {
2435
+ return join(this.store.root, "shared");
2436
+ }
2437
+ /**
2438
+ * Throw away one cache directory by key (docs/21 R2). The core reports only "non-empty",
2439
+ * so a cache left broken by a half-finished install is not something it can detect — this
2440
+ * is the escape hatch for the caller who can.
2441
+ */
2442
+ async dropSharedCache(key) {
2443
+ if (!/^[a-z0-9]{1,64}$/.test(key))
2444
+ throw new Error(`not a shared cache key: ${JSON.stringify(key)}`);
2445
+ const dir = join(this.#sharedRoot(), key);
2446
+ if (!existsSync(dir))
2447
+ return false;
2448
+ await rm(dir, { recursive: true, force: true });
2449
+ this.logger.info("shared.cache.dropped", { key });
2450
+ return true;
2451
+ }
2452
+ /**
2453
+ * Connect every configured shared path to its store-local cache (docs/21 §3.4). Runs
2454
+ * AFTER the tree has been written, because writing the tree can create directories.
2455
+ *
2456
+ * What the core does: derive the key, create the cache directory, connect it, and report
2457
+ * `populated`. What the core does NOT do: run an install. It does not know what
2458
+ * `node_modules` is, which package manager owns it, or whether the network is up — and the
2459
+ * moment it did, docs/21 §2 principle 1 would be gone. `populated` is the entire interface
2460
+ * between "the core made a place" and "somebody has to fill it".
2461
+ *
2462
+ * Existing content at a shared path is never destroyed. A real directory there is the
2463
+ * user's data and the core cannot recreate it (it does not know how to install), so it is
2464
+ * left alone with a warning. The one thing that IS re-pointed is a symlink the core itself
2465
+ * put inside this store's own cache tree, which is how a key change (S4) takes effect
2466
+ * instead of leaving the workspace wired to a stale environment.
2467
+ *
2468
+ * With `mode: "copy"`, a directory already at the target counts as materialized and is not
2469
+ * copied over — local edits inside it survive (S11). Re-materializing after a key change
2470
+ * therefore means removing that directory by hand; the core will not delete user data to
2471
+ * refresh a cache.
2472
+ */
2473
+ async linkSharedPaths(workDir, tree) {
2474
+ const entries = await this.readSharedPaths();
2475
+ if (!entries.length)
2476
+ return []; // S1: unconfigured ⇒ not even a mkdir
2477
+ const root = resolve(workDir);
2478
+ const sharedRoot = resolve(this.#sharedRoot());
2479
+ await this.#ensureSharedCacheIgnored(); // the cache tree is about to exist
2480
+ const out = [];
2481
+ for (const entry of entries) {
2482
+ const mode = entry.mode === "copy" ? "copy" : "symlink";
2483
+ const { key, missing, unkeyed } = _a.deriveSharedKey(entry.keyFrom, tree);
2484
+ // One key may hold several shared paths, so the leaf is the path slugged `/`→`__`.
2485
+ const cache = join(sharedRoot, key, entry.path.split("/").join("__"));
2486
+ const target = join(root, entry.path);
2487
+ const notes = [];
2488
+ if (unkeyed)
2489
+ notes.push("no keyFrom, so every workspace shares this one cache");
2490
+ if (missing.length)
2491
+ notes.push(`keyFrom absent from the view, hashed as empty: ${missing.join(", ")}`);
2492
+ // R4: the LOCK covers only creating the directory, so two concurrent projections cannot
2493
+ // race it. Coordinating concurrent INSTALLS is outside the core's reach by construction
2494
+ // — it does not run them.
2495
+ await this.store.withLock(`shared:${key}`, async () => { await mkdir(cache, { recursive: true }); });
2496
+ const populated = (await readdir(cache)).length > 0;
2497
+ let linked = false;
2498
+ const st = await lstat(target).catch(() => null);
2499
+ if (st?.isSymbolicLink()) {
2500
+ const dest = resolve(dirname(target), await readlink(target));
2501
+ if (dest === cache) {
2502
+ linked = true; // S5: already correct — no-op, and nothing to say about it
2503
+ }
2504
+ else if (dest === sharedRoot || dest.startsWith(sharedRoot + sep)) {
2505
+ await rm(target, { force: true }); // our own cache tree: the key moved (S4)
2506
+ await symlink(cache, target, "dir");
2507
+ linked = true;
2508
+ }
2509
+ else {
2510
+ notes.push(`${entry.path} is a symlink to ${dest}, outside this store's cache — left alone`);
2511
+ }
2512
+ }
2513
+ else if (st) {
2514
+ if (mode === "copy" && st.isDirectory())
2515
+ linked = true; // already materialized (S11)
2516
+ else
2517
+ notes.push(`${entry.path} exists and is not a link to the cache — left alone (your data)`);
2518
+ }
2519
+ else {
2520
+ await mkdir(dirname(target), { recursive: true });
2521
+ if (mode === "copy")
2522
+ await cp(cache, target, { recursive: true });
2523
+ else
2524
+ await symlink(cache, target, "dir");
2525
+ linked = true;
2526
+ }
2527
+ const link = { path: entry.path, key, cache, target, mode, linked, populated };
2528
+ if (notes.length)
2529
+ link.warning = notes.join("; ");
2530
+ if (link.warning)
2531
+ this.logger.warn("shared.link", { path: entry.path, key, warning: link.warning });
2532
+ out.push(link);
2533
+ }
2534
+ return out;
2535
+ }
2536
+ /**
2537
+ * Project a view into `workDir` AND connect its shared paths (docs/21 §3.4) — the full
2538
+ * physical checkout `avcs workspace project` performs. `checkoutInto` is this without the
2539
+ * shared report, kept as-is for every existing caller.
2540
+ *
2541
+ * `skipped` are tree entries that live INSIDE a shared path. Normally there are none —
2542
+ * capture cannot produce them (§3.5) — but a history contaminated before shared paths
2543
+ * existed can still be opened, and writing those files would spill recorded content over a
2544
+ * live build environment. So they are skipped and named rather than written.
2545
+ */
2546
+ async projectInto(workDir, view = "main", opts) {
2252
2547
  const res = await this.materialize(view, opts?.workspace ? { workspace: opts.workspace } : undefined);
2548
+ const shared = await this.readSharedPaths();
2549
+ const inShared = (rel) => shared.some((e) => rel === e.path || rel.startsWith(e.path + "/"));
2253
2550
  const written = [];
2551
+ const skipped = [];
2254
2552
  for (const [path, blobOid] of res.tree) {
2553
+ if (shared.length && inShared(path)) {
2554
+ skipped.push(path);
2555
+ continue;
2556
+ }
2255
2557
  const full = join(workDir, path);
2256
2558
  const synth = res.synthBlobs.get(blobOid);
2257
2559
  const want = synth ?? (await this.readBlob(blobOid));
@@ -2273,7 +2575,13 @@ export class Repo {
2273
2575
  }
2274
2576
  written.push(path);
2275
2577
  }
2276
- return written.sort();
2578
+ if (skipped.length)
2579
+ this.logger.warn("shared.projection.skipped", { count: skipped.length, sample: skipped.slice(0, 5) });
2580
+ return { written: written.sort(), shared: await this.linkSharedPaths(workDir, res.tree), skipped: skipped.sort() };
2581
+ }
2582
+ /** Write a view's materialized files into `workDir` (alongside .avcs, like git). */
2583
+ async checkoutInto(workDir, view = "main", opts) {
2584
+ return (await this.projectInto(workDir, view, opts)).written;
2277
2585
  }
2278
2586
  /**
2279
2587
  * Whether `buf` may travel the `edit_file` (text 3-way merge) path. `proposeEdit` takes
@@ -2802,6 +3110,10 @@ export class Repo {
2802
3110
  * past `quarantineTtlMs`, that nothing else builds on — the one place append-only
2803
3111
  * yields (abandoned/spam contributions, docs/09 G5).
2804
3112
  * `dryRun` reports without deleting.
3113
+ *
3114
+ * `shared` opts IN to collecting shared-path caches (docs/21 §3.6). Plain `gc` never
3115
+ * touches them: re-installing a build environment is expensive, so the routine reclaim of
3116
+ * orphan blobs must not be able to cost somebody an install.
2805
3117
  */
2806
3118
  async gc(opts = {}) {
2807
3119
  const ops = await this.store.collect("operation");
@@ -2855,8 +3167,51 @@ export class Repo {
2855
3167
  for (const oid of blobs)
2856
3168
  this.#blobCache.delete(oid);
2857
3169
  }
2858
- this.logger.info("gc", { dryRun: opts.dryRun ?? false, blobs: blobs.length, quarantinedOps: quarantinedOps.length });
2859
- return { blobs, quarantinedOps };
3170
+ const sharedKeys = opts.shared ? await this.#collectSharedCaches(opts.dryRun ?? false) : [];
3171
+ this.logger.info("gc", { dryRun: opts.dryRun ?? false, blobs: blobs.length, quarantinedOps: quarantinedOps.length, sharedKeys: sharedKeys.length });
3172
+ return { blobs, quarantinedOps, sharedKeys };
3173
+ }
3174
+ /**
3175
+ * Every shared cache key that some current scope still derives (docs/21 §3.6).
3176
+ *
3177
+ * "Some current scope" is deliberately generous — the base view, every line, and every one
3178
+ * of those crossed with every known workspace. Deriving a key extra times only costs a
3179
+ * reduce; failing to derive one costs a re-install, so over-approximating live keys is the
3180
+ * cheap mistake and the one to make.
3181
+ */
3182
+ async #derivedSharedKeys() {
3183
+ const entries = await this.readSharedPaths();
3184
+ const keys = new Set();
3185
+ if (!entries.length)
3186
+ return keys;
3187
+ const views = ["main", ...(await this.listLines()).map((l) => l.name).filter((n) => n !== "main")];
3188
+ const workspaces = [undefined, ...(await this.workspaceNames())];
3189
+ for (const view of views) {
3190
+ for (const ws of workspaces) {
3191
+ const res = await this.materialize(view, ws ? { workspace: ws } : undefined);
3192
+ for (const e of entries)
3193
+ keys.add(_a.deriveSharedKey(e.keyFrom, res.tree).key);
3194
+ }
3195
+ }
3196
+ return keys;
3197
+ }
3198
+ /** Delete the shared caches no scope derives any more. Returns the keys (sorted). */
3199
+ async #collectSharedCaches(dryRun) {
3200
+ const root = this.#sharedRoot();
3201
+ if (!existsSync(root))
3202
+ return [];
3203
+ const live = await this.#derivedSharedKeys();
3204
+ const dead = [];
3205
+ for (const ent of await readdir(root, { withFileTypes: true })) {
3206
+ if (!ent.isDirectory() || live.has(ent.name))
3207
+ continue;
3208
+ dead.push(ent.name);
3209
+ }
3210
+ dead.sort();
3211
+ if (!dryRun)
3212
+ for (const key of dead)
3213
+ await rm(join(root, key), { recursive: true, force: true });
3214
+ return dead;
2860
3215
  }
2861
3216
  /**
2862
3217
  * Materialize the state AT a given frontier: reduce only the causal closure of