@awebai/oats 0.30.3 → 0.32.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/lib/triggers.mjs CHANGED
@@ -28,7 +28,8 @@ import { spawnSync } from "node:child_process";
28
28
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
29
29
  import { dirname, join } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry } from "./schedule.mjs";
31
+ import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry, definitionsPath } from "./schedule.mjs";
32
+ import { herdrSettingRemoved } from "./errors.mjs";
32
33
  import { MODEL_RE, automationError, baseRow, localEntry, localId, parseOwner, qualifiedId, soulOriginOf } from "./automations.mjs";
33
34
  import { lockedPackageRef } from "./packages.mjs";
34
35
 
@@ -43,7 +44,6 @@ const SOUL_RE = /^(?:[a-z0-9][a-z0-9._-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
44
  const HARNESSES = new Set(["pi", "claude", "codex"]);
44
45
  /** A launch configuration name (oats-local.yaml launch-configs, docs/oats-local.schema.json). */
45
46
  const LAUNCH_CONFIG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
46
- const BACKENDS = new Set(["tmux", "herdr"]);
47
47
  const DEFAULT_POLL = "2m";
48
48
  const POLL_MIN_MS = 60_000;
49
49
  /** A tick runs once a minute and takes a moment: a poll due within this much is taken now. */
@@ -87,8 +87,9 @@ function checkTemplate(text, field) {
87
87
  if (bad.length) throw invalid(field, `the template may name only ${TEMPLATE_FIELDS.map((f) => `{${f}}`).join(" ")} (a PR's title and body are never templated); found ${bad.map((f) => `{${f}}`).join(" ")}`);
88
88
  }
89
89
 
90
- /** Validate and normalize one trigger definition. */
91
- export function validateTrigger(def) {
90
+ /** Validate and normalize one trigger definition. `backendSource` names the file and key that set its
91
+ * spawn.backend, for the refusal of `herdr` (Herdr was removed in 0.31.0). */
92
+ export function validateTrigger(def, { backendSource = "the trigger's spawn.backend" } = {}) {
92
93
  if (!isObject(def)) throw invalid("definition", "must be an object");
93
94
  onlyKeys(def, ["id", "enabled", "kind", "on", "spawn", "concurrency", "template", "createdAt", "updatedAt"], "");
94
95
  if (typeof def.id !== "string" || !ID_RE.test(def.id)) throw invalid("id", "lowercase letters, digits and dashes, 1 to 40 characters");
@@ -123,7 +124,8 @@ export function validateTrigger(def) {
123
124
  if (sp.harness !== undefined && !HARNESSES.has(sp.harness)) throw invalid("spawn.harness", "pi, claude or codex");
124
125
  if (sp.model !== undefined && (typeof sp.model !== "string" || !MODEL_RE.test(sp.model))) throw invalid("spawn.model", "a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)");
125
126
  if (sp.yolo !== undefined && typeof sp.yolo !== "boolean") throw invalid("spawn.yolo", "boolean");
126
- if (sp.backend !== undefined && !BACKENDS.has(sp.backend)) throw invalid("spawn.backend", "tmux or herdr");
127
+ if (sp.backend === "herdr") throw Object.assign(herdrSettingRemoved(`${backendSource}: herdr`), { field: "spawn.backend", details: { field: "spawn.backend" } });
128
+ if (sp.backend !== undefined && sp.backend !== "tmux") throw invalid("spawn.backend", "tmux");
127
129
  const cc = def.concurrency === undefined ? {} : def.concurrency;
128
130
  if (!isObject(cc)) throw invalid("concurrency", "{ max?, perKey? }");
129
131
  onlyKeys(cc, ["max", "perKey"], "concurrency.");
@@ -207,7 +209,7 @@ export function triggerKind({ lock = null, remote = null } = {}) {
207
209
  parseBody: parseTriggerBody,
208
210
  expand: async (a) => {
209
211
  const raw = a.source.from ? await packageTriggerTemplate(lock, a.source.from, a.source.set, { id: a.name, remote }) : { ...a.source.definition };
210
- const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
212
+ const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true }, { backendSource: `${a.source.from ? `package template ${a.source.from}` : a.origin?.path ?? a.name} sets spawn.backend` });
211
213
  // The owner check asks gh on the OWNER's host, and the poll runs on the REPO's host: they must be
212
214
  // one GitHub host, or polling would act as whoever is logged in there (re-review B #7).
213
215
  const owner = parseOwner(a.owner), repo = parseRepo(def.on.repo);
@@ -239,7 +241,7 @@ export function triggerEntries(ws, ctx) {
239
241
  const out = [];
240
242
  for (const [id, raw] of Object.entries(triggerJobs(ws)).sort(([a], [b]) => a.localeCompare(b))) {
241
243
  let def = null, bad = null;
242
- try { def = validateTrigger(raw); } catch (e) { bad = { code: e.code, message: e.message, ...(e.field ? { field: e.field } : {}) }; }
244
+ try { def = validateTrigger(raw, { backendSource: `${definitionsPath(ws)} sets jobs.${id}.spawn.backend` }); } catch (e) { bad = { code: e.code, message: e.message, ...(e.field ? { field: e.field } : {}) }; }
243
245
  out.push({ ...localEntry("trigger", def ?? { ...raw, id }, { invalid: bad, dep: ws }), raw });
244
246
  }
245
247
  out.push(...(ctx?.triggers || []));
@@ -577,7 +579,7 @@ export function listTriggers(ws, ctx = null, { io } = {}) {
577
579
  };
578
580
  }
579
581
  export function addTrigger(ws, spec) {
580
- const def = validateTrigger({ ...spec, kind: spec?.kind ?? "trigger" });
582
+ const def = validateTrigger({ ...spec, kind: spec?.kind ?? "trigger" }, { backendSource: `${definitionsPath(ws)} sets jobs.${spec?.id}.spawn.backend` });
581
583
  return withScopeLock(ws, () => {
582
584
  const defs = readDefinitions(ws);
583
585
  if (defs.jobs[def.id]) throw triggerError("E_TRIGGER_EXISTS", `${defs.jobs[def.id].kind === "trigger" ? "trigger" : "a schedule"} ${def.id} already exists in ${ws}`, { details: { id: def.id } });
package/lib/workspace.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { readFileSync, existsSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
+ import { recordLocalInput } from "./local-inputs.mjs";
15
16
  import { parseConfigData } from "./config-data.mjs";
16
17
  import { oatsError } from "./errors.mjs";
17
18
  import * as defaultRemote from "./remote.mjs";
@@ -36,6 +37,12 @@ function remoteOf(options) {
36
37
  const remote = assertRemoteApi(options?.remote ?? defaultRemote);
37
38
  return bindRemote(remote, options?.remoteOptions);
38
39
  }
40
+ /** The parsed cache (lib/remote.mjs memoAtCommit, optional on the remote contract): `compute()` is a pure
41
+ * function of the bytes at (ref's key, commit) and this kernel. A remote without it — every in-memory fake —
42
+ * just computes. */
43
+ function atCommit(remote, ref, commit, item, compute) {
44
+ return typeof remote.memoAtCommit === "function" ? remote.memoAtCommit(ref, commit, item, compute) : compute();
45
+ }
39
46
  const REQUIRED_REMOTE_API = ["parseRepoRef", "observeRemote", "readRemoteFile", "listRemoteTree"];
40
47
  function assertRemoteApi(remote) {
41
48
  for (const name of REQUIRED_REMOTE_API) {
@@ -362,10 +369,13 @@ export function loadLocal(dir) {
362
369
  const candidate = join(current, "oats-local.yaml");
363
370
  visited.push(candidate);
364
371
  if (existsSync(join(current, "oats-config.yaml"))) legacy.push(join(current, "oats-config.yaml"));
365
- if (existsSync(candidate)) {
372
+ if (!existsSync(candidate)) recordLocalInput(candidate, null); // a closer file appearing changes the answer
373
+ else {
366
374
  if (legacy.length) throw fail("E_CONFIG_BROKEN", `${legacy.join(", ")}: ${LEGACY_CONFIG_MIGRATION}`, { dir: resolve(dir), deployment: current, files: legacy, reason: "legacy-config" });
367
375
  const origin = { kind: "local", path: candidate };
368
- const read = readDeclaration("local", readFileSync(candidate), origin);
376
+ const bytes = readFileSync(candidate);
377
+ recordLocalInput(candidate, bytes);
378
+ const read = readDeclaration("local", bytes, origin);
369
379
  if (read.problems) throw schemaError("local", origin, read.problems, read.value);
370
380
  return { path: candidate, local: read.value };
371
381
  }
@@ -382,21 +392,53 @@ export async function observeWorkspace(ref, { at, remote: injected, remoteOption
382
392
  const obs = await remote.observeRemote(ref, { at });
383
393
  const path = "oats-workspace.yaml";
384
394
  const origin = { kind: "workspace", repoKey: obs.key, commit: obs.commit, path };
385
- let bytes;
386
- try { ({ bytes } = await remote.readRemoteFile(ref, obs.commit, path)); }
387
- catch (e) {
388
- // Contract: observeWorkspace throws E_REMOTE_UNREADABLE | E_WORKSPACE_SCHEMA. A repo without the
389
- // file is not a workspace (the usual mistake: a member handed in as the workspace).
390
- if (e?.code === "E_REMOTE_PATH_MISSING") throw fail("E_WORKSPACE_SCHEMA", `${obs.key}@${obs.commit.slice(0, 12)} has no ${path}: it is not a workspace host`, { path, notAHost: true, repoKey: obs.key, commit: obs.commit, problems: [{ path: "", message: `${path} is missing` }], cause: e.code });
391
- throw e;
392
- }
393
- const read = readDeclaration("workspace", bytes, origin, { remote });
395
+ // The file at the host commit, read and validated (parsed cache item: "missing" or the declaration read).
396
+ const read = await atCommit(remote, ref, obs.commit, "workspace", async () => {
397
+ try { return readDeclaration("workspace", (await remote.readRemoteFile(ref, obs.commit, path)).bytes, origin, { remote }); }
398
+ catch (e) { if (e?.code === "E_REMOTE_PATH_MISSING") return { missing: e.code }; throw e; }
399
+ });
400
+ // Contract: observeWorkspace throws E_REMOTE_UNREADABLE | E_WORKSPACE_SCHEMA. A repo without the
401
+ // file is not a workspace (the usual mistake: a member handed in as the workspace).
402
+ if (read.missing) throw fail("E_WORKSPACE_SCHEMA", `${obs.key}@${obs.commit.slice(0, 12)} has no ${path}: it is not a workspace host`, { path, notAHost: true, repoKey: obs.key, commit: obs.commit, problems: [{ path: "", message: `${path} is missing` }], cause: read.missing });
394
403
  if (read.problems) throw schemaError("workspace", origin, read.problems, read.value);
404
+ checkedWorkspace(read.value, remote, []); // validated just now: confirmMembership need not validate it again
395
405
  return { key: obs.key, url: obs.url, ref, commit: obs.commit, workspace: read.value, observedAt: obs.observedAt };
396
406
  }
397
407
 
398
408
  const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: false, reason, detail, ...extra });
399
409
 
410
+ /** A workspace value's own checks, computed once per workspace object (and parseRepoRef): its problems
411
+ * and the set of its member keys. confirmMembership runs per member; these do not change between them. */
412
+ const workspaceChecks = new WeakMap();
413
+ function checkedWorkspace(workspace, remote, knownProblems = null) {
414
+ if (workspace === null || typeof workspace !== "object") return { problems: validateWorkspace(workspace, { remote }), keys: new Set() };
415
+ let byParser = workspaceChecks.get(workspace);
416
+ if (!byParser) workspaceChecks.set(workspace, byParser = new Map());
417
+ let checked = byParser.get(remote.parseRepoRef);
418
+ if (!checked) {
419
+ const keys = new Set();
420
+ for (const m of workspace.members || []) { try { keys.add(refKey(remote, m)); } catch { /* validateWorkspace names it */ } }
421
+ byParser.set(remote.parseRepoRef, checked = { problems: knownProblems ?? validateWorkspace(workspace, { remote }), keys });
422
+ }
423
+ return checked;
424
+ }
425
+
426
+ /** The member's oats-membership.yaml at `commit`, read and validated — a pure function of the bytes there
427
+ * (parsed cache item "membership"): { kind: "ok", value } | { kind: "invalid", problems } | { kind: "missing" }
428
+ * | { kind: "unusable", code, message }. E_REMOTE_UNREADABLE (transient) is thrown, never an outcome. */
429
+ async function membershipAt(remote, memberRef, key, commit) {
430
+ let bytes;
431
+ try { ({ bytes } = await remote.readRemoteFile(memberRef, commit, "oats-membership.yaml")); }
432
+ catch (e) {
433
+ if (e?.code === "E_REMOTE_PATH_MISSING") return { kind: "missing" };
434
+ if (e?.code === "E_REMOTE_UNREADABLE") throw e;
435
+ if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) return { kind: "unusable", code: e.code, message: e.message };
436
+ throw e;
437
+ }
438
+ const read = readDeclaration("membership", bytes, { kind: "membership", repoKey: key, commit, path: "oats-membership.yaml" });
439
+ return read.problems ? { kind: "invalid", problems: read.problems } : { kind: "ok", value: read.value };
440
+ }
441
+
400
442
  /**
401
443
  * Read the member's oats-membership.yaml at its default branch in the same access context.
402
444
  * → { key, commit, confirmed: true } | { key, confirmed: false, reason, detail }
@@ -404,16 +446,15 @@ const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: fals
404
446
  export async function confirmMembership(workspaceObs, memberRef, { remote: injected, remoteOptions } = {}) {
405
447
  const remote = remoteOf({ remote: injected, remoteOptions });
406
448
  const workspace = workspaceObs.workspace;
407
- const wsProblems = validateWorkspace(workspace, { remote });
408
- if (wsProblems.length) throw schemaError("workspace", { kind: "workspace", repoKey: workspaceObs.key, commit: workspaceObs.commit, path: "oats-workspace.yaml" }, wsProblems, workspace);
449
+ const checked = checkedWorkspace(workspace, remote);
450
+ if (checked.problems.length) throw schemaError("workspace", { kind: "workspace", repoKey: workspaceObs.key, commit: workspaceObs.commit, path: "oats-workspace.yaml" }, checked.problems, workspace);
409
451
  let key;
410
452
  try { key = refKey(remote, memberRef); }
411
453
  catch (e) {
412
454
  if (e?.code === "E_REPO_REF") return unconfirmed(typeof memberRef === "string" ? memberRef : String(memberRef), "not-listed", `${show(memberRef)} is not a repo ref: ${e.message}`);
413
455
  throw e;
414
456
  }
415
- const listed = (workspace.members || []).some((m) => refKey(remote, m) === key);
416
- if (!listed) return unconfirmed(key, "not-listed", `${key} is not in members of workspace ${workspaceObs.key}`);
457
+ if (!checked.keys.has(key)) return unconfirmed(key, "not-listed", `${key} is not in members of workspace ${workspaceObs.key}`);
417
458
  const cannotRead = (e) => unconfirmed(key, "cannot-read", `cannot read ${e.details?.url ?? e.provenance?.url ?? memberRef}${e.details?.reason ? ` (${e.details.reason})` : ""}`, { url: e.details?.url ?? e.provenance?.url ?? null });
418
459
  let obs;
419
460
  try { obs = await remote.observeRemote(memberRef); }
@@ -421,17 +462,17 @@ export async function confirmMembership(workspaceObs, memberRef, { remote: injec
421
462
  if (e?.code === "E_REMOTE_UNREADABLE") return cannotRead(e);
422
463
  throw e;
423
464
  }
424
- let bytes;
425
- try { ({ bytes } = await remote.readRemoteFile(memberRef, obs.commit, "oats-membership.yaml")); }
465
+ let outcome;
466
+ try { outcome = await atCommit(remote, memberRef, obs.commit, "membership", () => membershipAt(remote, memberRef, key, obs.commit)); }
426
467
  catch (e) {
427
- if (e?.code === "E_REMOTE_PATH_MISSING") return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} has no oats-membership.yaml`, { commit: obs.commit });
428
468
  if (e?.code === "E_REMOTE_UNREADABLE") return cannotRead(e);
429
- // Contract: NEVER throws for an unconfirmed member. Anything else the remote refuses about the
430
- // member's file (oversize, symlink, unsafe tree) means the repo has not completed the handshake.
431
- if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml cannot be used: ${e.message}`, { commit: obs.commit, cause: e.code });
432
469
  throw e;
433
470
  }
434
- const read = readDeclaration("membership", bytes, { kind: "membership", repoKey: key, commit: obs.commit, path: "oats-membership.yaml" });
471
+ if (outcome.kind === "missing") return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} has no oats-membership.yaml`, { commit: obs.commit });
472
+ // Contract: NEVER throws for an unconfirmed member. Anything else the remote refuses about the
473
+ // member's file (oversize, symlink, unsafe tree) means the repo has not completed the handshake.
474
+ if (outcome.kind === "unusable") return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml cannot be used: ${outcome.message}`, { commit: obs.commit, cause: outcome.code });
475
+ const read = outcome.kind === "invalid" ? { problems: outcome.problems } : { value: outcome.value };
435
476
  if (read.problems) return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml is invalid: ${read.problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, { commit: obs.commit, problems: read.problems });
436
477
  let backlinkKey;
437
478
  try { backlinkKey = refKey(remote, read.value.workspace); }
@@ -553,7 +594,7 @@ export async function discoverRepo(ref, { at, remote: injected, remoteOptions }
553
594
  } catch (e) {
554
595
  if (e?.code !== "E_REMOTE_PATH_MISSING") throw e;
555
596
  }
556
- const items = await enumerateRepo(remote, ref, obs.key, obs.commit);
597
+ const items = await atCommit(remote, ref, obs.commit, "enumerate", () => enumerateRepo(remote, ref, obs.key, obs.commit));
557
598
  return { key: obs.key, commit: obs.commit, membership, ...items, problems: [...problems, ...items.problems] };
558
599
  }
559
600
 
@@ -590,13 +631,13 @@ export async function discoverPackageSouls(workspace, lock, { remote: injected,
590
631
  for (const locked of entry.souls) {
591
632
  const path = `${entry.path}/${locked.path}`;
592
633
  const file = `${path}/soul.yaml`;
593
- let bytes;
594
- try { ({ bytes } = await remote.readRemoteFile(ref, entry.commit, file)); }
595
- catch (e) {
596
- if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) { problem(e.code, file, e.message); continue; }
597
- throw e;
598
- }
599
- const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit: entry.commit, path: file });
634
+ // soul.yaml at the locked commit, read and validated (parsed cache item; a remote failure is an
635
+ // outcome here, and a transient one is never kept).
636
+ const read = await atCommit(remote, ref, entry.commit, `package-soul\0${file}`, async () => {
637
+ try { return readDeclaration("soul", (await remote.readRemoteFile(ref, entry.commit, file)).bytes, { kind: "soul", repoKey: key, commit: entry.commit, path: file }); }
638
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) return { remoteError: { code: e.code, message: e.message } }; throw e; }
639
+ });
640
+ if (read.remoteError) { problem(read.remoteError.code, file, read.remoteError.message); continue; }
600
641
  if (read.problems) { for (const p of read.problems) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
601
642
  const definition = read.value;
602
643
  if (definition.name !== locked.name) { problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} does not match its directory ${show(locked.name)}`); continue; }
@@ -621,36 +662,68 @@ export async function discoverPackageSouls(workspace, lock, { remote: injected,
621
662
  return { souls, problems };
622
663
  }
623
664
 
665
+ /** Members discovered at once (a fixed bound: the laptop's git and network, not the workspace's size). */
666
+ export const DISCOVERY_CONCURRENCY = 8;
667
+
668
+ /** `task(item, index)` over `items` with at most `limit` running, results in item order. Exactly the serial
669
+ * loop's outcome: indices are taken in order, nothing new starts after a failure, and the failure of the
670
+ * LOWEST index is thrown once everything started has settled (every earlier item had succeeded). */
671
+ async function boundedMap(items, limit, task) {
672
+ const results = new Array(items.length);
673
+ const failures = new Map();
674
+ let next = 0;
675
+ const worker = async () => {
676
+ while (next < items.length && failures.size === 0) {
677
+ const i = next++;
678
+ try { results[i] = await task(items[i], i); } catch (e) { failures.set(i, e); }
679
+ }
680
+ };
681
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
682
+ if (failures.size) throw failures.get(Math.min(...failures.keys()));
683
+ return results;
684
+ }
685
+
624
686
  /**
625
687
  * The whole picture in one access context.
626
688
  * → { workspace, members: [{ key, commit, confirmed, reason?, souls, capabilities, publishes }], external: [...],
627
689
  * problems: [{ code, path, message, repoKey? }] }
628
690
  * `publishes` = { package, version } when the member carries oats-package/oats-package.json (informational:
629
691
  * the package's capabilities are NOT member capabilities — non-collapse rule), else null.
692
+ * Members are read DISCOVERY_CONCURRENCY at a time; rows and problems are assembled in declaration order.
630
693
  */
631
694
  export async function discoverWorkspace(ref, { at, local, lock = null, remote: injected, remoteOptions } = {}) {
632
695
  const remote = remoteOf({ remote: injected, remoteOptions });
633
- const wsObs = await observeWorkspace(ref, { at, remote });
696
+ prefetchMembers(remote, ref, at);
697
+ let wsObs;
698
+ // The host cannot be observed: nothing will adopt the prefetches, so none still queued may run its git.
699
+ try { wsObs = await observeWorkspace(ref, { at, remote }); }
700
+ catch (e) { remote.abandonPrefetches?.(); throw e; }
634
701
  const workspace = wsObs.workspace;
635
702
  const problems = [];
636
703
  const members = [];
637
- for (const memberRef of workspace.members || []) {
638
- const confirmation = await confirmMembership(wsObs, memberRef, { remote });
639
- const row = { key: confirmation.key, ref: memberRef, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, souls: [], capabilities: [], publishes: null };
640
- if (!confirmation.confirmed) {
641
- // Contract: an unconfirmed member contributes nothing but its row.
642
- row.reason = confirmation.reason;
643
- row.detail = confirmation.detail;
644
- members.push(row);
645
- continue;
646
- }
647
- const items = await enumerateRepo(remote, memberRef, row.key, row.commit);
648
- row.souls = items.souls;
649
- row.capabilities = items.capabilities;
650
- row.publishes = items.publishes;
651
- problems.push(...items.problems);
652
- members.push(row);
704
+ let discovered;
705
+ try {
706
+ discovered = await boundedMap(workspace.members || [], DISCOVERY_CONCURRENCY, async (memberRef) => {
707
+ const confirmation = await confirmMembership(wsObs, memberRef, { remote });
708
+ const row = { key: confirmation.key, ref: memberRef, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, souls: [], capabilities: [], publishes: null };
709
+ if (!confirmation.confirmed) {
710
+ // Contract: an unconfirmed member contributes nothing but its row.
711
+ row.reason = confirmation.reason;
712
+ row.detail = confirmation.detail;
713
+ return { row, problems: [] };
714
+ }
715
+ const items = await atCommit(remote, memberRef, row.commit, "enumerate", () => enumerateRepo(remote, memberRef, row.key, row.commit));
716
+ row.souls = items.souls;
717
+ row.capabilities = items.capabilities;
718
+ row.publishes = items.publishes;
719
+ return { row, problems: items.problems };
720
+ });
721
+ } finally {
722
+ // Every member listed now has adopted its prefetch (or the discovery failed): the rest, members dropped
723
+ // since the host's last observed commit, are given up.
724
+ remote.abandonPrefetches?.();
653
725
  }
726
+ for (const d of discovered) { members.push(d.row); problems.push(...d.problems); }
654
727
  const externalRows = [];
655
728
  for (const [index, entry] of (workspace.external || []).entries()) {
656
729
  const pin = entry.source.lastIndexOf("@");
@@ -661,15 +734,13 @@ export async function discoverWorkspace(ref, { at, local, lock = null, remote: i
661
734
  let key;
662
735
  try { key = refKey(remote, sourceRef); }
663
736
  catch (e) { if (e?.code === "E_REPO_REF") { pushProblem("E_REPO_REF", "source", e.message); continue; } throw e; }
664
- let bytes;
665
- try {
666
- // Pinned: read at the declared OID only; the remote's default branch is never consulted.
667
- ({ bytes } = await remote.readRemoteFile(sourceRef, commit, file));
668
- } catch (e) {
669
- if (e?.code === "E_REMOTE_UNREADABLE" || e?.code === "E_REMOTE_PATH_MISSING") { pushProblem(e.code, file, e.message); continue; }
670
- throw e;
671
- }
672
- const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
737
+ // Pinned: read at the declared OID only; the remote's default branch is never consulted. The soul.yaml
738
+ // there, read and validated, is a parsed cache item (a transient failure is never kept).
739
+ const read = await atCommit(remote, sourceRef, commit, `external-soul\0${file}`, async () => {
740
+ try { return readDeclaration("soul", (await remote.readRemoteFile(sourceRef, commit, file)).bytes, { kind: "soul", repoKey: key, commit, path: file }); }
741
+ catch (e) { if (e?.code === "E_REMOTE_UNREADABLE" || e?.code === "E_REMOTE_PATH_MISSING") return { remoteError: { code: e.code, message: e.message } }; throw e; }
742
+ });
743
+ if (read.remoteError) { pushProblem(read.remoteError.code, file, read.remoteError.message); continue; }
673
744
  if (read.problems) { for (const p of read.problems) pushProblem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
674
745
  externalRows.push({ source: entry.source, key, commit, soul: { name: read.value.name, path: entry.soul, repoKey: key, commit, private: false, definition: read.value } });
675
746
  }
@@ -678,6 +749,42 @@ export async function discoverWorkspace(ref, { at, local, lock = null, remote: i
678
749
  return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, packageSouls: pkg.souls, problems, warnings: workspaceWarnings(workspace, members, externalRows, pkg.souls) };
679
750
  }
680
751
 
752
+ /**
753
+ * The member prefetch (spec Addendum 3): a whole-workspace discovery starts its members' head observations
754
+ * together with the host's instead of one network round after it. The member list is the one at the host's
755
+ * LAST observed commit (its observation record, of any age) as the parsed cache holds it — no git process
756
+ * decides anything here; either missing or corrupt means no prefetch. The host goes first (the session's
757
+ * observation slots are handed out in order). Only discoverWorkspace calls this: observeWorkspace alone (the
758
+ * teams reads, inspect --home) never fires member observations it would not use. Truth is unchanged: the
759
+ * members the answer uses come from the workspace file at the host commit observed NOW, each confirmed at
760
+ * its own observed commit; a member no longer listed costs one unused observation and is never read.
761
+ */
762
+ function prefetchMembers(remote, ref, at) {
763
+ if (typeof remote.lastObservedCommit !== "function" || typeof remote.peekAtCommit !== "function" || typeof remote.prefetchObservation !== "function") return;
764
+ const commit = remote.lastObservedCommit(ref, { at });
765
+ if (!commit) return;
766
+ const read = remote.peekAtCommit(ref, commit, "workspace");
767
+ if (!read || typeof read !== "object" || read.missing || read.problems || !Array.isArray(read.value?.members)) return;
768
+ remote.prefetchObservation(ref, { at });
769
+ for (const member of read.value.members) if (typeof member === "string") remote.prefetchObservation(member);
770
+ }
771
+
772
+ /** A discovery's member row by key: the FIRST row with that key, as `find` would give, from an index built
773
+ * once per members array (resolve, materialize and the CLI's facts look rows up per soul, capability and
774
+ * instance: a scan each was O(rows × members)). A discovery's members array is never edited after
775
+ * discoverWorkspace returns it. */
776
+ const memberRowIndex = new WeakMap();
777
+ export function memberRowByKey(members, key) {
778
+ if (!Array.isArray(members)) return null;
779
+ let index = memberRowIndex.get(members);
780
+ if (!index) {
781
+ index = new Map();
782
+ for (const m of members) if (m && !index.has(m.key)) index.set(m.key, m);
783
+ memberRowIndex.set(members, index);
784
+ }
785
+ return index.get(key) ?? null;
786
+ }
787
+
681
788
  /** The discovery's warnings: ignored `private:`, over every listed soul. (0.30 removed the
682
789
  * `unmapped-team-label` warning: an unmapped shared team is the readiness item `team-unmapped`.) */
683
790
  const byCodepointOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.17.1",
11
+ "ref": "v1.17.3",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.30.3",
3
+ "version": "0.32.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
package/lib/herdr.mjs DELETED
@@ -1,143 +0,0 @@
1
- /** Herdr 0.8.x/0.9.x host-local backend (explicit protocols 20/22).
2
- * SSH belongs to the CLI router. A new session records the server's protocol once; a recorded
3
- * target is never renegotiated. */
4
- import { execFileSync, spawn } from "node:child_process";
5
- import { existsSync, mkdirSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { dirname, join, resolve } from "node:path";
8
-
9
- // The legacy protocol literal; a new session records the protocol its server reports (ensureHerdr).
10
- export const HERDR_PROTOCOL = 20;
11
- export const HERDR_SUPPORTED_PROTOCOLS = Object.freeze([20, 22]);
12
- export const isSupportedHerdrProtocol = (value) => HERDR_SUPPORTED_PROTOCOLS.includes(value);
13
- function selectedHerdrProtocol(target) {
14
- if (!isSupportedHerdrProtocol(target?.protocol)) throw new Error("Herdr endpoint requires explicit supported protocol 20 or 22");
15
- return target.protocol;
16
- }
17
- const quote = (s) => `'${String(s).replaceAll("'", "'\\''")}'`;
18
- const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
-
20
- export function herdrSocket(session = "oats") {
21
- if (!/^[a-zA-Z0-9_-]+$/.test(session)) throw new Error("invalid Herdr session name");
22
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "herdr", "sessions", session, "herdr.sock");
23
- }
24
-
25
- /** One Herdr CLI call on the target's socket, its stdout as text. */
26
- function herdrOutput(target, args, { timeout = 10000, exec = execFileSync } = {}) {
27
- const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
28
- delete env.HERDR_SESSION;
29
- return exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
30
- }
31
-
32
- export function herdrCommand(target, args, io) {
33
- const output = herdrOutput(target, args, io);
34
- // Some successful terminal mutations have no output; reads below require
35
- // their documented result shape independently.
36
- if (!output.trim()) return {};
37
- const response = JSON.parse(output);
38
- if (response.error) throw Object.assign(new Error(response.error.message), { code: response.error.code });
39
- if (!response.result) throw new Error("Herdr returned no result");
40
- return response.result;
41
- }
42
-
43
- export function herdrSnapshot(target, io) {
44
- const protocol = selectedHerdrProtocol(target);
45
- const snapshot = herdrCommand(target, ["api", "snapshot"], io).snapshot;
46
- if (snapshot?.protocol !== protocol) throw new Error(`Herdr snapshot does not match selected protocol ${protocol}`);
47
- if (!Array.isArray(snapshot.panes)) throw new Error("Herdr snapshot has no pane inventory");
48
- return snapshot;
49
- }
50
-
51
- /** The target for a NEW session on the server at `socket`: it records the supported protocol that server
52
- * reports (Herdr 0.8 speaks 20, 0.9 speaks 22). Every later call checks the snapshot against that recorded
53
- * protocol, so a recorded target is never renegotiated. */
54
- function newSessionTarget(target, io) {
55
- const protocol = herdrCommand(target, ["api", "snapshot"], io).snapshot?.protocol;
56
- if (!isSupportedHerdrProtocol(protocol)) throw Object.assign(new Error(`Herdr server speaks protocol ${protocol ?? "unknown"}; OATS supports ${HERDR_SUPPORTED_PROTOCOLS.join(" and ")}`), { unsupportedProtocol: true });
57
- const selected = { ...target, protocol };
58
- herdrSnapshot(selected, io);
59
- return selected;
60
- }
61
-
62
- export function ensureHerdr({ binary = "herdr", socket, session = "oats", io } = {}) {
63
- const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)) };
64
- try { return newSessionTarget(target, io); }
65
- catch (e) {
66
- // An explicit socket belongs to an operator-managed server. Never start a
67
- // different server when it cannot be inspected or is incompatible.
68
- if (socket || existsSync(target.socket)) throw e;
69
- }
70
- mkdirSync(dirname(target.socket), { recursive: true });
71
- const env = { ...process.env };
72
- delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
73
- const child = spawn(binary, ["--session", session, "server"], { detached: true, stdio: "ignore", env });
74
- child.on("error", () => {});
75
- child.unref();
76
- let error;
77
- for (let i = 0; i < 30; i++) {
78
- pause(100);
79
- // A server that answers with an unsupported protocol is up: waiting longer cannot change its answer.
80
- try { return newSessionTarget(target, io); } catch (e) { if (e.unsupportedProtocol) throw e; error = e; }
81
- }
82
- throw new Error(`Herdr did not start: ${error?.message || "no socket"}`);
83
- }
84
-
85
- export function allocateHerdr(target, { home, instance }, io) {
86
- const protocol = selectedHerdrProtocol(target);
87
- const result = herdrCommand(target, ["workspace", "create", "--cwd", home, "--label", instance, "--no-focus"], io);
88
- const pane = result.root_pane;
89
- if (!pane?.pane_id || !pane?.terminal_id || !pane?.workspace_id) throw new Error("Herdr allocation returned no session identity");
90
- return { ...target, protocol, workspaceId: pane.workspace_id, paneId: pane.pane_id, terminalId: pane.terminal_id };
91
- }
92
-
93
- export function inspectHerdr(target, io) {
94
- const snapshot = herdrSnapshot(target, io);
95
- const matches = snapshot.panes.filter((p) => p.pane_id === target.paneId && p.terminal_id === target.terminalId);
96
- if (io?.strictHerdrTarget && (matches.length > 1 || (matches.length === 1 && matches[0].workspace_id !== target.workspaceId))) throw new Error("Herdr target workspace/pane/terminal identity is ambiguous or changed");
97
- const pane = matches[0];
98
- const agent = pane && snapshot.agents?.find((a) => a.terminal_id === target.terminalId);
99
- return { present: !!pane, pane, agent, status: agent?.agent_status || "unknown" };
100
- }
101
-
102
- /** Wait until a new pane's shell has drawn something (its prompt) before typing into it. Text typed
103
- * earlier sits in the terminal's canonical line buffer, which drops a line past 1024 bytes on macOS:
104
- * a launch command is longer than that, and arrived cut. Bounded; a Herdr that cannot read a pane
105
- * leaves the old behaviour (type at once). */
106
- function awaitShellReady(target, io) {
107
- const deadline = Date.now() + (io?.shellReadyMs ?? 10000);
108
- for (;;) {
109
- let text;
110
- // `pane read` answers the screen as plain text, not a JSON envelope.
111
- const remaining = Math.max(100, deadline - Date.now());
112
- try { text = herdrOutput(target, ["pane", "read", target.paneId, "--source", "visible"], { ...io, timeout: Math.min(2000, remaining) }); }
113
- catch { return; }
114
- if (typeof text === "string" && text.trim()) return;
115
- if (Date.now() >= deadline) return;
116
- pause(100);
117
- }
118
- }
119
-
120
- export function launchHerdr(target, command, io) {
121
- if (!inspectHerdr(target, io).present) throw new Error("Herdr launch pane disappeared");
122
- awaitShellReady(target, io);
123
- // This is the allocated shell's one launch command. Exec avoids a fallback
124
- // shell that could accidentally consume a subsequent agent notification.
125
- herdrCommand(target, ["pane", "run", target.paneId, `exec /bin/sh -c ${quote(command)}`], io);
126
- }
127
-
128
- export function stopHerdr(target, io) {
129
- if (!inspectHerdr(target, io).present) return;
130
- try { herdrCommand(target, ["pane", "close", target.paneId], io); } catch { /* inspect the effect even if the pane exited concurrently */ }
131
- if (inspectHerdr(target, io).present) throw new Error(`Herdr pane ${target.paneId} is still present`);
132
- }
133
-
134
- export function inputHerdr(target, text, io) {
135
- if (!inspectHerdr(target, io).present) throw new Error("Herdr session stopped or was replaced");
136
- herdrCommand(target, ["pane", "run", target.paneId, text], io);
137
- }
138
-
139
- export function validHerdrTarget(value) {
140
- return value?.backend === "herdr" && isSupportedHerdrProtocol(value.protocol)
141
- && [value.socket, value.binary, value.workspaceId, value.paneId, value.terminalId].every((v) => typeof v === "string" && v.length > 0)
142
- && value.socket === resolve(value.socket);
143
- }