@awebai/oats 0.33.0 → 0.34.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/lib/remote.mjs CHANGED
@@ -4,7 +4,11 @@
4
4
  * APPROACH (one approach, used for every remote kind — local bare repos and
5
5
  * https/ssh remotes alike):
6
6
  * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
- * never a fetch when the caller already gave a full OID.
7
+ * never a fetch when the caller already gave a full OID. A HEAD observation
8
+ * speaks protocol v0 unless the operator pinned `protocol.version` (one round
9
+ * trip instead of v2's two; what is kept of the advertisement is bounded, and a
10
+ * remote over budget, or one that timed out under v0, is observed under v2:
11
+ * observeLive).
8
12
  * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
13
  * commit locally. `ensureCommit` does a shallow, partial
10
14
  * `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
@@ -117,7 +121,15 @@
117
121
  * unref'd while idle and ended by session.close();
118
122
  * - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
119
123
  * (the observation store below) no older than that;
120
- * - the head observations used, for the `observation` output block.
124
+ * - the head observations used, for the `observation` output block;
125
+ * - `deadline` (DEADLINE; the deployment reads `oats status` and `oats workspace status` only, READ_REMOTE_BUDGET_MS
126
+ * after their session starts): every remote step gets what is left of it, not its own default — each git call's
127
+ * timeout (ls-remote, fetch, ls-tree, the batch readers' answers, every cache plumbing call, the git version
128
+ * probe), the cache write lock's wait, the half-initialised cache's wait and the lock-race backoff. A step the deadline ends is a
129
+ * `timeout` (git's group killed as any timeout kills it; a step not started yet starts no git), so a member
130
+ * not read by then degrades as any unreadable member does (a peel or version the deadline ended is a timeout,
131
+ * never a missing commit or an older git). A wait the deadline cuts never changes what it judges: past it no
132
+ * lock is taken or reclaimed, and a cache directory is not taken for a crash's leftover.
121
133
  *
122
134
  * PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
123
135
  * (repo key, commit) by a given kernel never changes, so it is kept on disk under
@@ -157,6 +169,13 @@ export const GIT_TIMEOUT_MS = 30_000;
157
169
  /** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
158
170
  * large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
159
171
  export const GIT_FETCH_TIMEOUT_MS = 600_000;
172
+ /** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
173
+ * this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
174
+ export const READ_REMOTE_BUDGET_MS = 12_000;
175
+ /** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
176
+ * reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
177
+ * and the remote observed under v2, its server-side filter, from then on (observeLive). */
178
+ export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
160
179
  /** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
161
180
  export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
162
181
  /** `--max-age` bounds (seconds). */
@@ -376,6 +395,21 @@ function abortError(signal) {
376
395
  return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
377
396
  }
378
397
 
398
+ /** A git call the session's deadline left no time for: shaped as runGit's own timeout (classified "timeout"). */
399
+ function deadlineError(args) {
400
+ return Object.assign(new Error(`Command failed: git ${args.join(" ")} (the read budget ended before it ran)`),
401
+ { code: null, killed: false, signal: null, timedOut: true, overflowed: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) });
402
+ }
403
+
404
+ /** `exec(args, opts)` under the session's deadline: its timeout (opts.timeout, else GIT_TIMEOUT_MS) cut to what is
405
+ * left, and no git started once nothing is. Without a deadline, exactly `exec(args, opts)`. */
406
+ function sessionExec(exec, session, args, opts = {}) {
407
+ if (session?.deadline == null) return exec(args, opts);
408
+ const timeout = session.remaining(opts.timeout ?? GIT_TIMEOUT_MS);
409
+ if (timeout <= 0) return Promise.reject(deadlineError(args));
410
+ return exec(args, { ...opts, timeout });
411
+ }
412
+
379
413
  function stderrText(error) {
380
414
  const s = error?.stderr;
381
415
  return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
@@ -475,15 +509,17 @@ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|an
475
509
  * git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
476
510
  const LOCK_WAIT_MS = 3_000;
477
511
  /** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
478
- * LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried. */
479
- async function retryLockRace(fn) {
512
+ * LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
513
+ * A session's deadline cuts the backoff: the next call then ends as its timeout (sessionExec). */
514
+ async function retryLockRace(fn, session = null) {
480
515
  const deadline = Date.now() + LOCK_WAIT_MS;
481
516
  for (let attempt = 0; ; attempt++) {
482
517
  try { return await fn(); }
483
518
  catch (error) {
484
519
  if (!isLockRace(error) || error.timedOut) throw error;
485
520
  if (Date.now() >= deadline) throw error;
486
- await sleep(Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random()));
521
+ const backoff = Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random());
522
+ await sleep(session ? session.remaining(backoff) : backoff);
487
523
  }
488
524
  }
489
525
  }
@@ -541,8 +577,18 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
541
577
  try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
542
578
  const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
543
579
  let held = null, waited = false;
580
+ // The session's deadline ends the wait before anything is taken or reclaimed: the read is a timeout, and the
581
+ // lock (live, stale or unreadable) stays exactly as it is.
582
+ const budgetEnded = () => {
583
+ let now = held;
584
+ try { now = readCacheWriteLock(lock); } catch { /* as last seen */ }
585
+ const holder = now?.owner ? `oats process ${now.owner.pid}` : "another process";
586
+ return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (timeout, ${stage}): the read budget ended ${now ? `while ${holder} held its write lock ${lock}` : `before it took its write lock ${lock}`}`,
587
+ { url: ref.url, key: ref.key, reason: "timeout", stage, cacheDir: dir, lock, ...(now?.owner ? { holderPid: now.owner.pid } : {}) });
588
+ };
544
589
  for (let attempt = 0; ; attempt++) {
545
590
  if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
591
+ if (session?.expired()) throw budgetEnded();
546
592
  const tmp = `${lock}.${owner.pid}.${owner.token}`;
547
593
  try {
548
594
  writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
@@ -550,7 +596,7 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
550
596
  } catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
551
597
  finally { try { unlinkSync(tmp); } catch { /* never written */ } }
552
598
  try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
553
- if (held && isStaleLock(held)) {
599
+ if (held && isStaleLock(held) && !session?.expired()) {
554
600
  const abandoned = reclaimCacheWriteLock(lock, held, owner);
555
601
  if (abandoned) {
556
602
  throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
@@ -561,8 +607,10 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
561
607
  throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
562
608
  : `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
563
609
  }
610
+ if (session?.expired()) throw budgetEnded();
564
611
  waited = true;
565
- await sleep(Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random()));
612
+ const backoff = Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random());
613
+ await sleep(session ? Math.max(1, session.remaining(backoff)) : backoff);
566
614
  }
567
615
  try { return await fn({ waited }); }
568
616
  finally {
@@ -627,7 +675,7 @@ function reclaimGitLock(repo, lockFile) {
627
675
  * the call made once more. Anything else is thrown as git raised it. */
628
676
  async function cacheGit(repo, args, opts = {}) {
629
677
  const run = () => repo.local(args, opts);
630
- try { return await retryLockRace(run); }
678
+ try { return await retryLockRace(run, repo.session); }
631
679
  catch (error) {
632
680
  const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
633
681
  if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
@@ -646,15 +694,16 @@ function redactUrl(url) {
646
694
  }
647
695
 
648
696
  function repoHandle(dir, exec, session) {
649
- const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
697
+ const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
650
698
  return { dir, exec, local, session };
651
699
  }
652
700
 
653
701
  async function cacheRepo(ref, options) {
654
702
  const exec = options.exec ?? runGit;
655
703
  const dir = cacheDirOf(cacheRootOf(options), ref);
656
- if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec);
657
- return repoHandle(dir, exec, sessionOf(options));
704
+ const session = sessionOf(options);
705
+ if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec, session);
706
+ return repoHandle(dir, exec, session);
658
707
  }
659
708
 
660
709
  /** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
@@ -669,12 +718,12 @@ const HALF_INIT_WAIT_MS = 2_000;
669
718
  * moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
670
719
  * { reason: "cache", stage: "init", cacheDir }.
671
720
  */
672
- async function initCacheRepo(ref, dir, exec) {
721
+ async function initCacheRepo(ref, dir, exec, session = null) {
673
722
  const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
674
723
  const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
675
724
  try {
676
725
  try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
677
- try { await exec(["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
726
+ try { await sessionExec(exec, session, ["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
678
727
  try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
679
728
  const deadline = Date.now() + HALF_INIT_WAIT_MS;
680
729
  for (;;) {
@@ -683,7 +732,13 @@ async function initCacheRepo(ref, dir, exec) {
683
732
  if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
684
733
  }
685
734
  if (existsSync(join(dir, "HEAD"))) return; // another process created it first
686
- if (Date.now() < deadline) { await sleep(50); continue; }
735
+ if (Date.now() < deadline) {
736
+ // The session's deadline never shortens the judgment: a directory waited for less than the whole wait is
737
+ // never taken for a leftover. The read ends as a timeout instead.
738
+ if (session?.expired()) throw failInit(deadlineError(["init", "-q", "--bare", dir]));
739
+ await sleep(Math.max(1, Math.min(50, session ? session.remaining(50) : 50)));
740
+ continue;
741
+ }
687
742
  // No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
688
743
  // with ENOENT, which is fine) and take its place.
689
744
  const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
@@ -705,8 +760,9 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
705
760
 
706
761
  class ReadSession {
707
762
  constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
708
- cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
763
+ cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
709
764
  if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
765
+ if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
710
766
  this.maxAge = maxAge;
711
767
  this.now = now;
712
768
  this.startedAt = new Date(now()).toISOString();
@@ -715,6 +771,9 @@ class ReadSession {
715
771
  this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
716
772
  this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
717
773
  this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
774
+ this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
775
+ this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
776
+ this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
718
777
  this.parsedLimits = parsedLimits; // tests inject small prune bounds
719
778
  this.observations = new Map(); // memo key → Promise<head observation>
720
779
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
@@ -793,12 +852,19 @@ class ReadSession {
793
852
  this.retiring.clear();
794
853
  reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
795
854
  }
855
+ /** What a wait or a git call of `ms` may take: `ms`, cut to what is left before the deadline (never below 0).
856
+ * Without a deadline, `ms` itself. */
857
+ remaining(ms) { return this.deadline === null ? ms : Math.max(0, Math.min(ms, this.deadline - Date.now())); }
858
+ /** Whether the deadline has passed: every remote step not over by then ends as a `timeout`. */
859
+ expired() { return this.remaining(Infinity) === 0; }
796
860
  /** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
797
861
  toJSON() { return "[oats read session]"; }
798
862
  }
799
863
 
800
- /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). Test seams:
801
- * `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
864
+ /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
865
+ * milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
866
+ * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
867
+ * `v0AdvertisementBudget`. */
802
868
  export function createReadSession(options = {}) { return new ReadSession(options); }
803
869
  /** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
804
870
  * by a caller that is still reading, so its shape only has to be a typed remote failure. */
@@ -1106,7 +1172,7 @@ async function ensureCommit(ref, oid, options) {
1106
1172
  let peeled = false; // the object store was asked already, and said no
1107
1173
  const usable = async (repo) => {
1108
1174
  if (!existsSync(join(dir, "HEAD"))) return null;
1109
- if (!keepsPartialCache(await gitVersion(repo.exec)) && await fetchMode(repo) === "partial") return null;
1175
+ if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
1110
1176
  peeled = true;
1111
1177
  return peelCommit(repo, oid);
1112
1178
  };
@@ -1116,7 +1182,7 @@ async function ensureCommit(ref, oid, options) {
1116
1182
  // waited for it finds what the holder fetched.
1117
1183
  return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
1118
1184
  let repo = await cacheRepo(ref, options);
1119
- const version = await gitVersion(repo.exec);
1185
+ const version = await readVersion(repo, ref);
1120
1186
  // A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
1121
1187
  if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
1122
1188
  const cached = peeled && !waited ? null : await peelCommit(repo, oid);
@@ -1129,6 +1195,8 @@ async function ensureCommit(ref, oid, options) {
1129
1195
  if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
1130
1196
  const commit = await peelCommit(repo, oid);
1131
1197
  if (!commit) {
1198
+ // A peel the session's deadline ended is the read's timeout, never a missing commit.
1199
+ if (session?.expired()) throw unreadable(ref, deadlineError(["rev-parse", `${oid}^{commit}`]), { commit: oid, cacheDir: repo.dir, stage: "fetch" });
1132
1200
  const type = await objectType(repo, oid);
1133
1201
  throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
1134
1202
  }
@@ -1186,19 +1254,41 @@ async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does
1186
1254
  const olderGitNotice = (version, ref) =>
1187
1255
  `git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
1188
1256
 
1189
- /** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null. */
1257
+ /** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
1258
+ * `opts` (a timeout, a signal) go to the call that asks. A call that timed out says nothing about this git:
1259
+ * it answers null to its waiters and is asked again next time. */
1190
1260
  const gitVersions = new WeakMap();
1191
- export function gitVersion(exec = runGit) {
1261
+ export function gitVersion(exec = runGit, opts = undefined) {
1192
1262
  let version = gitVersions.get(exec);
1193
1263
  if (!version) {
1194
- version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
1264
+ version = Promise.resolve().then(() => exec(["--version"], ...(opts ? [opts] : []))).then((out) => {
1195
1265
  const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
1196
1266
  return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
1197
- }, () => null);
1267
+ }, (error) => {
1268
+ if (error?.timedOut && gitVersions.get(exec) === version) gitVersions.delete(exec);
1269
+ return null;
1270
+ });
1198
1271
  gitVersions.set(exec, version);
1199
1272
  }
1200
1273
  return version;
1201
1274
  }
1275
+ /** gitVersion for a read of `ref` in `repo`'s session. Under a deadline the probe gets what is left of it, and
1276
+ * is waited for no longer (another caller may own the probe): a version the deadline left unknown is the
1277
+ * read's timeout, never taken for an older git (which would rebuild a partial cache). */
1278
+ async function readVersion(repo, ref) {
1279
+ const session = repo.session;
1280
+ if (session?.deadline == null) return gitVersion(repo.exec);
1281
+ const ended = () => unreadable(ref, deadlineError(["--version"]), { cacheDir: repo.dir, stage: "fetch" });
1282
+ const left = session.remaining(GIT_TIMEOUT_MS);
1283
+ if (left <= 0) throw ended();
1284
+ let timer;
1285
+ const late = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise(ended), left); });
1286
+ try {
1287
+ const version = await Promise.race([gitVersion(repo.exec, { timeout: left, signal: session.signal }), late]);
1288
+ if (version === ended || (version === null && session.expired())) throw ended();
1289
+ return version;
1290
+ } finally { clearTimeout(timer); }
1291
+ }
1202
1292
  /** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
1203
1293
  export function keepsPartialCache(version) {
1204
1294
  const [major, minor] = PARTIAL_FETCH_GIT;
@@ -1311,7 +1401,7 @@ function parseLsRemote(stdout) {
1311
1401
  }
1312
1402
 
1313
1403
  function resolveAt(parsed, at) {
1314
- if (at === undefined || at === null || at === "" || at === "HEAD") {
1404
+ if (isHeadAt(at)) {
1315
1405
  const oid = parsed.oids.get("HEAD");
1316
1406
  return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
1317
1407
  }
@@ -1355,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
1355
1445
 
1356
1446
  /** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
1357
1447
  function lsRemoteArgs(ref, at) {
1358
- const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
1448
+ const wantHead = isHeadAt(at);
1359
1449
  if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
1360
1450
  const args = ["ls-remote", "--symref", ref.url];
1361
1451
  if (wantHead) args.push("HEAD");
@@ -1439,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
1439
1529
  } finally { session.observeDone(); }
1440
1530
  }
1441
1531
 
1532
+ /** Whether `at` asks for the remote's default branch (a HEAD observation). */
1533
+ const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
1534
+
1535
+ /** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
1536
+ * `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
1537
+ * once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
1538
+ * included) → true: today's argv, never an error. */
1539
+ const protocolPins = new WeakMap();
1540
+ function protocolPinned(exec, session) {
1541
+ const memo = session?.protocolPins ?? protocolPins;
1542
+ let pinned = memo.get(exec);
1543
+ if (!pinned) {
1544
+ pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
1545
+ .then(() => true, (error) => error?.code !== 1);
1546
+ memo.set(exec, pinned);
1547
+ }
1548
+ return pinned;
1549
+ }
1550
+
1551
+ /** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
1552
+ * beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
1553
+ * root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
1554
+ * good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
1555
+ * file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
1556
+ * Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
1557
+ * that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
1558
+ const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
1559
+ const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
1560
+ function lsRemoteV2Recorded(root, ref, now) {
1561
+ const record = (reason) => {
1562
+ const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
1563
+ return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
1564
+ };
1565
+ if (record("overflow")) return true;
1566
+ const rec = record("timeout");
1567
+ const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
1568
+ return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
1569
+ }
1570
+ function recordLsRemoteV2(root, ref, reason, now) {
1571
+ writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
1572
+ }
1573
+
1574
+ /** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
1575
+ * such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
1576
+ const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
1577
+
1578
+ /**
1579
+ * `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
1580
+ * operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
1581
+ * advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
1582
+ * filtered answer gives, HEAD's symref included (v0's symref capability).
1583
+ * - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
1584
+ * advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
1585
+ * recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
1586
+ * - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
1587
+ * - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
1588
+ * and said once if the retry succeeds.
1589
+ * `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
1590
+ * and branches always use it.
1591
+ */
1442
1592
  async function observeLive(ref, args, at, options) {
1443
1593
  const exec = options.exec ?? runGit;
1444
- const signal = sessionOf(options)?.signal;
1445
- let out;
1446
- try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
1447
- catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1594
+ const session = sessionOf(options);
1595
+ const signal = session?.signal;
1596
+ // Every git call takes what is left of the session's deadline, if it has one (sessionExec).
1597
+ const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
1598
+ const root = cacheRootOf(options);
1599
+ const now = () => (session ? session.now() : Date.now());
1600
+ const url = redactUrl(ref.url);
1601
+ const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
1602
+ let out = null, retried = null;
1603
+ const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
1604
+ // A command given up while its protocol was asked starts no ls-remote.
1605
+ if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
1606
+ if (v0) {
1607
+ const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
1608
+ // A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
1609
+ // which says nothing about the remote: its timeout is not recorded.
1610
+ const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
1611
+ try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
1612
+ catch (error) {
1613
+ if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
1614
+ recordLsRemoteV2(root, ref, "overflow", now());
1615
+ say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
1616
+ } else {
1617
+ const reason = classifyRemoteFailure(error);
1618
+ const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
1619
+ if (!aborted && reason === "timeout" && !cut) {
1620
+ recordLsRemoteV2(root, ref, "timeout", now());
1621
+ say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
1622
+ }
1623
+ if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
1624
+ retried = reason;
1625
+ }
1626
+ }
1627
+ }
1628
+ if (!out) {
1629
+ try { out = await run(args); }
1630
+ catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1631
+ }
1448
1632
  const parsed = parseLsRemote(out.stdout);
1449
1633
  const hit = resolveAt(parsed, at);
1450
1634
  if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1451
1635
  if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1452
1636
  const { commit } = await ensureCommit(ref, hit.commit, options);
1637
+ if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
1453
1638
  return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
1454
1639
  }
1640
+ const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
1455
1641
 
1456
1642
  // ---------------------------------------------------------------------------
1457
1643
  // tree reading
@@ -1578,11 +1764,11 @@ function killChild(child) {
1578
1764
  * like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
1579
1765
  * never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
1580
1766
  * body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
1581
- * the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS rejects EVERY pending
1582
- * request with a git-shaped error (classifyRemoteFailure reads it as today: timeout → "timeout") and
1583
- * ends the child; the next read starts a new one.
1767
+ * the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS (cut to what is left of
1768
+ * `session`'s deadline) rejects EVERY pending request with a git-shaped error (classifyRemoteFailure reads it
1769
+ * as today: timeout → "timeout") and ends the child; the next read starts a new one.
1584
1770
  */
1585
- function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
1771
+ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS, session = null) {
1586
1772
  // Detached, as its own process group: killChild's group kill also ends anything git started.
1587
1773
  const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
1588
1774
  liveChildren.add(child);
@@ -1596,7 +1782,8 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
1596
1782
  const arm = () => {
1597
1783
  clearTimeout(timer); timer = null;
1598
1784
  if (!queue.length) { idle(); return; }
1599
- timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${timeoutMs} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), timeoutMs);
1785
+ const ms = session ? session.remaining(timeoutMs) : timeoutMs;
1786
+ timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${ms} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), ms);
1600
1787
  };
1601
1788
  const die = (error) => {
1602
1789
  if (dead) return;
@@ -1686,7 +1873,8 @@ function catFileBatch(repo) {
1686
1873
  if (!session || session.closed || repo.exec !== runGit) return null;
1687
1874
  let reader = session.batches.get(repo.dir);
1688
1875
  if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
1689
- reader = openBatch(repo.dir, session.batchTimeoutMs);
1876
+ if (session.expired()) return null; // a per-blob read, which ends as the deadline's timeout (sessionExec)
1877
+ reader = openBatch(repo.dir, session.batchTimeoutMs, session);
1690
1878
  session.batches.set(repo.dir, reader);
1691
1879
  if (session.batches.size > BATCH_LIMIT) {
1692
1880
  for (const [dir, other] of session.batches) {
@@ -1795,6 +1983,42 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
1795
1983
  return result;
1796
1984
  }
1797
1985
 
1986
+ /**
1987
+ * → { files: [{ path, size }], total } for the regular files (blobs) under <dir> at <commit>, recursively,
1988
+ * relative to <dir>, sorted by path in codepoint order (feature capability-show): `total` of them in all, and
1989
+ * `files` the first `limit` (every one without a limit). Symlinks and gitlinks are omitted; a name that could
1990
+ * escape a checkout ANYWHERE below <dir> is refused (E_REMOTE_TREE_UNSAFE, as listRemoteTree), past the limit
1991
+ * too. One listing (the session's tree index when it has one); the sizes a partial cache lacks are learned with
1992
+ * ONE ensureBlobs call over the listed files only, so nothing past the limit is fetched. A missing dir, or one
1993
+ * that is not a directory → { files: [], total: 0 }. Memoized at the commit (memoAtCommit), per limit.
1994
+ */
1995
+ export async function listRemoteFiles(refText, commitArg, dir, { limit, ...options } = {}) {
1996
+ const ref = parseRepoRef(refText, options);
1997
+ requireCommit(commitArg);
1998
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) throw fail("E_REPO_REF", "limit must be a non-negative integer", { limit });
1999
+ const rel = normalizeTreePath(dir, { allowRoot: true });
2000
+ const none = { files: [], total: 0 };
2001
+ return memoAtCommit(ref, commitArg, `files\0${rel}\0${limit ?? "all"}`, async () => {
2002
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
2003
+ if (rel) {
2004
+ const entry = await entryAt(repo, commit, rel, ref);
2005
+ if (!entry || entry.type !== "tree") return none;
2006
+ }
2007
+ const index = await treeIndex(repo, commit, ref);
2008
+ const entries = index ? indexDescendants(index, rel) : await lsTree(repo, rel ? `${commit}:${rel}` : commit, { flags: ["-r"], ref, commit });
2009
+ if (!entries) return none;
2010
+ const blobs = [];
2011
+ for (const e of entries) {
2012
+ assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
2013
+ if (e.type === "blob" && e.mode !== "120000") blobs.push({ ...e });
2014
+ }
2015
+ blobs.sort(byPath);
2016
+ const listed = limit === undefined ? blobs : blobs.slice(0, limit);
2017
+ await ensureBlobs(repo, ref, commit, listed);
2018
+ return { files: listed.map((e) => ({ path: e.path, size: e.size })), total: blobs.length };
2019
+ }, options);
2020
+ }
2021
+
1798
2022
  // ---------------------------------------------------------------------------
1799
2023
  // digest
1800
2024
  // ---------------------------------------------------------------------------
package/lib/resolve.mjs CHANGED
@@ -474,12 +474,29 @@ export function packageRef(id, entry, catalog, remote) {
474
474
  const SAFE_REL = (rel) => typeof rel === "string" && rel && !posix.isAbsolute(rel) && !/^[\\/]/.test(rel) && !/\0/.test(rel) && !rel.split(/[\\/]/).some((p) => p === "..");
475
475
 
476
476
  /** Normalize a manifest-declared relative path ("./skills/x/" → "skills/x"); null when unsafe. */
477
- function declaredPath(rel) {
477
+ export function declaredPath(rel) {
478
478
  if (!SAFE_REL(rel)) return null;
479
479
  const n = posix.normalize(rel).replace(/\/+$/, "");
480
480
  return n === "." || n === "" || n.startsWith("../") ? null : n;
481
481
  }
482
482
 
483
+ /** A relative path no listing of a checkout can hold: absolute, empty, a `.`/`..`/`.git` (any case) or empty
484
+ * component, a trailing slash, a backslash or a NUL. */
485
+ export function unsafeRelPath(path) {
486
+ if (typeof path !== "string" || path === "" || path.startsWith("/") || path.endsWith("/") || /[\\\0]/.test(path)) return true;
487
+ return path.split("/").some((c) => c === "" || c === "." || c === ".." || c.toLowerCase() === ".git");
488
+ }
489
+
490
+ /** A manifest-declared path as the module install reads it (lib/materialize.mjs modulePath: a backslash is a
491
+ * separator, `./` and trailing slashes dropped), and only when the result is a safe POSIX path (unsafeRelPath);
492
+ * else null. What `oats capabilities show` reports for an inject or a skill. */
493
+ export function manifestFilePath(rel) {
494
+ const declared = declaredPath(rel);
495
+ if (!declared) return null;
496
+ const n = posix.normalize(declared.replace(/\\/g, "/")).replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
497
+ return unsafeRelPath(n) ? null : n;
498
+ }
499
+
483
500
  /**
484
501
  * Skills declared by a manifest: each `skills[]` entry is a directory under the capability that either
485
502
  * IS a skill (holds SKILL.md) or holds skill directories (<entry>/<skill>/SKILL.md) — the same reading
@@ -525,14 +542,31 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
525
542
  * would), commands and hooks. `skills` is null when its declared skills cannot be listed (a spawn of it
526
543
  * would refuse; the listing does not). */
527
544
  export async function capabilityProvides({ ref, commit, dir, manifest, remote: injected, remoteOptions }) {
528
- const remote = remoteOf({ remote: injected, remoteOptions });
529
545
  const keys = (o) => (isObject(o) ? Object.keys(o).sort(byCodepoint) : []);
530
- let skills;
546
+ const { skills } = await capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions });
547
+ return { skills: skills && skills.map((s) => s.name), commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
548
+ }
549
+
550
+ /** A capability's skills as a spawn enumerates them (enumerateSkills), sorted by name in codepoint order:
551
+ * → { skills: [{ name, path }] (path relative to the capability directory), problem: null }, or
552
+ * { skills: null, problem } when the declared skills cannot be enumerated — `problem` the oats error a spawn
553
+ * would meet (`missingCode`: E_CAPABILITY_MISSING for a member, E_PACKAGE_MANIFEST for a package), or a skill
554
+ * path that is not safe (manifestFilePath). Paths are as the install reads them. The one reading of
555
+ * `manifest.skills` behind `oats capabilities` (capabilityProvides) and `oats capabilities show`. */
556
+ export async function capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions, missingCode = "E_CAPABILITY_MISSING" }) {
557
+ const remote = remoteOf({ remote: injected, remoteOptions });
558
+ const missing = (raw, why, text) => fail(missingCode, `${manifest.capability} ${text}`, { capability: manifest.capability, skill: raw, why });
531
559
  try {
532
- const missing = (raw, why, text) => fail("E_CAPABILITY_MISSING", text, { skill: raw, why });
533
- skills = (await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })).map((x) => x.name).sort(byCodepoint);
534
- } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) skills = null; else throw e; }
535
- return { skills, commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
560
+ const skills = [];
561
+ for (const { name, path } of await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })) {
562
+ // A skill path is reported as the install reads it, and only when it is a safe POSIX path (a `.git`
563
+ // directory the remote serves is one a spawn's fetch refuses).
564
+ const safe = manifestFilePath(path);
565
+ if (!safe) throw missing(path, "unsafe", `declares skill ${show(name)} at ${show(path)}, which is not a safe path inside the capability`);
566
+ skills.push({ name, path: safe });
567
+ }
568
+ return { skills: skills.sort((a, b) => byCodepoint(a.name, b.name)), problem: null };
569
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return { skills: null, problem: e }; throw e; }
536
570
  }
