@awebai/oats 0.31.0 → 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/bin/oats.mjs +141 -36
- package/docs/configuration.md +65 -8
- package/docs/desktop-cli-api.md +121 -16
- package/docs/desktop.md +16 -0
- package/docs/implementation.md +58 -0
- package/docs/oats-local.schema.json +2 -1
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +75 -29
- package/lib/instance-inspect.mjs +2 -1
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/remote.mjs +792 -36
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package.json +1 -1
package/lib/resolve.mjs
CHANGED
|
@@ -60,6 +60,7 @@ import * as defaultRemote from "./remote.mjs";
|
|
|
60
60
|
import { BY_TEAM_REMOVED, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
|
|
61
61
|
import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
|
|
62
62
|
import { settingValueProblems } from "./capability-contract.mjs";
|
|
63
|
+
import { memberRowByKey } from "./workspace.mjs";
|
|
63
64
|
|
|
64
65
|
export const RESOLUTION_API = 1;
|
|
65
66
|
export const SLOTS = Object.freeze(["knowledge", "messaging", "tasks"]);
|
|
@@ -367,14 +368,26 @@ export function composeCapabilities(workspace, soulDefinition, { offs = null } =
|
|
|
367
368
|
|
|
368
369
|
/* ───────────────────────────── lookups ────────────────────────────────── */
|
|
369
370
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
371
|
+
/** A discovery's member row by key (the first with that key; lib/workspace.mjs memberRowByKey). */
|
|
372
|
+
const memberRow = (discovery, repoKey) => memberRowByKey(discovery?.members, repoKey);
|
|
373
373
|
|
|
374
|
-
/** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key.
|
|
374
|
+
/** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key.
|
|
375
|
+
* The key → first listed ref index is built once per members array and parseRepoRef. */
|
|
376
|
+
const memberRefIndex = new WeakMap();
|
|
375
377
|
export function memberRef(discovery, remote, repoKey) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
const members = discovery?.workspace?.members;
|
|
379
|
+
if (Array.isArray(members)) {
|
|
380
|
+
let byParser = memberRefIndex.get(members);
|
|
381
|
+
if (!byParser) memberRefIndex.set(members, byParser = new Map());
|
|
382
|
+
let index = byParser.get(remote.parseRepoRef);
|
|
383
|
+
if (!index) {
|
|
384
|
+
index = new Map();
|
|
385
|
+
for (const ref of members) {
|
|
386
|
+
try { const key = remote.parseRepoRef(ref).key; if (!index.has(key)) index.set(key, ref); } catch { /* validated upstream */ }
|
|
387
|
+
}
|
|
388
|
+
byParser.set(remote.parseRepoRef, index);
|
|
389
|
+
}
|
|
390
|
+
if (index.has(repoKey)) return index.get(repoKey);
|
|
378
391
|
}
|
|
379
392
|
return refForKey(repoKey);
|
|
380
393
|
}
|
|
@@ -496,7 +509,10 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
|
|
|
496
509
|
// Discovery already listed the capability directory (relative to it): project onto this entry.
|
|
497
510
|
entries = listing.filter((e) => e.path === declared || e.path.startsWith(`${declared}/`)).map((e) => ({ ...e, path: e.path === declared ? "" : e.path.slice(declared.length + 1) })).filter((e) => e.path);
|
|
498
511
|
} else {
|
|
499
|
-
|
|
512
|
+
// A listing at a commit never changes: the parsed cache keeps it (lib/remote.mjs memoAtCommit).
|
|
513
|
+
const at = posix.join(dir, declared);
|
|
514
|
+
const list = () => remote.listRemoteTree(ref, commit, at, { depth: 2 });
|
|
515
|
+
entries = typeof remote.memoAtCommit === "function" ? await remote.memoAtCommit(ref, commit, `list\0${at}\0${2}`, list) : await list();
|
|
500
516
|
}
|
|
501
517
|
const found = skillsInListing(declared, entries);
|
|
502
518
|
if (found.length === 0) throw missing(raw, "skill-missing", `declares skills entry ${show(raw)} but no SKILL.md is there (neither ${declared}/SKILL.md nor ${declared}/*/SKILL.md)`);
|
package/lib/servers.mjs
CHANGED
|
@@ -943,6 +943,13 @@ function launchRemoteSession(serverId, action, choices, io) {
|
|
|
943
943
|
/** A launch definition in the host's vocabulary: `harness` for a host with the harness feature,
|
|
944
944
|
* `runtime` for one before 0.27.0. Either spelling is read here; both, disagreeing, are refused. */
|
|
945
945
|
function hostLaunchDefinition(remote, definition) {
|
|
946
|
+
// `default` (0.32) never reaches a host without the feature: `true` is refused, `false` (the absence) dropped.
|
|
947
|
+
if (definition && Object.hasOwn(definition, "default") && !(remote?.features || []).includes("launch-config-default")) {
|
|
948
|
+
if (definition.default === true) throw serverError("E_REMOTE_INCOMPATIBLE", "the server's OATS does not support a default launch configuration (feature launch-config-default, OATS 0.32+); upgrade it, or set the configuration without default");
|
|
949
|
+
if (definition.default !== false) throw serverError("E_LAUNCH_CONFIG_INVALID", "default must be true or false");
|
|
950
|
+
const { default: _absent, ...without } = definition;
|
|
951
|
+
definition = without;
|
|
952
|
+
}
|
|
946
953
|
const { harness, runtime, ...rest } = definition; // a disagreeing pair was refused before the probe
|
|
947
954
|
if (runtime !== undefined) noteRuntimeName("runtime in the launch configuration definition (sent as the host's harness key)");
|
|
948
955
|
const value = harness ?? runtime;
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
408
|
-
if (
|
|
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
|
-
|
|
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
|
|
425
|
-
try {
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
throw e;
|
|
598
|
-
}
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
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",
|