@awebai/oats 0.31.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.
@@ -30,7 +30,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
30
30
  import { tmpdir } from "node:os";
31
31
  import { spawnSync } from "node:child_process";
32
32
  import { randomBytes } from "node:crypto";
33
- import { readLock, LOCK_FILE, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
33
+ import { readLock, readLockIfPresent, LOCK_FILE, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
34
34
  import * as defaultRemote from "./remote.mjs";
35
35
  import { parseRepoRef } from "./remote.mjs";
36
36
 
@@ -438,7 +438,7 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
438
438
  const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
439
439
  const local = found.local;
440
440
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
441
- const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
441
+ const lock = readLockIfPresent(deployment);
442
442
  const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
443
443
  const soulEntry = findSoulEntry(discovery, soulName);
444
444
  const disabled = disabledEntry(local, soulEntry);
@@ -458,7 +458,7 @@ const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASO
458
458
 
459
459
  /** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
460
460
  export function deploymentLock(deployment) {
461
- return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
461
+ return readLockIfPresent(deployment);
462
462
  }
463
463
 
464
464
  /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
@@ -0,0 +1,46 @@
1
+ /** lib/local-inputs.mjs — the local configuration a command read, for `observation.localRevision`
2
+ * (spec Addendum 4; docs/desktop-cli-api.md "Observation reuse").
3
+ *
4
+ * Every reader of local configuration on the read verbs' paths reports what it parsed:
5
+ * `recordLocalInput(absPath, bytes)` for a file it read, `recordLocalInput(absPath, null)` for one it
6
+ * looked for and found absent (a file appearing changes the answer as much as one changing). The inputs:
7
+ * oats-local.yaml and every walk-up candidate loadLocal found absent (lib/workspace.mjs), oats-lock.json
8
+ * (lib/packages.mjs readLock / readLockIfPresent), an OATS_PACKAGE_CATALOG override (lib/core.mjs; the
9
+ * bundled catalog is the kernel's, not configuration), the automations snapshot (lib/automations.mjs).
10
+ * NOT inputs: instance homes and runtime state (the answer reports them), the parsed and observation caches.
11
+ *
12
+ * Recording is a no-op until the CLI activates it for this process (a command with --max-age), so
13
+ * library callers and tests are unaffected unless they opt in. The first bytes recorded for a path win.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { realpathSync } from "node:fs";
17
+ import { basename, dirname, join, resolve } from "node:path";
18
+
19
+ let inputs = null; // canonical path → sha256(bytes) | "absent", while active
20
+
21
+ /** Start recording for this process (idempotent; clears nothing already recorded). */
22
+ export function activateLocalInputs() { inputs ??= new Map(); }
23
+
24
+ /** A file's canonical path: its realpath when it exists, else its directory's realpath plus its name
25
+ * (else the resolved path), so a symlinked deployment gives one revision. */
26
+ function canonical(absPath) {
27
+ const path = resolve(absPath);
28
+ try { return realpathSync(path); } catch { /* absent */ }
29
+ try { return join(realpathSync(dirname(path)), basename(path)); } catch { return path; }
30
+ }
31
+
32
+ /** Record one input: the bytes parsed (Buffer | string), or null for a file looked for and absent. */
33
+ export function recordLocalInput(absPath, bytes) {
34
+ if (!inputs) return;
35
+ const key = canonical(absPath);
36
+ if (inputs.has(key)) return;
37
+ inputs.set(key, bytes === null || bytes === undefined ? "absent" : createHash("sha256").update(bytes).digest("hex"));
38
+ }
39
+
40
+ /** 24 lowercase hex: sha256 over the sorted `path NUL digest|absent` lines of every recorded input (the
41
+ * empty set included). Never a path or content. null when recording is not active. */
42
+ export function localRevision() {
43
+ if (!inputs) return null;
44
+ const lines = [...inputs].map(([path, digest]) => `${path}\0${digest}`).sort();
45
+ return createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 24);
46
+ }
@@ -59,6 +59,7 @@ import { oatsError } from "./errors.mjs";
59
59
  import * as defaultRemote from "./remote.mjs";
60
60
  import { renderInstructionText } from "./instruction-composition.mjs";
61
61
  import { DEFAULT_PACKAGE_PATH, validateLock } from "./packages.mjs";
62
+ import { memberRowByKey } from "./workspace.mjs";
62
63
 
63
64
  export const MODULES_DIR = join(".oats", "modules");
64
65
  export const SKILLS_DIR = join(".agents", "skills");
@@ -572,7 +573,7 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
572
573
  }
573
574
  const repoKey = typeof from.repoKey === "string" ? from.repoKey : null;
574
575
  const recorded = { repoKey, commit };