537
571
 
538
572
  /** The capability manifests of a locked package, read at its locked commit (feature desktop-facts). */
@@ -543,6 +577,20 @@ export async function lockedPackageCapabilities(id, entry, { catalog = null, rem
543
577
  return { ref, capabilities };
544
578
  }
545
579
 
580
+ /** The lock must describe the tree it names: the capability list a package's manifests declare at the locked
581
+ * commit (`capabilities`, readPackageManifests) must equal the lock entry's, and must hold `name`, else
582
+ * E_PACKAGE_INTEGRITY. → that capability { name, dir, manifest }. Spawn's check (resolveSoul), shared with
583
+ * `oats capabilities show`. */
584
+ export function lockedCapability(name, id, entry, capabilities, details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path }) {
585
+ const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
586
+ if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
587
+ throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
588
+ }
589
+ const cap = capabilities.find((c) => c.name === name);
590
+ if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
591
+ return cap;
592
+ }
593
+
546
594
  /* ───────────────────────────── resolveSoul ────────────────────────────── */
547
595
 
548
596
  /**
@@ -601,13 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
601
649
  const ref = packageRef(id, entry, catalog, remote);
602
650
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
603
651
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
604
- // The lock must describe the tree it names: its capability list is what the package declares there.
605
- const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
606
- if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
607
- throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
608
- }
609
- const cap = capabilities.find((c) => c.name === name);
610
- if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
652
+ const cap = lockedCapability(name, id, entry, capabilities, details);
611
653
  const layer = layerOf(cap.manifest);
612
654
  if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
613
655
  module = {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "oats.engineering": {
30
30
  "url": "https://github.com/awebai/oats-engineering.git",
31
- "ref": "v1.4.0",
31
+ "ref": "v1.5.0",
32
32
  "path": "oats-package"
33
33
  },
34
34
  "oats.framework": {