@awebai/oats 0.25.0 → 0.25.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/bin/oats.mjs +68 -21
- package/docs/conventions.md +51 -24
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-23-simplified-workspace-model.md +1 -1
- package/docs/design/2026-09-23-workspace-module-contracts.md +151 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +2 -2
- package/docs/desktop-cli-api.md +32 -0
- package/docs/desktop-succession.md +9 -2
- package/docs/desktop.md +9 -4
- package/docs/execution-targets.md +16 -4
- package/docs/implementation.md +35 -7
- package/docs/integrations.md +5 -3
- package/docs/knowledge-migration.md +16 -8
- package/docs/knowledge.md +36 -10
- package/docs/migration-from-oas.md +20 -9
- package/docs/rebuild-to-v2.md +119 -5
- package/docs/release-notes/v0.25.1.md +94 -0
- package/docs/schedules.md +12 -6
- package/docs/souls-and-instances.md +2 -2
- package/docs/workspaces.md +8 -1
- package/lib/core.mjs +45 -8
- package/lib/instance-resolution.mjs +96 -21
- package/lib/operator-dispatch.mjs +117 -0
- package/lib/packages.mjs +62 -1
- package/lib/remote.mjs +128 -49
- package/lib/resolve.mjs +70 -8
- package/lib/workspace.mjs +25 -6
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -6131,7 +6131,13 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6131
6131
|
if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
|
|
6132
6132
|
if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
|
|
6133
6133
|
const launch = o.launch !== false;
|
|
6134
|
-
|
|
6134
|
+
// Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
|
|
6135
|
+
// boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
|
|
6136
|
+
// is a plain directory with member clones beside it, not a Git checkout. It is
|
|
6137
|
+
// the execution/config context too; no Git identity is required or recorded.
|
|
6138
|
+
const preparedDeployment = o.prepared && work === "workspace" && typeof o.prepared.deployment === "string" && o.prepared.deployment ? resolve(o.prepared.deployment) : undefined;
|
|
6139
|
+
if (preparedDeployment !== undefined && !(existsSync(preparedDeployment) && statSync(preparedDeployment).isDirectory())) throw oatsError("E_BAD_ARGS", `workspace mode: the deployment directory ${preparedDeployment} (where oats-local.yaml lives) is not a directory`);
|
|
6140
|
+
const repoAbs = preparedDeployment ?? resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
|
|
6135
6141
|
if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
|
|
6136
6142
|
// Launch selection: a named configuration (explicit, or the soul's
|
|
6137
6143
|
// launch-config default), or none; the runtime and model follow from it.
|
|
@@ -6564,7 +6570,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6564
6570
|
const preparedCapabilities = o.prepared ? o.prepared.resolution.modules.map((m) => ({ name: m.name, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}` })) : null;
|
|
6565
6571
|
const preparedSkills = o.prepared ? o.prepared.resolution.modules.flatMap((m) => (m.manifest?.skills || []).filter((s) => typeof s === "string" && s).map((s) => ({ name: basename(s.replace(/\/+$/, "")), source: `module:${m.name}` }))) : [];
|
|
6566
6572
|
return deliver({
|
|
6567
|
-
...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
|
|
6573
|
+
...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, declRevision: o.prepared.resolution.declRevision ?? null, payloadRevision: o.prepared.resolution.payloadRevision ?? null, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
|
|
6568
6574
|
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6569
6575
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6570
6576
|
decision, preflight,
|
|
@@ -6684,7 +6690,29 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6684
6690
|
initializeNativeHistory(home);
|
|
6685
6691
|
|
|
6686
6692
|
// Body: the soul is linked for reference, while instructions are a generated instance-local view.
|
|
6687
|
-
|
|
6693
|
+
// The home's soul link. A workspace soul (o.prepared) lives in the per-commit cache
|
|
6694
|
+
// agents/<name>/souls/<commit12>/ and agents/<name>/soul is only the kernel-owned
|
|
6695
|
+
// "current" POINTER (a symlink swapped by every ensureWorkspaceSoul, including a
|
|
6696
|
+
// preview's). The home links ITS commit's directory by realpath — never the pointer —
|
|
6697
|
+
// so a later fetch can move "current" without changing anything under a running
|
|
6698
|
+
// instance (decision 7); the OKF hook's owner pin (realpath of <home>/soul) stays valid.
|
|
6699
|
+
let homeSoulTarget = soulDir;
|
|
6700
|
+
if (o.prepared?.soulEntry?.commit) {
|
|
6701
|
+
const perCommit = join(agent._dir, "souls", String(o.prepared.soulEntry.commit).slice(0, 12));
|
|
6702
|
+
if (existsSync(join(perCommit, "soul.yaml"))) homeSoulTarget = realpathSync(perCommit);
|
|
6703
|
+
}
|
|
6704
|
+
if (homeSoulTarget === soulDir) {
|
|
6705
|
+
// Not prepared (or the cache entry is absent): still never link a swappable pointer —
|
|
6706
|
+
// when agents/<name>/soul is the kernel's pointer into souls/, link what it shows now.
|
|
6707
|
+
try {
|
|
6708
|
+
if (lstatSync(soulDir).isSymbolicLink()) {
|
|
6709
|
+
const real = realpathSync(soulDir), soulsReal = realpathSync(join(dirname(soulDir), "souls"));
|
|
6710
|
+
const rel = relative(soulsReal, real);
|
|
6711
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel) && !rel.includes(sep)) homeSoulTarget = real;
|
|
6712
|
+
}
|
|
6713
|
+
} catch { /* absent souls/ or unreadable link: link the classic soul dir */ }
|
|
6714
|
+
}
|
|
6715
|
+
symlinkSync(homeSoulTarget, join(home, "soul"));
|
|
6688
6716
|
if (!o.prepared) { writeFileSync(join(home, "AGENTS.md"), composition.text); symlinkSync("AGENTS.md", join(home, "CLAUDE.md")); }
|
|
6689
6717
|
else if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
|
|
6690
6718
|
|
|
@@ -6839,13 +6867,22 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6839
6867
|
symlinkSync(resolve(o.workDir), join(home, "work"));
|
|
6840
6868
|
branch = shTry(`git -C ${shq(o.workDir)} rev-parse --abbrev-ref HEAD`);
|
|
6841
6869
|
} else if (work === "workspace") {
|
|
6842
|
-
// Cross-repo coordinator: ./work is the
|
|
6843
|
-
//
|
|
6844
|
-
//
|
|
6870
|
+
// Cross-repo coordinator: ./work is the deployment boundary, not a repo — member
|
|
6871
|
+
// repos are read-context; repo edits are routed, not made.
|
|
6872
|
+
// workspace model (o.prepared): the directory holding oats-local.yaml
|
|
6873
|
+
// (prepared.deployment — the taught <name>-workspace/, member clones beside it);
|
|
6874
|
+
// classic: config team: scope, else the workspace-scope oats-config.yaml.
|
|
6845
6875
|
const resolvedCfgEarly = composition.resolved;
|
|
6846
|
-
const wsRoot =
|
|
6876
|
+
const wsRoot = preparedDeployment
|
|
6877
|
+
|| resolvedCfgEarly.team?.scope
|
|
6847
6878
|
|| resolvedCfgEarly.chain?.find((c) => c._level !== homedir())?._level;
|
|
6848
|
-
if (!wsRoot) {
|
|
6879
|
+
if (!wsRoot) {
|
|
6880
|
+
rmSync(home, { recursive: true, force: true });
|
|
6881
|
+
const remedy = o.prepared
|
|
6882
|
+
? `add oats-local.yaml (workspace: <ref> or standalone: <ref>) to the deployment directory so ./work has a root`
|
|
6883
|
+
: `add a "team:" block (or a workspace-scope oats-config.yaml) so ./work has a root`;
|
|
6884
|
+
throw new Error(`workspace mode needs a declared boundary — ${remedy}`);
|
|
6885
|
+
}
|
|
6849
6886
|
symlinkSync(resolve(wsRoot), join(home, "work"));
|
|
6850
6887
|
branch = undefined; // no repo identity: the workspace is not a git tree
|
|
6851
6888
|
} else {
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Nothing here reads `oats-config.yaml`, an installed-capability directory or a
|
|
18
18
|
* per-soul `source:` — those do not exist in this model. */
|
|
19
|
-
import { existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
|
|
20
|
-
import { join, resolve as resolvePath, dirname, basename } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
|
|
20
|
+
import { join, resolve as resolvePath, dirname, basename, relative, isAbsolute, sep } from "node:path";
|
|
21
21
|
import { oatsError } from "./errors.mjs";
|
|
22
22
|
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./workspace.mjs";
|
|
23
23
|
import { resolveSoul, packageRef } from "./resolve.mjs";
|
|
@@ -98,29 +98,104 @@ export function findSoulEntry(discovery, name) {
|
|
|
98
98
|
return hits[0];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
101
|
+
/** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
|
|
102
|
+
* Each entry is IMMUTABLE once written (fetched to staging, renamed in) and is never
|
|
103
|
+
* removed by the kernel — a running instance's `<home>/soul` links straight at its
|
|
104
|
+
* own commit's directory (realpath), so nothing can change under it. */
|
|
105
|
+
export const SOULS_DIR = "souls";
|
|
106
|
+
export const SOUL_SOURCE_STAMP = ".oats-soul-source.json";
|
|
107
|
+
const commit12 = (c) => String(c || "").slice(0, 12) || "unknown";
|
|
108
|
+
/** The kernel-owned "current" pointer `<agentDir>/soul` is a symlink whose target
|
|
109
|
+
* sits inside `<agentDir>/souls/`. Returns that target's realpath, or null when the
|
|
110
|
+
* path is absent, a real directory (0.25.0 layout) or a symlink elsewhere. */
|
|
111
|
+
export function soulPointerTarget(soulDir) {
|
|
112
|
+
let st; try { st = lstatSync(soulDir); } catch { return null; }
|
|
113
|
+
if (!st.isSymbolicLink()) return null;
|
|
114
|
+
let real; try { real = realpathSync(soulDir); } catch { return null; }
|
|
115
|
+
let soulsReal; try { soulsReal = realpathSync(join(dirname(soulDir), SOULS_DIR)); } catch { return null; }
|
|
116
|
+
const rel = relative(soulsReal, real);
|
|
117
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
|
|
118
|
+
return real;
|
|
119
|
+
}
|
|
120
|
+
/** Atomically point `<agentDir>/soul` at `target`: symlink to a temp name, rename over.
|
|
121
|
+
* The previous target directory is untouched (an instance may link it). */
|
|
122
|
+
function swapSoulPointer(soulDir, target) {
|
|
123
|
+
const tmp = `${soulDir}.pointer-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
124
|
+
try {
|
|
125
|
+
symlinkSync(target, tmp);
|
|
126
|
+
renameSync(tmp, soulDir); // rename over an existing symlink replaces it; over a directory it fails (caller migrates first)
|
|
127
|
+
} catch (x) { try { rmSync(tmp, { force: true }); } catch { /* nothing */ } throw x; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into the
|
|
131
|
+
* per-commit cache `<agentsRoot>/<name>/souls/<commit12>/` and point the classic
|
|
132
|
+
* `<agentsRoot>/<name>/soul` at it (a SYMLINK, swapped atomically), so the classic
|
|
133
|
+
* spawn skeleton, findAgent, doctor … keep reading "current" from the usual place
|
|
134
|
+
* while every spawned home links its OWN commit's directory (decision 7: an
|
|
135
|
+
* instance never changes under itself — a preview or a later spawn may fetch a new
|
|
136
|
+
* commit and move the pointer, but no directory an instance links is ever touched).
|
|
137
|
+
*
|
|
138
|
+
* Idempotent per (repo, commit): an existing complete entry is reused (never
|
|
139
|
+
* refetched, never rewritten); an entry that lost its soul.yaml is moved aside and
|
|
140
|
+
* refetched. `.oats-soul-source.json` stamps what the pointer currently shows.
|
|
141
|
+
* Migration: a 0.25.0 `soul/` that is a REAL directory is moved to
|
|
142
|
+
* `souls/<commit from the stamp | unknown>/` before the pointer replaces it.
|
|
143
|
+
* Returns the PER-COMMIT directory (what a home should link). */
|
|
106
144
|
export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
107
145
|
const e = prepared.soulEntry;
|
|
108
146
|
const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
mkdirSync(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
const soulsDir = join(agentDir, SOULS_DIR);
|
|
148
|
+
const stamp = join(agentDir, SOUL_SOURCE_STAMP);
|
|
149
|
+
const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
|
|
150
|
+
const commitDir = join(soulsDir, commit12(e.commit));
|
|
151
|
+
mkdirSync(soulsDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
// --- migration: a 0.25.0 real `soul/` directory becomes souls/<commit|unknown>/ ---
|
|
154
|
+
let st; try { st = lstatSync(soulDir); } catch { st = null; }
|
|
155
|
+
if (st && !st.isSymbolicLink() && st.isDirectory()) {
|
|
156
|
+
const cur = readStamp();
|
|
157
|
+
const legacyCommit = cur && cur.repoKey === e.repoKey && typeof cur.commit === "string" && cur.commit ? commit12(cur.commit) : "unknown";
|
|
158
|
+
let dest = join(soulsDir, legacyCommit);
|
|
159
|
+
if (existsSync(dest)) dest = join(soulsDir, `${legacyCommit}.migrated-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
160
|
+
renameSync(soulDir, dest);
|
|
161
|
+
swapSoulPointer(soulDir, dest);
|
|
162
|
+
} else if (st && !st.isSymbolicLink()) {
|
|
163
|
+
// a regular file where the pointer belongs: not ours to keep
|
|
164
|
+
rmSync(soulDir, { force: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- the per-commit entry: reuse when complete, else fetch (staging → rename in) ---
|
|
168
|
+
let complete = existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"));
|
|
169
|
+
if (existsSync(commitDir) && !complete) {
|
|
170
|
+
// A damaged entry (someone removed soul.yaml): move it aside — an instance may
|
|
171
|
+
// still link it, so it is never deleted — and fetch a fresh copy under the canonical name.
|
|
172
|
+
renameSync(commitDir, `${commitDir}.damaged-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
173
|
+
}
|
|
174
|
+
if (!complete) {
|
|
175
|
+
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
176
|
+
const staging = join(soulsDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
177
|
+
try {
|
|
178
|
+
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
179
|
+
await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
|
|
180
|
+
if (!existsSync(join(staging, "soul.yaml")) || !existsSync(join(staging, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
|
|
181
|
+
if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
|
|
182
|
+
try { renameSync(staging, commitDir); }
|
|
183
|
+
catch (x) {
|
|
184
|
+
// Lost a race with a concurrent fetch of the same commit: theirs is the same bytes.
|
|
185
|
+
if (!(existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md")))) throw x;
|
|
186
|
+
rmSync(staging, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
} catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- the "current" pointer + its stamp ---
|
|
192
|
+
const target = realpathSync(commitDir);
|
|
193
|
+
if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
|
|
194
|
+
const cur = readStamp();
|
|
195
|
+
if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
|
|
121
196
|
writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
122
|
-
|
|
123
|
-
|
|
197
|
+
}
|
|
198
|
+
return target;
|
|
124
199
|
}
|
|
125
200
|
|
|
126
201
|
/** The async half of a spawn: everything that touches the network. Returns a
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Operator-level capability commands — `oats <ns> <cmd>` run from a DEPLOYMENT
|
|
2
|
+
* directory (one holding `oats-local.yaml`), not from an instance home.
|
|
3
|
+
*
|
|
4
|
+
* Contract (docs/design/2026-09-23-workspace-module-contracts.md, "Post-0.25.0
|
|
5
|
+
* clarifications"): resolve exactly as `oats spawn --soul <name>` would —
|
|
6
|
+
* `prepareInstance(dir, soul)` → the soul's Resolution — find the module whose
|
|
7
|
+
* manifest `command` is the namespace, fetch that capability into the
|
|
8
|
+
* deployment's per-commit module store `<deployment>/.oats/modules/<cap>@<commit12>/`
|
|
9
|
+
* (the same store capability-defined agents use; see
|
|
10
|
+
* lib/instance-resolution.mjs#resolvePackageCapabilityAgent) and dispatch to that
|
|
11
|
+
* copy with the soul's merged payload as `OATS_SETTINGS`.
|
|
12
|
+
*
|
|
13
|
+
* Never "the newest instance's copy" (an instance is not an authority for the
|
|
14
|
+
* deployment) and never an unlocked cache read: trust is exactly spawn's —
|
|
15
|
+
* members by membership, packages by the lock's approval (`prepareInstance`
|
|
16
|
+
* already refuses E_PACKAGE_UNAPPROVED). A module in the Resolution IS active.
|
|
17
|
+
*
|
|
18
|
+
* `--soul` is required: without a soul there is no Resolution to answer from
|
|
19
|
+
* (E_BAD_ARGS naming --soul). Nothing here reads oats-config.yaml. */
|
|
20
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { oatsError } from "./errors.mjs";
|
|
24
|
+
import { loadLocal } from "./workspace.mjs";
|
|
25
|
+
import { packageRef, refForKey } from "./resolve.mjs";
|
|
26
|
+
import { MODULES_DIR } from "./materialize.mjs";
|
|
27
|
+
import * as defaultRemote from "./remote.mjs";
|
|
28
|
+
import { prepareInstance } from "./instance-resolution.mjs";
|
|
29
|
+
|
|
30
|
+
function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
|
|
31
|
+
|
|
32
|
+
/** Is `dir` (or an ancestor) a v2 deployment? → the loadLocal result or null when
|
|
33
|
+
* no oats-local.yaml is in reach (the caller then falls back to the classic chain).
|
|
34
|
+
* Any OTHER failure (a malformed oats-local.yaml → E_WORKSPACE_SCHEMA) propagates. */
|
|
35
|
+
export function deploymentOf(dir) {
|
|
36
|
+
try { return loadLocal(dir); }
|
|
37
|
+
catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The repo ref a module's tree is fetched from, spelled as lib/remote.mjs accepts it:
|
|
41
|
+
* member → the member repoKey (`local/<abs>` → the abs path, else `git:<key>`);
|
|
42
|
+
* package → packageRef over the lock entry (catalog url or git source). */
|
|
43
|
+
export function moduleRef(module, lock, { catalog = null, remote = defaultRemote } = {}) {
|
|
44
|
+
const from = module?.from;
|
|
45
|
+
if (!from || typeof from !== "object") throw err("E_CAPABILITY_BROKEN", `module ${module?.name ?? "?"}: resolution carries no provenance`, { module: module?.name ?? null });
|
|
46
|
+
if (from.kind === "member") return refForKey(from.repoKey);
|
|
47
|
+
if (from.kind === "package") {
|
|
48
|
+
const entry = lock?.packages?.[from.package];
|
|
49
|
+
if (!entry || typeof entry !== "object") throw err("E_PACKAGE_MISSING", `module ${module.name}: package ${from.package} is not in the deployment's lock`, { module: module.name, id: from.package });
|
|
50
|
+
return packageRef(from.package, entry, catalog, remote);
|
|
51
|
+
}
|
|
52
|
+
throw err("E_CAPABILITY_BROKEN", `module ${module.name}: unknown provenance kind ${JSON.stringify(from.kind)}`, { module: module.name, kind: from.kind ?? null });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `<deployment>/.oats/modules/<cap>@<commit12>` — the per-commit store path of a module. */
|
|
56
|
+
export function moduleStoreDir(deployment, module) {
|
|
57
|
+
return join(deployment, MODULES_DIR, `${module.name}@${String(module.from.commit).slice(0, 12)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ensure the module's capability tree is in the deployment store; returns its dir.
|
|
61
|
+
* A tree already there (oats.json present) is reused — the commit is in the name,
|
|
62
|
+
* so it cannot be stale. The fetch lands in a sibling staging dir and is renamed
|
|
63
|
+
* into place, so a failed or interrupted fetch never leaves a half tree that a
|
|
64
|
+
* later call would trust. */
|
|
65
|
+
export async function ensureModuleTree(deployment, module, lock, { catalog = null, remote = defaultRemote, remoteOptions, fetch = defaultRemote.fetchRemoteTree } = {}) {
|
|
66
|
+
const store = join(deployment, MODULES_DIR);
|
|
67
|
+
const dir = moduleStoreDir(deployment, module);
|
|
68
|
+
if (existsSync(join(dir, "oats.json"))) return dir;
|
|
69
|
+
const ref = moduleRef(module, lock, { catalog, remote });
|
|
70
|
+
if (typeof module.dir !== "string" || !module.dir) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: resolution records no capability directory`, { module: module.name });
|
|
71
|
+
mkdirSync(store, { recursive: true });
|
|
72
|
+
const staging = join(store, `.staging-${module.name}-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
73
|
+
try {
|
|
74
|
+
await fetch(ref, module.from.commit, module.dir, staging, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
75
|
+
if (!existsSync(join(staging, "oats.json"))) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: ${module.dir} at ${String(module.from.commit).slice(0, 12)} has no oats.json`, { module: module.name, dir: module.dir, commit: module.from.commit });
|
|
76
|
+
// A concurrent call may have won; its tree is the same commit — keep it.
|
|
77
|
+
if (existsSync(join(dir, "oats.json"))) { rmSync(staging, { recursive: true, force: true }); return dir; }
|
|
78
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); // a half tree from an interrupted direct write
|
|
79
|
+
renameSync(staging, dir);
|
|
80
|
+
return dir;
|
|
81
|
+
} catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve an operator-level command namespace from a deployment directory.
|
|
86
|
+
*
|
|
87
|
+
* contextDir — where the operator stands (oats-local.yaml is found walking up)
|
|
88
|
+
* namespace — the `<ns>` the operator typed (`okf`)
|
|
89
|
+
* soulName — the value of --soul; REQUIRED (undefined/true/"" → E_BAD_ARGS)
|
|
90
|
+
*
|
|
91
|
+
* → { deployment, soul: { name, repoKey, commit, team }, module, manifest, commands,
|
|
92
|
+
* settings: <resolution.payloads[cap] or {}>, resolution, prepared,
|
|
93
|
+
* ensureTree(): Promise<dir> } — or null when no module of the soul's
|
|
94
|
+
* resolution claims the namespace (the caller answers E_UNKNOWN_COMMAND).
|
|
95
|
+
* Two modules claiming one namespace → E_DUPLICATE_NAMESPACE. Everything
|
|
96
|
+
* prepareInstance can throw (E_SOUL_UNKNOWN, E_PACKAGE_UNAPPROVED,
|
|
97
|
+
* E_REMOTE_UNREADABLE, …) propagates untouched.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveOperatorDispatch(contextDir, namespace, soulName, { remoteOptions, remote, catalog = null, fetch, prepare = prepareInstance } = {}) {
|
|
100
|
+
if (typeof namespace !== "string" || !namespace) throw err("E_BAD_ARGS", "a command namespace is required");
|
|
101
|
+
if (typeof soulName !== "string" || !soulName.trim()) {
|
|
102
|
+
throw err("E_BAD_ARGS", `oats ${namespace}: outside an instance home a capability command resolves as a spawn would — pass --soul <name> (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
|
|
103
|
+
}
|
|
104
|
+
const prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
|
|
105
|
+
const { resolution, lock } = prepared;
|
|
106
|
+
const deployment = prepared.deployment ?? dirname(loadLocal(contextDir).path);
|
|
107
|
+
const claimants = (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
|
|
108
|
+
if (!claimants.length) return null;
|
|
109
|
+
if (claimants.length > 1) throw err("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${namespace}": ${claimants.map((m) => m.name).join(", ")}`, { namespace, modules: claimants.map((m) => m.name) });
|
|
110
|
+
const module = claimants[0];
|
|
111
|
+
const payload = resolution.payloads?.[module.name];
|
|
112
|
+
const settings = payload && typeof payload === "object" ? { ...payload } : {};
|
|
113
|
+
return {
|
|
114
|
+
deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared,
|
|
115
|
+
ensureTree: () => ensureModuleTree(deployment, module, lock, { catalog, remote, remoteOptions: remoteOptions ?? prepared.remoteOptions, fetch }),
|
|
116
|
+
};
|
|
117
|
+
}
|
package/lib/packages.mjs
CHANGED
|
@@ -43,6 +43,12 @@
|
|
|
43
43
|
* the executable target whose bytes are digested. Only command targets are
|
|
44
44
|
* digested — skills, injects and other files are covered by `integrity`.
|
|
45
45
|
*
|
|
46
|
+
* `executablesDigestAt(remote, ref, commit, path, capabilities?)` is the ONE shared
|
|
47
|
+
* computation of that digest over a remote tree at a commit: `oats sync` approves
|
|
48
|
+
* what it returns, `resolveSoul` requires equality with `approved.executables` at
|
|
49
|
+
* spawn (E_PACKAGE_UNAPPROVED reason "digest-mismatch"). An edited lock — same
|
|
50
|
+
* id/version, different commit, copied approval — can therefore never materialize.
|
|
51
|
+
*
|
|
46
52
|
* Deprecated shims: every name the 0.24 kernel still imports from this module
|
|
47
53
|
* is exported below as a thin function throwing E_REMOVED so nothing breaks at
|
|
48
54
|
* import time; callers are deleted in the next phase.
|
|
@@ -370,6 +376,61 @@ export async function readPackageTree(remote, remoteRef, commit, path, { depth =
|
|
|
370
376
|
return { manifests };
|
|
371
377
|
}
|
|
372
378
|
|
|
379
|
+
/**
|
|
380
|
+
* The executables digest of a locked package AT A COMMIT, read over the remote — the ONE definition
|
|
381
|
+
* `oats sync` (approval) and `resolveSoul` (the gate at spawn) share, so what was approved is exactly what
|
|
382
|
+
* is checked. `remote` is a contract-§1 remote (default lib/remote.mjs; `remoteOptions` bound here).
|
|
383
|
+
*
|
|
384
|
+
* executablesDigestAt(remote, ref, commit, path, capabilities?, { remoteOptions } = {})
|
|
385
|
+
* → { digest: "sha256-…", executables: [{ capability, kind, name, target }], capabilities: [<names>] } (frozen)
|
|
386
|
+
*
|
|
387
|
+
* `capabilities` (optional, the lock entry's list) is checked against what `<path>/oats-package.json`
|
|
388
|
+
* declares at `commit`: a mismatch is E_PACKAGE_INTEGRITY { why: "capabilities", listed, locked } — a lock
|
|
389
|
+
* whose capability list drifted from the tree it names is not describing that tree.
|
|
390
|
+
*
|
|
391
|
+
* Cached per process by (remote identity, repo key, commit, path): a commit is content-addressed, so the
|
|
392
|
+
* tree — and its digest — can never change under the same OID. The cache is keyed on the remote OBJECT
|
|
393
|
+
* (WeakMap): a test's fake remote gets its own cache and never sees another test's tree; the kernel's
|
|
394
|
+
* default remote keeps one cache for the process (sync → many spawns of the same lock).
|
|
395
|
+
*/
|
|
396
|
+
const digestCache = new WeakMap();
|
|
397
|
+
export async function executablesDigestAt(remote, ref, commit, path, capabilities = null, { remoteOptions } = {}) {
|
|
398
|
+
if (!remote || typeof remote.readRemoteFile !== "function" || typeof remote.listRemoteTree !== "function") {
|
|
399
|
+
throw new TypeError("executablesDigestAt: remote must provide readRemoteFile()/listRemoteTree() (module contract §1)");
|
|
400
|
+
}
|
|
401
|
+
if (typeof commit !== "string" || !OID_RE.test(commit)) throw oatsError("E_PACKAGE_INTEGRITY", `executablesDigestAt: commit must be a full 40-hex OID, got ${JSON.stringify(commit)}`, { commit, path });
|
|
402
|
+
if (capabilities !== null && (!Array.isArray(capabilities) || capabilities.some((c) => typeof c !== "string"))) throw new TypeError("executablesDigestAt: capabilities must be an array of names or null");
|
|
403
|
+
const parse = typeof remote.parseRepoRef === "function" ? remote.parseRepoRef : defaultRemote.parseRepoRef;
|
|
404
|
+
let repoKey; try { repoKey = parse(ref).key; } catch { repoKey = String(ref); }
|
|
405
|
+
const cacheKey = `${repoKey}\0${commit}\0${path}`;
|
|
406
|
+
let perRemote = digestCache.get(remote);
|
|
407
|
+
if (!perRemote) { perRemote = new Map(); digestCache.set(remote, perRemote); }
|
|
408
|
+
let pending = perRemote.get(cacheKey);
|
|
409
|
+
if (!pending) {
|
|
410
|
+
pending = (async () => {
|
|
411
|
+
const tree = await readPackageTree(remote, ref, commit, path, { remoteOptions });
|
|
412
|
+
const digest = executablesDigest(tree);
|
|
413
|
+
const executables = [];
|
|
414
|
+
for (const m of [...tree.manifests].sort((a, b) => byCodepoint(String(a.name), String(b.name)))) {
|
|
415
|
+
for (const x of manifestExecutables(m.manifest)) executables.push({ capability: m.name, kind: x.kind, name: x.name, target: x.target });
|
|
416
|
+
}
|
|
417
|
+
return Object.freeze({ digest, executables: Object.freeze(executables.map((x) => Object.freeze(x))), capabilities: Object.freeze(tree.manifests.map((m) => m.name).sort(byCodepoint)) });
|
|
418
|
+
})();
|
|
419
|
+
perRemote.set(cacheKey, pending);
|
|
420
|
+
// A failed read is not cached: the next caller retries (a transient remote failure must not pin an error).
|
|
421
|
+
pending.catch(() => { if (perRemote.get(cacheKey) === pending) perRemote.delete(cacheKey); });
|
|
422
|
+
}
|
|
423
|
+
const result = await pending;
|
|
424
|
+
if (capabilities !== null) {
|
|
425
|
+
const locked = [...capabilities].sort(byCodepoint);
|
|
426
|
+
const listed = result.capabilities;
|
|
427
|
+
if (locked.length !== listed.length || locked.some((c, i) => c !== listed[i])) {
|
|
428
|
+
throw oatsError("E_PACKAGE_INTEGRITY", `the lock lists capabilities [${locked.join(", ")}] for ${path} at ${commit.slice(0, 12)}, but the package there declares [${listed.join(", ")}]`, { why: "capabilities", commit, path, listed: [...listed], locked });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
|
|
373
434
|
// ---------- resolvePackages ----------
|
|
374
435
|
|
|
375
436
|
function assertDigest(what, value, details) {
|
|
@@ -454,7 +515,7 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
|
|
|
454
515
|
}
|
|
455
516
|
// A recorded approval must describe THESE executables, not merely any well-formed digest.
|
|
456
517
|
if (old.approved) {
|
|
457
|
-
const executables =
|
|
518
|
+
const { digest: executables } = await executablesDigestAt(remote, req.remoteRef, obs.commit, old.path);
|
|
458
519
|
if (executables !== old.approved.executables) {
|
|
459
520
|
throw oatsError("E_PACKAGE_UNAPPROVED",
|
|
460
521
|
`packages.${id} ${version}: the recorded approval ${old.approved.executables} does not match the package's executables ${executables} — approve again`,
|