575
- const member = repoKey ? members.find((m) => m && m.key === repoKey) : null;
576
+ const member = repoKey ? memberRowByKey(members, repoKey) : null;
576
577
  if (!member || !member.confirmed || typeof member.commit !== "string") {
577
578
  rows.push({ module: name, from: clone(from), recorded, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" });
578
579
  continue;
@@ -616,7 +617,7 @@ export function soulDriftOf(instanceJson, discovery) {
616
617
  return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
617
618
  }
618
619
  const members = Array.isArray(discovery?.members) ? discovery.members : [];
619
- const member = members.find((m) => m && m.key === soul.repoKey) || null;
620
+ const member = memberRowByKey(members, soul.repoKey);
620
621
  const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
621
622
  const base = { name, repoKey: soul.repoKey, commit };
622
623
  if (!member || (!member.confirmed && !standaloneOwn) || typeof member.commit !== "string") {
package/lib/packages.mjs CHANGED
@@ -44,6 +44,7 @@
44
44
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, lstatSync } from "node:fs";
45
45
  import { tmpdir } from "node:os";
46
46
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
47
+ import { recordLocalInput } from "./local-inputs.mjs";
47
48
  import { oatsError as baseOatsError } from "./errors.mjs";
48
49
  import * as defaultRemote from "./remote.mjs";
49
50
  import { manifestContractProblems } from "./capability-contract.mjs";
@@ -165,14 +166,23 @@ export function validateLock(lock, { file } = {}) {
165
166
  * else is validated (E_LOCK_SCHEMA). Returns a fresh object every call. */
166
167
  export function readLock(dir) {
167
168
  const file = join(resolve(dir), LOCK_FILE);
168
- if (!existsSync(file)) return emptyLock();
169
+ if (!existsSync(file)) { recordLocalInput(file, null); return emptyLock(); }
169
170
  let parsed;
170
- try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
171
+ const text = readFileSync(file, "utf8");
172
+ recordLocalInput(file, text);
173
+ try { parsed = JSON.parse(text); } catch (e) {
171
174
  throw oatsError("E_LOCK_SCHEMA", `oats-lock.json is not valid JSON (${file}): ${e.message}`, { file, path: "/" });
172
175
  }
173
176
  return validateLock(parsed, { file });
174
177
  }
175
178
 
179
+ /** `<dir>/oats-lock.json` read as readLock does, or null when there is none (recorded as absent). */
180
+ export function readLockIfPresent(dir) {
181
+ const file = join(resolve(dir), LOCK_FILE);
182
+ if (!existsSync(file)) { recordLocalInput(file, null); return null; }
183
+ return readLock(dir);
184
+ }
185
+
176
186
  /** Canonical lock text: sorted package ids, sorted capability names, 2-space indent, trailing newline. */
177
187
  export function canonicalLock(lock) {
178
188
  validateLock(lock);
@@ -309,8 +319,14 @@ async function readJson(remote, remoteRef, commit, path, what, details) {
309
319
 
310
320
  /** Read `oats-package.json` and every capability manifest it lists.
311
321
  * → { manifest, capabilities: [{ name, dir, manifest }] } (sorted by name, codepoint order).
312
- * Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }. */
322
+ * Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }.
323
+ * The answer is a pure function of the bytes at the commit: a remote with the parsed cache
324
+ * (lib/remote.mjs memoAtCommit) keeps it; a refusal is thrown and never kept. */
313
325
  export async function readPackageManifests(remote, remoteRef, commit, path, details = {}) {
326
+ const read = () => readPackageManifestsAt(remote, remoteRef, commit, path, details);
327
+ return typeof remote.memoAtCommit === "function" ? remote.memoAtCommit(remoteRef, commit, `package-manifests\0${path}`, read) : read();
328
+ }
329
+ async function readPackageManifestsAt(remote, remoteRef, commit, path, details) {
314
330
  const manifestPath = pjoin(path, PACKAGE_MANIFEST);
315
331
  const manifest = await readJson(remote, remoteRef, commit, manifestPath, "package manifest", details);
316
332
  if (!plainObject(manifest) || typeof manifest.package !== "string" || !Array.isArray(manifest.capabilities)) {
@@ -412,7 +428,6 @@ function assertDigest(what, value, details) {
412
428
  return value;
413
429
  }
414
430
 
415
- /** Bind `remoteOptions` (cacheDir, exec, …) into every call of a contract-§1 remote. */
416
431
  /** A remote whose reads at a commit are shared for one command (feature desktop-facts): the souls and
417
432
  * capabilities listings resolve many souls over the same package manifests and skill listings, and a read at
418
433
  * an immutable commit gives the same answer every time. A failed read is not kept (it is retried). */
@@ -430,13 +445,18 @@ export function memoizedRemote(remote) {
430
445
  return memo;
431
446
  }
432
447
 
448
+ /** Bind `remoteOptions` (cacheDir, exec, the command's read `session`, …) into every call of a contract-§1
449
+ * remote. Object references are kept (a spread, never a copy of the values), so one session is shared by
450
+ * every call; `memoAtCommit` (the parsed cache, lib/remote.mjs) and the member prefetch's primitives
451
+ * (`lastObservedCommit`, `peekAtCommit`, `prefetchObservation`) are optional and bound when present. */
433
452
  export function bindRemote(remote, remoteOptions) {
434
453
  if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
435
454
  const bound = { ...remote };
436
- for (const name of ["observeRemote", "readRemoteFile", "listRemoteTree", "fetchRemoteTree"]) {
455
+ const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
456
+ for (const name of Object.keys(arities)) {
437
457
  if (typeof remote[name] !== "function") continue;
438
458
  bound[name] = (...args) => {
439
- const arity = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4 }[name];
459
+ const arity = arities[name];
440
460
  const opts = { ...(args[arity] || {}), ...remoteOptions };
441
461
  return remote[name](...args.slice(0, arity), opts);
442
462
  };
@@ -10,3 +10,57 @@ export function killGroup(child, signal = "SIGKILL") {
10
10
  try { process.kill(pid, signal); } catch { /* already gone */ }
11
11
  return true;
12
12
  }
13
+
14
+ /** How long a group asked to end with SIGTERM gets before SIGKILL. */
15
+ export const TERM_GRACE_MS = 2_000;
16
+
17
+ /** Children whose 'close' has fired (watchGroup): the leader exited and was reaped, its stdio pipes shut. */
18
+ const closedGroups = new WeakSet();
19
+
20
+ /** Record a detached child's 'close': call it right after spawning a child that terminateGroup may end. → the child. */
21
+ export function watchGroup(child) {
22
+ child.once("close", () => closedGroups.add(child));
23
+ return child;
24
+ }
25
+
26
+ /** How often terminateGroup checks, after the leader's 'close', whether its group is empty yet. */
27
+ export const GROUP_PROBE_MS = 50;
28
+
29
+ /** Whether a detached child's process group still has a member we may signal (ESRCH: empty; EPERM: not ours). */
30
+ function groupAlive(child) {
31
+ try { process.kill(-child.pid, 0); return true; } catch { return false; }
32
+ }
33
+
34
+ /** End a detached child's whole process group gracefully: SIGTERM now, SIGKILL to the group after `graceMs`.
35
+ * git removes its own lock files (`shallow.lock`, `config.lock`, a ref's `.lock`) on SIGTERM, never on SIGKILL:
36
+ * a git killed outright leaves a lock that blocks every later write. The timers are unref'd, so they never keep
37
+ * the process alive. A group seen empty is never signalled again: its id may then lead an unrelated group.
38
+ * Returns whether anything was signalled. */
39
+ export function terminateGroup(child, graceMs = TERM_GRACE_MS) {
40
+ // A child already closed may have left an empty group, whose id is free: send nothing.
41
+ if (closedGroups.has(child)) return false;
42
+ if (!signalGroup(child, "SIGTERM")) return false;
43
+ // Until 'close', the group holds a pipe-holding member (git, or its ssh or remote helper), so its id is ours and
44
+ // the SIGKILL may come. After 'close' the leader is gone but a member that ignores SIGTERM and holds no pipe may
45
+ // remain: no pid is allocated while it is a live group's id, so a group seen non-empty is still ours. It is probed
46
+ // until the grace ends: empty → no SIGKILL, ever; still there → SIGKILL. What remains is a group that empties and is
47
+ // reused within one GROUP_PROBE_MS.
48
+ let probe = null;
49
+ const done = () => { clearTimeout(timer); clearInterval(probe); };
50
+ const timer = setTimeout(() => { clearInterval(probe); signalGroup(child, "SIGKILL"); }, graceMs);
51
+ timer.unref?.();
52
+ child.once?.("close", () => {
53
+ if (!groupAlive(child)) { done(); return; }
54
+ probe = setInterval(() => { if (!groupAlive(child)) done(); }, GROUP_PROBE_MS);
55
+ probe.unref?.();
56
+ });
57
+ return true;
58
+ }
59
+
60
+ /** Signal a detached child's process group only (see killGroup for the pid-0 guard). → whether it was a real child. */
61
+ export function signalGroup(child, signal) {
62
+ const pid = child?.pid;
63
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
64
+ try { process.kill(-pid, signal); } catch { /* the group is gone */ }
65
+ return true;
66
+ }