@awebai/oats 0.25.0 → 0.25.2
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 +219 -56
- package/docs/configuration.md +3 -3
- package/docs/conventions.md +51 -24
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +1 -1
- package/docs/design/2026-09-23-simplified-workspace-model.md +5 -5
- package/docs/design/2026-09-23-workspace-module-contracts.md +257 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +5 -5
- package/docs/desktop-cli-api.md +33 -1
- package/docs/desktop-succession.md +9 -2
- package/docs/desktop.md +9 -4
- package/docs/execution-targets.md +16 -4
- package/docs/first-team.md +3 -3
- package/docs/implementation.md +35 -7
- package/docs/integrations.md +5 -3
- package/docs/knowledge-migration.md +16 -8
- package/docs/knowledge.md +50 -17
- package/docs/migration-from-oas.md +20 -9
- package/docs/rebuild-to-v2.md +295 -25
- package/docs/release-notes/v0.25.1.md +94 -0
- package/docs/release-notes/v0.25.2.md +80 -0
- package/docs/schedules.md +12 -6
- package/docs/souls-and-instances.md +36 -18
- package/docs/workspaces.md +73 -27
- package/lib/core.mjs +76 -10
- package/lib/instance-resolution.mjs +228 -23
- package/lib/materialize.mjs +29 -0
- 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
|
@@ -16,14 +16,15 @@
|
|
|
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";
|
|
24
24
|
import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
25
25
|
import { fetchRemoteTree } from "./remote.mjs";
|
|
26
|
-
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
|
|
26
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
27
28
|
import { randomBytes } from "node:crypto";
|
|
28
29
|
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
29
30
|
import * as defaultRemote from "./remote.mjs";
|
|
@@ -98,29 +99,233 @@ export function findSoulEntry(discovery, name) {
|
|
|
98
99
|
return hits[0];
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
102
|
+
/** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
|
|
103
|
+
* Each entry is IMMUTABLE once written (fetched to staging, renamed in) and is never
|
|
104
|
+
* removed by the kernel — a running instance's `<home>/soul` links straight at its
|
|
105
|
+
* own commit's directory (realpath), so nothing can change under it. */
|
|
106
|
+
export const SOULS_DIR = "souls";
|
|
107
|
+
export const SOUL_SOURCE_STAMP = ".oats-soul-source.json";
|
|
108
|
+
const commit12 = (c) => String(c || "").slice(0, 12) || "unknown";
|
|
109
|
+
/** The kernel-owned "current" pointer `<agentDir>/soul` is a symlink whose target
|
|
110
|
+
* sits inside `<agentDir>/souls/`. Returns that target's realpath, or null when the
|
|
111
|
+
* path is absent, a real directory (0.25.0 layout) or a symlink elsewhere. */
|
|
112
|
+
export function soulPointerTarget(soulDir) {
|
|
113
|
+
let st; try { st = lstatSync(soulDir); } catch { return null; }
|
|
114
|
+
if (!st.isSymbolicLink()) return null;
|
|
115
|
+
let real; try { real = realpathSync(soulDir); } catch { return null; }
|
|
116
|
+
let soulsReal; try { soulsReal = realpathSync(join(dirname(soulDir), SOULS_DIR)); } catch { return null; }
|
|
117
|
+
const rel = relative(soulsReal, real);
|
|
118
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
|
|
119
|
+
return real;
|
|
120
|
+
}
|
|
121
|
+
/** Atomically point `<agentDir>/soul` at `target`: symlink to a temp name, rename over.
|
|
122
|
+
* The previous target directory is untouched (an instance may link it). */
|
|
123
|
+
function swapSoulPointer(soulDir, target) {
|
|
124
|
+
const tmp = `${soulDir}.pointer-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
125
|
+
try {
|
|
126
|
+
symlinkSync(target, tmp);
|
|
127
|
+
renameSync(tmp, soulDir); // rename over an existing symlink replaces it; over a directory it fails (caller migrates first)
|
|
128
|
+
} catch (x) { try { rmSync(tmp, { force: true }); } catch { /* nothing */ } throw x; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into the
|
|
132
|
+
* per-commit cache `<agentsRoot>/<name>/souls/<commit12>/` and point the classic
|
|
133
|
+
* `<agentsRoot>/<name>/soul` at it (a SYMLINK, swapped atomically), so the classic
|
|
134
|
+
* spawn skeleton, findAgent, doctor … keep reading "current" from the usual place
|
|
135
|
+
* while every spawned home links its OWN commit's directory (decision 7: an
|
|
136
|
+
* instance never changes under itself — a preview or a later spawn may fetch a new
|
|
137
|
+
* commit and move the pointer, but no directory an instance links is ever touched).
|
|
138
|
+
*
|
|
139
|
+
* Idempotent per (repo, commit): an existing complete entry is reused (never
|
|
140
|
+
* refetched, never rewritten); an entry that lost its soul.yaml is moved aside and
|
|
141
|
+
* refetched. `.oats-soul-source.json` stamps what the pointer currently shows.
|
|
142
|
+
* Migration: a 0.25.0 `soul/` that is a REAL directory is moved to
|
|
143
|
+
* `souls/<commit from the stamp | unknown>/` before the pointer replaces it.
|
|
144
|
+
* Returns the PER-COMMIT directory (what a home should link). */
|
|
106
145
|
export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
107
146
|
const e = prepared.soulEntry;
|
|
108
147
|
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
|
-
|
|
148
|
+
const soulsDir = join(agentDir, SOULS_DIR);
|
|
149
|
+
const stamp = join(agentDir, SOUL_SOURCE_STAMP);
|
|
150
|
+
const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
|
|
151
|
+
const commitDir = join(soulsDir, commit12(e.commit));
|
|
152
|
+
mkdirSync(soulsDir, { recursive: true });
|
|
153
|
+
|
|
154
|
+
// --- migration: a 0.25.0 real `soul/` directory becomes souls/<commit|unknown>/ ---
|
|
155
|
+
let st; try { st = lstatSync(soulDir); } catch { st = null; }
|
|
156
|
+
if (st && !st.isSymbolicLink() && st.isDirectory()) {
|
|
157
|
+
const cur = readStamp();
|
|
158
|
+
const legacyCommit = cur && cur.repoKey === e.repoKey && typeof cur.commit === "string" && cur.commit ? commit12(cur.commit) : "unknown";
|
|
159
|
+
let dest = join(soulsDir, legacyCommit);
|
|
160
|
+
if (existsSync(dest)) dest = join(soulsDir, `${legacyCommit}.migrated-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
161
|
+
renameSync(soulDir, dest);
|
|
162
|
+
swapSoulPointer(soulDir, dest);
|
|
163
|
+
} else if (st && !st.isSymbolicLink()) {
|
|
164
|
+
// a regular file where the pointer belongs: not ours to keep
|
|
165
|
+
rmSync(soulDir, { force: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- the per-commit entry: reuse when complete, else fetch (staging → rename in) ---
|
|
169
|
+
let complete = existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"));
|
|
170
|
+
if (existsSync(commitDir) && !complete) {
|
|
171
|
+
// A damaged entry (someone removed soul.yaml): move it aside — an instance may
|
|
172
|
+
// still link it, so it is never deleted — and fetch a fresh copy under the canonical name.
|
|
173
|
+
renameSync(commitDir, `${commitDir}.damaged-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
174
|
+
}
|
|
175
|
+
if (!complete) {
|
|
176
|
+
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
177
|
+
const staging = join(soulsDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
178
|
+
try {
|
|
179
|
+
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
180
|
+
await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
|
|
181
|
+
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 });
|
|
182
|
+
if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
|
|
183
|
+
try { renameSync(staging, commitDir); }
|
|
184
|
+
catch (x) {
|
|
185
|
+
// Lost a race with a concurrent fetch of the same commit: theirs is the same bytes.
|
|
186
|
+
if (!(existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md")))) throw x;
|
|
187
|
+
rmSync(staging, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
} catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- the "current" pointer + its stamp ---
|
|
193
|
+
const target = realpathSync(commitDir);
|
|
194
|
+
if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
|
|
195
|
+
const cur = readStamp();
|
|
196
|
+
if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
|
|
121
197
|
writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
122
|
-
|
|
123
|
-
|
|
198
|
+
}
|
|
199
|
+
return target;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Member clones — where a workspace soul's `work: worktree|checkout` target lives
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** The taught member name of a repo key: the last path segment without `.git`
|
|
207
|
+
* (`github.com/northwind/platform` → `platform`). The same rule `oats onboard`
|
|
208
|
+
* and `oats sync` print (memberLabel). */
|
|
209
|
+
export function memberNameOf(key) {
|
|
210
|
+
return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The convention path of a member's clone: `<deployment>/<member name>` — except a
|
|
214
|
+
* member called `agents`, which is cloned as `agents-repo/` because `<deployment>/agents/`
|
|
215
|
+
* is the instance root (design doc §4; matches onboard's cloneDirOf). */
|
|
216
|
+
export function conventionCloneDir(deployment, key) {
|
|
217
|
+
const name = memberNameOf(key);
|
|
218
|
+
return join(deployment, name === "agents" ? "agents-repo" : name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Normalise a key an operator may have WRITTEN in `clones:` — the canonical key
|
|
222
|
+
* (`github.com/org/repo`), or any ref form parseRepoRef understands (`git:…`,
|
|
223
|
+
* `https://…`, `git@host:…`, `/abs/bare.git`) — to the canonical key. A key that
|
|
224
|
+
* parses no way is returned as written (it can then only match literally). */
|
|
225
|
+
function canonicalCloneKey(written) {
|
|
226
|
+
const s = String(written).trim();
|
|
227
|
+
if (s.startsWith("local/")) return s; // a local key is already canonical
|
|
228
|
+
for (const candidate of [s, `git:${s}`]) {
|
|
229
|
+
try { return parseRepoRef(candidate).key; } catch { /* next form */ }
|
|
230
|
+
}
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The remote urls a clone carries (any remote, not only origin), parsed to their
|
|
235
|
+
* repo keys. Not a git repo → null. */
|
|
236
|
+
function cloneRemoteKeys(path) {
|
|
237
|
+
const git = (argv) => spawnSync("git", ["-C", path, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
238
|
+
const inside = git(["rev-parse", "--git-dir"]);
|
|
239
|
+
if (inside.status !== 0) return null;
|
|
240
|
+
const cfg = git(["config", "--get-regexp", "^remote\\..*\\.url$"]);
|
|
241
|
+
const keys = [];
|
|
242
|
+
if (cfg.status === 0) {
|
|
243
|
+
for (const line of cfg.stdout.split("\n")) {
|
|
244
|
+
const url = line.replace(/^\S+\s+/, "").trim();
|
|
245
|
+
if (!url) continue;
|
|
246
|
+
try { keys.push(parseRepoRef(url).key); } catch { keys.push(`?/${url}`); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return keys;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Two repo keys name the same repository. Hosted keys compare literally; `local/<abs>`
|
|
253
|
+
* keys compare by realpath when the path exists (macOS tmpdir → /private/var…). */
|
|
254
|
+
function sameRepoKey(a, b) {
|
|
255
|
+
if (a === b) return true;
|
|
256
|
+
if (!a.startsWith("local/") || !b.startsWith("local/")) return false;
|
|
257
|
+
const real = (k) => { try { return realpathSync(k.slice("local/".length)); } catch { return k.slice("local/".length); } };
|
|
258
|
+
return real(a) === real(b);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Verify `path` is the clone of `key`: it exists, is a directory, is a Git repo, and
|
|
262
|
+
* one of its remotes parses to `key`. Anything else → E_CLONE_MISMATCH { path, expected,
|
|
263
|
+
* found } — the operator pointed the kernel at the wrong place; it never works there. */
|
|
264
|
+
export function verifyMemberClone(path, key, { via } = {}) {
|
|
265
|
+
const mismatch = (found, why) => err("E_CLONE_MISMATCH", `${via ? `${via}: ` : ""}${path} is not a clone of ${key}${why ? ` (${why})` : ""}${found && found.length ? ` — its remotes point at ${found.join(", ")}` : ""}`, { path, expected: key, found, via: via ?? null });
|
|
266
|
+
let st; try { st = statSync(path); } catch { throw mismatch(null, "the path does not exist"); }
|
|
267
|
+
if (!st.isDirectory()) throw mismatch(null, "not a directory");
|
|
268
|
+
const found = cloneRemoteKeys(path);
|
|
269
|
+
if (found === null) throw mismatch(null, "not a Git repository");
|
|
270
|
+
if (!found.some((k) => sameRepoKey(k, key))) throw mismatch(found, found.length ? "no remote names it" : "it has no remotes");
|
|
271
|
+
return path;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Where the work target of a PREPARED spawn lives — the member clone of the soul's
|
|
276
|
+
* repo on this machine (design doc §4; decision 9: the work target is the only thing
|
|
277
|
+
* that needs a clone). In order:
|
|
278
|
+
* 1. `explicit` (`--repo`) — the operator's word; relative to the deployment; not
|
|
279
|
+
* verified against the member (an explicit --repo may deliberately point elsewhere);
|
|
280
|
+
* 2. `oats-local.yaml` `clones: { <repo key>: <path> }` — the key as written is
|
|
281
|
+
* normalised through parseRepoRef so `git:…`, `https://…`, `git@…` spellings match;
|
|
282
|
+
* 3. `<deployment>/<member name>` (`agents` → `agents-repo`);
|
|
283
|
+
* 4. null — nothing on this machine.
|
|
284
|
+
* A path found by (2) or (3) is VERIFIED: a directory, a Git repo, one remote parsing to
|
|
285
|
+
* the member's key → else E_CLONE_MISMATCH. A `clones:` entry whose path is absent is a
|
|
286
|
+
* mismatch too (the operator named it; "missing" would hide the typo).
|
|
287
|
+
* Returns an absolute path or null.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveMemberClone(prepared, { explicit } = {}) {
|
|
290
|
+
const deployment = resolvePath(prepared?.deployment ?? process.cwd());
|
|
291
|
+
if (typeof explicit === "string" && explicit.trim()) return isAbsolute(explicit) ? resolvePath(explicit) : resolvePath(deployment, explicit);
|
|
292
|
+
const key = prepared?.soulEntry?.repoKey;
|
|
293
|
+
if (typeof key !== "string" || !key) return null;
|
|
294
|
+
const clones = prepared?.local?.clones;
|
|
295
|
+
if (clones && typeof clones === "object") {
|
|
296
|
+
for (const [written, value] of Object.entries(clones)) {
|
|
297
|
+
if (!sameRepoKey(canonicalCloneKey(written), key)) continue;
|
|
298
|
+
if (typeof value !== "string" || !value.trim()) throw err("E_CLONE_MISMATCH", `oats-local.yaml clones: ${written} must be a path`, { path: value, expected: key, found: null, via: "oats-local.yaml clones:" });
|
|
299
|
+
const path = isAbsolute(value) ? resolvePath(value) : resolvePath(deployment, value);
|
|
300
|
+
return verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const convention = conventionCloneDir(deployment, key);
|
|
304
|
+
if (!existsSync(convention)) return null;
|
|
305
|
+
return verifyMemberClone(convention, key, { via: "convention path" });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The clone url of the soul's member as the workspace names it (for remedies). */
|
|
309
|
+
function memberUrlOf(prepared, key) {
|
|
310
|
+
for (const ref of prepared?.discovery?.workspace?.members || []) {
|
|
311
|
+
try { const p = parseRepoRef(ref); if (sameRepoKey(p.key, key)) return p.url; } catch { /* validated already */ }
|
|
312
|
+
}
|
|
313
|
+
if (prepared?.discovery?.standalone === true && prepared.discovery.key === key) return prepared.discovery.url ?? null;
|
|
314
|
+
return key.startsWith("local/") ? key.slice("local/".length) : `https://${key}.git`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** resolveMemberClone, or E_CLONE_MISSING naming BOTH ways to provide the clone
|
|
318
|
+
* (the convention path and a `clones:` entry) plus `--repo` for a one-off. */
|
|
319
|
+
export function requireMemberClone(prepared, { explicit } = {}) {
|
|
320
|
+
const found = resolveMemberClone(prepared, { explicit });
|
|
321
|
+
if (found) return found;
|
|
322
|
+
const key = prepared?.soulEntry?.repoKey ?? "<repo>";
|
|
323
|
+
const deployment = resolvePath(prepared?.deployment ?? process.cwd());
|
|
324
|
+
const convention = conventionCloneDir(deployment, key);
|
|
325
|
+
const url = memberUrlOf(prepared, key);
|
|
326
|
+
const soul = prepared?.soulEntry?.name ?? "<soul>";
|
|
327
|
+
const work = prepared?.soulEntry?.definition?.work ?? "worktree";
|
|
328
|
+
throw err("E_CLONE_MISSING", `soul ${soul} works in a ${work} of ${key}, and this machine has no clone of it — either \`git clone ${url} ${convention}\` (the convention: <deployment>/<member name>) or point oats-local.yaml at an existing clone: \`clones: { ${key}: <abs path> }\`; for a one-off pass --repo <path>`, { soul, repoKey: key, work, deployment, convention, url, remedies: { clone: `git clone ${url} ${convention}`, local: { clones: { [key]: "<abs path>" } }, flag: "--repo <path>" } });
|
|
124
329
|
}
|
|
125
330
|
|
|
126
331
|
/** The async half of a spawn: everything that touches the network. Returns a
|
|
@@ -133,7 +338,7 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
133
338
|
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
|
|
134
339
|
const soulEntry = findSoulEntry(discovery, soulName);
|
|
135
340
|
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
136
|
-
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
|
|
341
|
+
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
|
|
137
342
|
}
|
|
138
343
|
|
|
139
344
|
/** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
|
package/lib/materialize.mjs
CHANGED
|
@@ -571,6 +571,35 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
|
|
|
571
571
|
return rows;
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
/**
|
|
575
|
+
* soulDriftOf(instanceJson, discovery)
|
|
576
|
+
* → { name, repoKey, commit, current: { commit } | null, status: "current"|"moved"|"missing", reason? } | null
|
|
577
|
+
*
|
|
578
|
+
* Decision 17 for the SOUL SOURCE: a workspace spawn records `instance.json.workspace.soul =
|
|
579
|
+
* { repoKey, commit, team }` (the member and commit the soul was fetched from). The member row
|
|
580
|
+
* for `repoKey` in `discovery.members` is the current state: a different commit → "moved", the
|
|
581
|
+
* soul gone from the member → "missing" (reason "soul-absent"), no usable row → "missing"
|
|
582
|
+
* (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
|
|
583
|
+
* by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
|
|
584
|
+
* not reported missing. Returns null for a home without a recorded workspace soul (classic).
|
|
585
|
+
*/
|
|
586
|
+
export function soulDriftOf(instanceJson, discovery) {
|
|
587
|
+
const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
|
|
588
|
+
if (!soul || typeof soul.repoKey !== "string") return null;
|
|
589
|
+
const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
|
|
590
|
+
const commit = typeof soul.commit === "string" ? soul.commit : null;
|
|
591
|
+
const members = Array.isArray(discovery?.members) ? discovery.members : [];
|
|
592
|
+
const member = members.find((m) => m && m.key === soul.repoKey) || null;
|
|
593
|
+
const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
|
|
594
|
+
const base = { name, repoKey: soul.repoKey, commit, team: soul.team ?? null };
|
|
595
|
+
if (!member || (!member.confirmed && !standaloneOwn) || typeof member.commit !== "string") {
|
|
596
|
+
return { ...base, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" };
|
|
597
|
+
}
|
|
598
|
+
const present = name === null || (Array.isArray(member.souls) && member.souls.some((s) => s && s.name === name));
|
|
599
|
+
if (!present) return { ...base, current: { commit: member.commit }, status: "missing", reason: "soul-absent" };
|
|
600
|
+
return { ...base, current: { commit: member.commit }, status: member.commit === commit ? "current" : "moved" };
|
|
601
|
+
}
|
|
602
|
+
|
|
574
603
|
/** Read the modules recorded in an instance home (→ {} when absent). */
|
|
575
604
|
export function recordedModules(home) {
|
|
576
605
|
const file = join(resolve(home), "instance.json");
|
|
@@ -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`,
|