@awebai/oats 0.33.0 → 0.34.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 +100 -7
- package/docs/capabilities.md +17 -0
- package/docs/desktop-cli-api.md +184 -4
- package/docs/implementation.md +14 -0
- package/docs/release-notes/v0.34.0.md +63 -0
- package/lib/capability-show.mjs +208 -0
- package/lib/packages.mjs +1 -1
- package/lib/remote.mjs +146 -29
- package/lib/resolve.mjs +56 -14
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* oats package add|remove ... edit `packages:` in the workspace file
|
|
11
11
|
* oats workspace status membership table, packages
|
|
12
12
|
* oats capabilities | oats souls every visible item of the workspace
|
|
13
|
+
* oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>] [--json]
|
|
14
|
+
* what one capability ships (inject text, skill files)
|
|
13
15
|
*
|
|
14
16
|
* Workspace model v2 (docs/design/2026-09-23-workspace-module-contracts.md §6):
|
|
15
17
|
* nothing is installed. `oats-local.yaml` names the workspace, `oats sync`
|
|
@@ -1027,9 +1029,13 @@ async function readinessCmd() {
|
|
|
1027
1029
|
let readSession = null;
|
|
1028
1030
|
/** The validated `--max-age` seconds (checked once at dispatch: maxAgeRefusal), null when not given. */
|
|
1029
1031
|
let maxAgeGiven = null;
|
|
1032
|
+
/** The remote budget of this command's reads (ms), or null: only the deployment reads `status` and `workspace
|
|
1033
|
+
* status` have one (remote.mjs READ_REMOTE_BUDGET_MS; OATS_READ_REMOTE_BUDGET_MS overrides it), so they answer
|
|
1034
|
+
* inside a caller's own limit. Spawn, sync and every other verb read with no deadline. */
|
|
1035
|
+
let readBudgetMs = null;
|
|
1030
1036
|
function commandSession() {
|
|
1031
1037
|
if (!readSession) {
|
|
1032
|
-
readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0 });
|
|
1038
|
+
readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0, ...(readBudgetMs !== null ? { deadline: Date.now() + readBudgetMs } : {}) });
|
|
1033
1039
|
process.on("exit", () => { sayReadNotices(); readSession.closeNow(); });
|
|
1034
1040
|
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) process.once(signal, () => {
|
|
1035
1041
|
readSession.closeNow();
|
|
@@ -1046,7 +1052,7 @@ function sayReadNotices() {
|
|
|
1046
1052
|
}
|
|
1047
1053
|
/** Which kernel command forms take --max-age: THE allow-list (docs/desktop-cli-api.md "Observation reuse").
|
|
1048
1054
|
* → null when this form reads with observation reuse, else the E_BAD_ARGS message. `head` is argv before `--`. */
|
|
1049
|
-
const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
|
|
1055
|
+
const MAX_AGE_READS = "status, workspace status, souls, capabilities, capabilities show, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
|
|
1050
1056
|
function maxAgeRefusal(command, head) {
|
|
1051
1057
|
const word = (i) => (head[i] !== undefined && !head[i].startsWith("--") ? head[i] : undefined);
|
|
1052
1058
|
const refuse = (form) => `--max-age is not accepted by \`oats ${form}\`: only the read verbs reuse observations (${MAX_AGE_READS})`;
|
|
@@ -1645,14 +1651,25 @@ async function soulCmd() {
|
|
|
1645
1651
|
if (doc.teams.length) printTable(["team", "id", "from", "why"], doc.teams.map((t) => [t.default ? `${t.label} (default)` : t.label, t.team ?? "(no id yet)", t.from, t.via.join(",")]));
|
|
1646
1652
|
}
|
|
1647
1653
|
|
|
1648
|
-
/** `oats capabilities` / `oats souls`
|
|
1649
|
-
|
|
1650
|
-
|
|
1654
|
+
/** The catalog's rows of `kind` ("capabilities" | "souls") as `oats capabilities` / `oats souls` list them:
|
|
1655
|
+
* one discovery (honouring --max-age), the lock, workspaceItems. → { ctx, discovery, lock, items }. */
|
|
1656
|
+
async function catalogRows(kind, bail) {
|
|
1651
1657
|
const ctx = workspaceContext(bail);
|
|
1652
1658
|
const discovery = await discoverForCli(ctx, bail);
|
|
1653
1659
|
let lock;
|
|
1654
1660
|
try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
1655
|
-
|
|
1661
|
+
return { ctx, discovery, lock, items: workspaceItems(discovery, lock, ctx.local, ctx.deploymentDir)[kind] };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
|
|
1665
|
+
async function itemsCmd(kind) {
|
|
1666
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
1667
|
+
if (kind === "capabilities") {
|
|
1668
|
+
const { words } = capabilitiesArgv();
|
|
1669
|
+
if (words[0] === "show") return capabilityShowCmd(bail);
|
|
1670
|
+
if (words.length) return bail("E_BAD_ARGS", `unknown capabilities subcommand ${JSON.stringify(words[0])}: \`oats capabilities\` lists the catalog, \`oats capabilities show <name>\` reads one capability`, { subcommand: words[0] });
|
|
1671
|
+
}
|
|
1672
|
+
const { ctx, discovery, lock, items } = await catalogRows(kind, bail);
|
|
1656
1673
|
// One command's reads at a commit are shared: many souls resolve over the same manifests and listings.
|
|
1657
1674
|
const remote = memoizedRemote(remoteModule);
|
|
1658
1675
|
if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
|
|
@@ -1667,6 +1684,74 @@ async function itemsCmd(kind) {
|
|
|
1667
1684
|
if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
|
|
1668
1685
|
}
|
|
1669
1686
|
|
|
1687
|
+
/** `oats capabilities` argv after the command: its words (the subcommand first) with every flag and a value
|
|
1688
|
+
* flag's value set aside, and the first flag neither form takes (`unknown`). */
|
|
1689
|
+
function capabilitiesArgv() {
|
|
1690
|
+
const VALUE_FLAGS = new Set(["--member", "--package", "--file", "--dir", "--max-age", "--server"]);
|
|
1691
|
+
const words = [];
|
|
1692
|
+
let unknown;
|
|
1693
|
+
for (let i = 1; i < args.length; i++) {
|
|
1694
|
+
if (VALUE_FLAGS.has(args[i])) { if (args[i + 1] !== undefined && !args[i + 1].startsWith("--")) i++; continue; }
|
|
1695
|
+
if (args[i].startsWith("--")) { if (args[i] !== "--json") unknown ??= args[i]; continue; }
|
|
1696
|
+
words.push(args[i]);
|
|
1697
|
+
}
|
|
1698
|
+
return { words, unknown };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/** `oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>] [--dir] [--json]`
|
|
1702
|
+
* (feature capability-show, capabilityShowApi 1; lib/capability-show.mjs): what one catalog row ships, read
|
|
1703
|
+
* at that row's commit — the rows are `oats capabilities`'s own (catalogRows). */
|
|
1704
|
+
async function capabilityShowCmd(bail) {
|
|
1705
|
+
const S = await import("../lib/capability-show.mjs");
|
|
1706
|
+
if (flag("server") !== undefined) return bail("E_BAD_ARGS", "oats capabilities show reads this machine's workspace only: --server is not accepted");
|
|
1707
|
+
const { words, unknown } = capabilitiesArgv();
|
|
1708
|
+
if (unknown) return bail("E_BAD_ARGS", `oats capabilities show: unknown flag ${unknown}`, { flag: unknown });
|
|
1709
|
+
const positionals = words.slice(1);
|
|
1710
|
+
if (positionals.length !== 1) return bail("E_BAD_ARGS", positionals.length ? `oats capabilities show takes one capability name, got ${positionals.map((p) => JSON.stringify(p)).join(" ")}` : "oats capabilities show needs a capability name (`oats capabilities` lists them)");
|
|
1711
|
+
const name = positionals[0];
|
|
1712
|
+
const memberArg = valueFlag("member"), packageArg = valueFlag("package"), file = valueFlag("file");
|
|
1713
|
+
if (memberArg !== undefined && packageArg !== undefined) return bail("E_BAD_ARGS", "choose --member <repoKey> or --package <id>, not both");
|
|
1714
|
+
if (file !== undefined && S.unsafeFilePath(file)) return bail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(file)} is not a relative path inside the capability`, { path: file });
|
|
1715
|
+
const { ctx, discovery, lock, items } = await catalogRows("capabilities", bail);
|
|
1716
|
+
// --member takes the repo key a row shows, or any ref spelling of it (compared on the canonical key).
|
|
1717
|
+
let member = memberArg ?? null;
|
|
1718
|
+
if (member !== null && !items.some((r) => r.repoKey === member)) { try { member = remoteModule.parseRepoRef(member).key; } catch { /* matches no row */ } }
|
|
1719
|
+
const remote = memoizedRemote(remoteModule);
|
|
1720
|
+
const catalog = (() => { try { return officialPackageCatalog(); } catch { return null; } })();
|
|
1721
|
+
let doc;
|
|
1722
|
+
try {
|
|
1723
|
+
const row = S.selectCapabilityRow(items, name, { member, package: packageArg ?? null });
|
|
1724
|
+
const source = await S.capabilitySource(row, { discovery, lock, catalog, remote, remoteOptions: ctx.remoteOptions });
|
|
1725
|
+
doc = file === undefined ? await S.capabilityShow(source, { remote, remoteOptions: ctx.remoteOptions }) : await S.capabilityFile(source, file, { remote, remoteOptions: ctx.remoteOptions });
|
|
1726
|
+
} catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
|
|
1727
|
+
if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
|
|
1728
|
+
if (file !== undefined) {
|
|
1729
|
+
const f = doc.file;
|
|
1730
|
+
if (f.binary) console.log(`(${f.path}: binary, ${formatBytes(f.bytes)} — not shown)`);
|
|
1731
|
+
else {
|
|
1732
|
+
process.stdout.write(f.text.endsWith("\n") || f.text === "" ? f.text : `${f.text}\n`);
|
|
1733
|
+
if (f.truncated) console.log(`(${f.path}: truncated — ${formatBytes(f.bytes)}, the first ${formatBytes(S.TEXT_LIMIT)} shown)`);
|
|
1734
|
+
}
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
console.log(`${doc.name} — ${doc.kind === "member" ? `member ${doc.repoKey}` : `package ${doc.package} v${doc.version} (${doc.repoKey})`} @ ${short(doc.commit)}, ${doc.path}\n`);
|
|
1738
|
+
const size = (bytes) => (bytes === null ? "unreadable" : formatBytes(bytes));
|
|
1739
|
+
console.log(` inject: ${doc.inject ? `${doc.inject.path ?? "(unsafe path)"} (${size(doc.inject.bytes)}${doc.inject.binary ? ", binary" : ""}${doc.inject.truncated ? ", truncated" : ""})` : "(none)"}`);
|
|
1740
|
+
if (doc.skills === null) console.log(" skills: (cannot be listed — see problems)");
|
|
1741
|
+
else if (!doc.skills.length) console.log(" skills: (none)");
|
|
1742
|
+
else {
|
|
1743
|
+
console.log(" skills:");
|
|
1744
|
+
for (const skill of doc.skills) {
|
|
1745
|
+
console.log(` ${skill.name} ${skill.path}`);
|
|
1746
|
+
if (skill.files === null) console.log(" (files cannot be listed — see problems)");
|
|
1747
|
+
for (const f of skill.files ?? []) console.log(` ${f.path} ${size(f.bytes)}`);
|
|
1748
|
+
if (skill.filesTruncated) console.log(` … more files (the first ${S.FILES_PER_SKILL} are listed)`);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
for (const p of doc.problems) console.log(` problem: ${p.code}${p.path ? ` ${p.path}` : ""} — ${p.message}`);
|
|
1752
|
+
console.log(`\n a file's text: oats capabilities show ${doc.name}${doc.kind === "package" ? ` --package ${doc.package}` : ""} --file <path>`);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1670
1755
|
/** Capability rows' manifest facts (feature desktop-facts): layer, description, and what each provides
|
|
1671
1756
|
* (skills, commands, hooks by name) — member rows from discovery's manifest, package rows from the manifest
|
|
1672
1757
|
* at the locked commit (the sync cache; no network beyond sync's). An unreadable package leaves its rows' facts null. */
|
|
@@ -2946,7 +3031,7 @@ function versionCmd() {
|
|
|
2946
3031
|
// Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
|
|
2947
3032
|
// runs on resolve/materialize (contract §6); a feature the binary does not implement is
|
|
2948
3033
|
// never listed.
|
|
2949
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
|
|
3034
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
|
|
2950
3035
|
return;
|
|
2951
3036
|
}
|
|
2952
3037
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -3349,6 +3434,10 @@ try {
|
|
|
3349
3434
|
maxAgeGiven = Number(raw);
|
|
3350
3435
|
activateLocalInputs(); // observation.localRevision: every local config read from here on is recorded
|
|
3351
3436
|
}
|
|
3437
|
+
if (kernelArgv && !head.includes("--server") && (cmd === "status" || (cmd === "workspace" && head[1] === "status"))) {
|
|
3438
|
+
const override = Number(process.env.OATS_READ_REMOTE_BUDGET_MS);
|
|
3439
|
+
readBudgetMs = Number.isSafeInteger(override) && override > 0 ? override : remoteModule.READ_REMOTE_BUDGET_MS;
|
|
3440
|
+
}
|
|
3352
3441
|
const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
|
|
3353
3442
|
if (inherited.length && cmd !== "version") refuse(`this environment carries a captured context (${inherited.join(", ")}): the captured/portable path was removed in 0.26, and nothing is run against the current context in its place — retire the captured home and re-spawn it from the deployment`, { inherited });
|
|
3354
3443
|
if (cmd === "inspect" && head.includes("--request")) {
|
|
@@ -3606,6 +3695,10 @@ Usage:
|
|
|
3606
3695
|
oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
|
|
3607
3696
|
[--max-age <s>] private one is listed as repo-owned: usable only by
|
|
3608
3697
|
its own repo's souls) + the locked packages
|
|
3698
|
+
oats capabilities show <name> [--member <repoKey> | --package <id>] [--file <path>]
|
|
3699
|
+
[--dir <d>] [--json] [--max-age <s>] what one capability of that list ships, at its commit:
|
|
3700
|
+
its inject (text) and each skill's files (sizes);
|
|
3701
|
+
--file <path>: one listed file's text
|
|
3609
3702
|
oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
|
|
3610
3703
|
[--max-age <s>] (souls have no private mode), with origin
|
|
3611
3704
|
(member <key> @ <commit> | package <id> v<ver>) and its
|
package/docs/capabilities.md
CHANGED
|
@@ -246,6 +246,23 @@ discovery; the details are in [souls-and-instances.md](souls-and-instances.md).
|
|
|
246
246
|
Change a capability's inject or skills in its repository, then spawn a new
|
|
247
247
|
instance: the generated files are not a source.
|
|
248
248
|
|
|
249
|
+
See what a capability ships before any instance has it:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
oats capabilities show oats.okf # inject path and size, each skill's files, problems
|
|
253
|
+
oats capabilities show oats.okf --file injects/okf.md # one listed file's text
|
|
254
|
+
oats capabilities show nw-house-style --member github.com/nw/agents --json
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`oats capabilities show <name>` reads one row of `oats capabilities` at that
|
|
258
|
+
row's commit (a package at its locked commit, after spawn's lock check): the
|
|
259
|
+
inject text exactly as committed, and each skill, enumerated as a spawn
|
|
260
|
+
enumerates it, with its description and files. `--file <path>` prints one
|
|
261
|
+
file the show lists (the inject or a skill file), and no other file of the
|
|
262
|
+
capability. Use `--member <repoKey>` or `--package <id>` when two rows share
|
|
263
|
+
the name. The JSON contract is in
|
|
264
|
+
[desktop-cli-api.md](desktop-cli-api.md#oats-capabilities-show).
|
|
265
|
+
|
|
249
266
|
Inspect a composition before it exists:
|
|
250
267
|
|
|
251
268
|
```bash
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -38,9 +38,10 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
|
|
|
38
38
|
"instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
|
|
39
39
|
"workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
|
|
40
40
|
"team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
|
|
41
|
-
"preview-composed-from","observe-max-age","spawn-preview-max-age"],
|
|
41
|
+
"preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show"],
|
|
42
42
|
"automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
|
|
43
|
-
"readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2
|
|
43
|
+
"readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2,
|
|
44
|
+
"capabilityShowApi":1}
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
- The Desktop accepts `desktopApi === 1` and a released `version` inside
|
|
@@ -97,6 +98,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
|
|
|
97
98
|
| `preview-composed-from` | `composedFrom` on preview `modules[]` ([Composition](#the-preview)) | |
|
|
98
99
|
| `observe-max-age` | `--max-age <s>` on the read verbs and their `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311)) | |
|
|
99
100
|
| `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
|
|
101
|
+
| `capability-show` | `oats capabilities show <name>` and its `--file` form, OATS 0.34.0 ([`oats capabilities show`](#oats-capabilities-show)) | `capabilityShowApi: 1` |
|
|
100
102
|
|
|
101
103
|
Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
|
|
102
104
|
`workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
|
|
@@ -196,6 +198,7 @@ it and answer live, without the block.
|
|
|
196
198
|
oats status | workspace status | souls | capabilities | inspect --soul|--home
|
|
197
199
|
| teams | soul teams <soul> … --max-age <seconds> --json
|
|
198
200
|
oats spawn <soul> … --preview --max-age <seconds> --json (feature spawn-preview-max-age)
|
|
201
|
+
oats capabilities show <name> … --max-age <seconds> --json (feature capability-show)
|
|
199
202
|
```
|
|
200
203
|
|
|
201
204
|
- **Values:** whole seconds, `0` to `86400`. `0` is live: it reuses nothing.
|
|
@@ -253,8 +256,8 @@ oats spawn <soul> … --preview --max-age <seconds> --json (feature spawn-pre
|
|
|
253
256
|
invocation refuse the flag before reading or writing anything, with
|
|
254
257
|
`E_BAD_ARGS` "--max-age is not accepted by \`oats <form>\`: only the read
|
|
255
258
|
verbs reuse observations (status, workspace status, souls, capabilities,
|
|
256
|
-
inspect --soul|--home, spawn --preview, and the read
|
|
257
|
-
teams)" and,
|
|
259
|
+
capabilities show, inspect --soul|--home, spawn --preview, and the read
|
|
260
|
+
forms of teams and soul teams)" and,
|
|
258
261
|
with `--server`, "--max-age cannot be combined with --server: observation
|
|
259
262
|
reuse is local to this machine".
|
|
260
263
|
A capability command's argv (`oats <namespace> …`) is its provider's: the
|
|
@@ -734,6 +737,14 @@ Read-only (it writes no lock):
|
|
|
734
737
|
```
|
|
735
738
|
|
|
736
739
|
- `members[]` and `packages[]` are the sync rows (packages from the lock).
|
|
740
|
+
- **Remote budget (0.33.1).** `oats workspace status` and `oats status` finish
|
|
741
|
+
their remote work within 12 s of their first remote read, whatever the
|
|
742
|
+
machine's load or another process holding a remote's cache. A member not
|
|
743
|
+
read by then is a `cannot-read` row whose `detail` ends `(timeout)`, and its
|
|
744
|
+
git is ended; the command still answers. The workspace definition itself
|
|
745
|
+
(the host) not read by then fails the command as any unreadable host does
|
|
746
|
+
(`E_REMOTE_UNREADABLE`, `reason: "timeout"`). Spawn, sync and every other
|
|
747
|
+
verb have no such budget.
|
|
737
748
|
- `declaredPackages`: the ids in `packages:` (standalone: the kernel's
|
|
738
749
|
default). `unsynced`: declared, not locked. `stale`: locked, no longer
|
|
739
750
|
declared. `external[]`: `{source, soul}`.
|
|
@@ -810,6 +821,175 @@ problem`, and (feature `launch-preference`) `key` and `launch`.
|
|
|
810
821
|
This document keeps `soulsApi: 1`; the probe's `soulsApi: 2` is the inspect
|
|
811
822
|
soul row's.
|
|
812
823
|
|
|
824
|
+
### `oats capabilities show`
|
|
825
|
+
|
|
826
|
+
Feature `capability-show`, `capabilityShowApi: 1`, OATS 0.34.0.
|
|
827
|
+
|
|
828
|
+
```text
|
|
829
|
+
oats capabilities show <name> [--member <repoKey> | --package <id>] [--dir <d>] [--max-age <s>] --json
|
|
830
|
+
oats capabilities show <name> [--member <repoKey> | --package <id>] --file <path> [--dir <d>] [--max-age <s>] --json
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
What one capability ships: its inject text and each skill's files, and one
|
|
834
|
+
file's text on request. The Desktop's capability page shows them without
|
|
835
|
+
reading clones or caches itself.
|
|
836
|
+
|
|
837
|
+
> **Every `text` and `description` is untrusted repository content.** Render
|
|
838
|
+
> it as plain text (`textContent`), or through a sanitising Markdown renderer
|
|
839
|
+
> that allows no raw HTML, no scripts and no remote images.
|
|
840
|
+
|
|
841
|
+
**Selection.** The rows are exactly the rows of `oats capabilities --json`
|
|
842
|
+
(one discovery, honouring `--max-age`), so the answer's `commit` equals that
|
|
843
|
+
row's `commit`.
|
|
844
|
+
|
|
845
|
+
- `<name>` alone selects the one row with that name.
|
|
846
|
+
- `--member <repoKey>` selects a member row of that repository: the key as a
|
|
847
|
+
row shows it, or any ref spelling of the same repository.
|
|
848
|
+
- `--package <id>` selects that package's row.
|
|
849
|
+
- No row: `E_CAPABILITY_UNKNOWN`, `details: {name}` plus `member` or
|
|
850
|
+
`package` when one was given. An unsynced package is not in the catalog, so
|
|
851
|
+
its capabilities are `E_CAPABILITY_UNKNOWN` until `oats sync`.
|
|
852
|
+
- More than one row: `E_CAPABILITY_AMBIGUOUS`, `details: {name, candidates}`,
|
|
853
|
+
each candidate `{kind, repoKey, origin}` (member) or `{kind, package,
|
|
854
|
+
origin}` (package). Choose one with `--member` or `--package`.
|
|
855
|
+
- `E_BAD_ARGS` for `--member` with `--package`, a missing or second name, an
|
|
856
|
+
unknown flag, and `--server` (the verb reads this machine's workspace only).
|
|
857
|
+
An unknown word after `oats capabilities` (`oats capabilities foo`) is
|
|
858
|
+
`E_BAD_ARGS` too.
|
|
859
|
+
|
|
860
|
+
**Trust path.** Every read goes through the remote cache at a full commit id;
|
|
861
|
+
nothing reads a working clone.
|
|
862
|
+
|
|
863
|
+
- A member capability is read from its member repository at the row's commit.
|
|
864
|
+
- A package capability is read at the locked commit, the same trust path
|
|
865
|
+
spawn uses. First comes spawn's lock check: when the package manifest at
|
|
866
|
+
that commit does not list the capability, or its capability list differs
|
|
867
|
+
from the lock's, the show refuses `E_PACKAGE_INTEGRITY` with spawn's
|
|
868
|
+
details. The full-tree content digest is not recomputed per show: `oats
|
|
869
|
+
sync` proved the lock's integrity over the tree of exactly that commit, and
|
|
870
|
+
the commit id content-addresses the tree.
|
|
871
|
+
|
|
872
|
+
**The show:**
|
|
873
|
+
|
|
874
|
+
```json
|
|
875
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.5",
|
|
876
|
+
"commit":"26d8216f…","path":"oats-package/capabilities/oats-okf",
|
|
877
|
+
"inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
|
|
878
|
+
"skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
|
|
879
|
+
"files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
|
|
880
|
+
"filesTruncated":false},
|
|
881
|
+
{"name":"okf-instance-knowledge","path":"skills/okf-instance-knowledge","description":"Keeping this instance's own knowledge …",
|
|
882
|
+
"files":[{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787}],"filesTruncated":false}],
|
|
883
|
+
"problems":[]}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
- `kind` is `member` or `package`. `repoKey` is set for both kinds; for a
|
|
887
|
+
package it is the repository the package is read from. `package` and
|
|
888
|
+
`version` are the package id and locked version, `null` for a member.
|
|
889
|
+
- `commit` is 40 hex and equals the catalog row's `commit`. `path` is the
|
|
890
|
+
capability directory, repository-relative.
|
|
891
|
+
- `inject` is `{path, bytes, text, binary, truncated}` or `null`. `skills`
|
|
892
|
+
is a list of `{name, path, description, files, filesTruncated}`, each file
|
|
893
|
+
`{path, bytes}`, or `null`. `problems` is a list of `{code, message,
|
|
894
|
+
path}`.
|
|
895
|
+
- With `--max-age` (`0` included) both the show and the `--file` answer gain
|
|
896
|
+
the [`observation`](#observation-reuse-feature-observe-max-age-oats-0311)
|
|
897
|
+
block `{observedAt, reused, localRevision}`.
|
|
898
|
+
|
|
899
|
+
**The `--file` answer:**
|
|
900
|
+
|
|
901
|
+
```json
|
|
902
|
+
{"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"26d8216f…",
|
|
903
|
+
"file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
**Rules.**
|
|
907
|
+
|
|
908
|
+
- **Paths.** Every `path` in `inject`, `skills`, `file` and `problems` is
|
|
909
|
+
POSIX and relative to the capability directory, never the repository.
|
|
910
|
+
Every non-null `path` is a safe relative path: no `.`, `..` or `.git`
|
|
911
|
+
component (any case), no empty component, no `\`. A manifest's inject and
|
|
912
|
+
skill paths are reported as the module install reads them: a `\` is a
|
|
913
|
+
separator, and a leading `./` and trailing slashes are dropped
|
|
914
|
+
(`injects\guide.md` is `injects/guide.md`).
|
|
915
|
+
- **The inject** is the committed file exactly: untrimmed and untemplated.
|
|
916
|
+
(Spawn composes it raw and trimmed, with no settings substitution.)
|
|
917
|
+
- `inject: null`: the manifest declares no inject.
|
|
918
|
+
- Declared but unreadable (missing, a symlink, a directory, over the read
|
|
919
|
+
budget): `inject: {path, bytes: null, text: null, binary: false,
|
|
920
|
+
truncated: false}` and a `problems[]` entry with the remote's code
|
|
921
|
+
(`E_REMOTE_PATH_MISSING`, `E_REMOTE_TREE_UNSAFE`, `E_REMOTE_FILE_OVERSIZE`,
|
|
922
|
+
…). The show still answers ok.
|
|
923
|
+
- Declared as a path that is not a safe relative path (spawn refuses it):
|
|
924
|
+
`inject: {path: null, bytes: null, text: null, binary: false, truncated:
|
|
925
|
+
false}` and a problem with spawn's code (`E_CAPABILITY_MISSING` for a
|
|
926
|
+
member, `E_PACKAGE_MANIFEST` for a package) and `path: null`. The raw
|
|
927
|
+
manifest value appears only inside `message`, JSON-quoted. So
|
|
928
|
+
`inject.path` is `null` only with a problem.
|
|
929
|
+
- **Skills** are in the catalog row's order (by name, in codepoint order),
|
|
930
|
+
enumerated exactly as a spawn enumerates them. `skills` is `null` exactly
|
|
931
|
+
when the catalog row's `skills` is `null`, with a problem carrying spawn's
|
|
932
|
+
code (`E_CAPABILITY_MISSING` or `E_PACKAGE_MANIFEST`) or an `E_REMOTE_*`
|
|
933
|
+
code, and `path: null`. A skill whose path is not safe (a `.git`
|
|
934
|
+
directory) makes the skills unlistable the same way, in both answers.
|
|
935
|
+
- **Files.** `files` is every regular file under the skill directory,
|
|
936
|
+
recursively (no symlinks, no submodules), sorted by path in codepoint order.
|
|
937
|
+
At most 200 per skill; beyond that `filesTruncated` is `true`. `bytes` is
|
|
938
|
+
the blob size. When a skill's files cannot be listed (an unsafe entry name
|
|
939
|
+
in the tree, an unreadable remote), `files` is `null`, `filesTruncated` is
|
|
940
|
+
`false`, and a problem's `path` is the skill's `path`: "could not list"
|
|
941
|
+
never collapses into "listed nothing".
|
|
942
|
+
- **`description`** is the `description` key of SKILL.md's leading `---` YAML
|
|
943
|
+
front matter, when it is a string. It is parsed from the whole SKILL.md,
|
|
944
|
+
not from its 262144-byte text cut. It is `null` when the front matter is
|
|
945
|
+
absent or does not parse, the key is absent or not a string, or SKILL.md is
|
|
946
|
+
unreadable or binary (no problem is reported for it). It is cut to at most
|
|
947
|
+
1024 UTF-8 bytes on a code point boundary.
|
|
948
|
+
- **Text.** A file is `binary: true, text: null` when it contains a NUL byte
|
|
949
|
+
or is not valid UTF-8. Only the first 262144 + 3 bytes are examined, so the
|
|
950
|
+
cut is decided on the same bytes; when the file is longer, a valid sequence
|
|
951
|
+
they end inside of is not held against it. Otherwise `text` is the content cut to at
|
|
952
|
+
most 262144 UTF-8 bytes on a code point boundary, with `truncated: true`
|
|
953
|
+
when cut. A byte order mark is kept in the text. `bytes` is always the real
|
|
954
|
+
size.
|
|
955
|
+
- **Invariants** (pinned for the Desktop):
|
|
956
|
+
- `binary: true` ⇒ `text: null` and `truncated: false` (`truncated` is a
|
|
957
|
+
text-only flag).
|
|
958
|
+
- `truncated: true` ⇒ `text` is a string and `binary: false`.
|
|
959
|
+
- `bytes` is `null` only for an unreadable declared inject (with its
|
|
960
|
+
problem). In a `--file` answer it is always an integer.
|
|
961
|
+
- **Large files.** A file over the read budget (4 MiB) is listed with its
|
|
962
|
+
size. `--file` refuses it with `E_REMOTE_FILE_OVERSIZE`, passed through
|
|
963
|
+
unchanged: its `details.path` is repository-relative, not
|
|
964
|
+
capability-relative.
|
|
965
|
+
|
|
966
|
+
**`--file <path>`** reads one file the show lists, and nothing else.
|
|
967
|
+
|
|
968
|
+
- A syntactically unsafe path is `E_CAPABILITY_FILE_UNSAFE`, `details:
|
|
969
|
+
{path}`, before anything is read: absolute, empty, a `.`, `..` or `.git`
|
|
970
|
+
component (any case), an empty component, a trailing slash, a `\` or a NUL.
|
|
971
|
+
- Otherwise the path must be the inject's `path` or a path in some skill's
|
|
972
|
+
`files` as the show lists it (the 200 cap included). Anything else is
|
|
973
|
+
`E_CAPABILITY_FILE_UNKNOWN`, `details: {path, name}`. No other file of the
|
|
974
|
+
capability (`oats.json`, scripts, `bin/`) is readable through this verb.
|
|
975
|
+
- A file the show does not list (beyond the 200 cap, a symlink, under a skill
|
|
976
|
+
whose files cannot be listed) is `E_CAPABILITY_FILE_UNKNOWN` by design:
|
|
977
|
+
show "not available", not an error.
|
|
978
|
+
- A listed file the remote cannot read answers the remote's own code
|
|
979
|
+
(`E_REMOTE_FILE_OVERSIZE`, `E_REMOTE_PATH_MISSING`, `E_REMOTE_TREE_UNSAFE`,
|
|
980
|
+
…).
|
|
981
|
+
|
|
982
|
+
**Failures.** Every failure is exactly one error envelope on stdout with a
|
|
983
|
+
nonzero exit, as for every command: `E_BAD_ARGS`, `E_CAPABILITY_UNKNOWN`,
|
|
984
|
+
`E_CAPABILITY_AMBIGUOUS`, `E_PACKAGE_INTEGRITY`, `E_CAPABILITY_FILE_UNSAFE`,
|
|
985
|
+
`E_CAPABILITY_FILE_UNKNOWN`, an `E_REMOTE_*` code, and the workspace's own
|
|
986
|
+
(`E_LOCAL_MISSING`, `E_LOCK_SCHEMA`, …).
|
|
987
|
+
|
|
988
|
+
**Without `--json`** the show prints a short listing for an operator: the
|
|
989
|
+
inject's path and size, each skill with its files and sizes, and the
|
|
990
|
+
problems. `--file` prints the text; a binary file prints a one-line note
|
|
991
|
+
instead, and a truncated file prints its text followed by a one-line note.
|
|
992
|
+
|
|
813
993
|
<a id="desktop-facts-feature-desktop-facts-oats-0290"></a>
|
|
814
994
|
### Desktop facts
|
|
815
995
|
|
package/docs/implementation.md
CHANGED
|
@@ -48,6 +48,7 @@ published to npm. Its developer docs are in
|
|
|
48
48
|
| `workspace.mjs` | workspace, membership and soul files; discovery |
|
|
49
49
|
| `resolve.mjs` | a soul's resolution: capabilities, slots, provenance |
|
|
50
50
|
| `packages.mjs` | `packages:`, the catalog, `oats sync`, `oats-lock.json` |
|
|
51
|
+
| `capability-show.mjs` | `oats capabilities show`: one catalog row's inject and skill files, read at its commit |
|
|
51
52
|
| `materialize.mjs` | copying modules into a home and composing it |
|
|
52
53
|
| `core.mjs` | spawn, retire, sessions, hooks, launch recipes, instance metadata |
|
|
53
54
|
| `instruction-composition.mjs` | the generated `AGENTS.md` |
|
|
@@ -104,6 +105,19 @@ gets the plain per-call behaviour. Within a session:
|
|
|
104
105
|
command that ends normally awaits the close, so its readers are reaped
|
|
105
106
|
before it exits; a `process.exit` (every refusal) ends them in the
|
|
106
107
|
exit hook (`closeNow`), and the system reaps them once the process is gone;
|
|
108
|
+
- a session may have a `deadline` (`READ_REMOTE_BUDGET_MS`, 12 s after it
|
|
109
|
+
starts): the CLI gives one to `status` and `workspace status` only
|
|
110
|
+
(`readBudgetMs`; `OATS_READ_REMOTE_BUDGET_MS` overrides it for tests). Every
|
|
111
|
+
remote step then gets what is left of it instead of its own default: each
|
|
112
|
+
git call's timeout (`sessionExec`: ls-remote, fetch, ls-tree, the cache's
|
|
113
|
+
plumbing; none starts once nothing is left), the git version probe
|
|
114
|
+
(`readVersion`), the batch readers' answers, the cache write lock's wait,
|
|
115
|
+
the half-initialised cache's wait and the lock-race backoff. What the
|
|
116
|
+
deadline ends is a `timeout` (a peel or version it ended is never read as a
|
|
117
|
+
missing commit or an older git), so an unread member degrades as any
|
|
118
|
+
unreadable one. A cut wait never changes what it judges: past the deadline
|
|
119
|
+
no lock is taken or reclaimed (live, stale or unreadable), and a cache
|
|
120
|
+
directory waited for less than in full is not taken for a crash's leftover;
|
|
107
121
|
- every git child is ended with SIGTERM first and SIGKILL only after a
|
|
108
122
|
grace (`terminateGroup`): git removes its own lock files on SIGTERM, and
|
|
109
123
|
a git killed outright leaves one that blocks every later write. The
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# OATS 0.34.0
|
|
2
|
+
|
|
3
|
+
## Added
|
|
4
|
+
|
|
5
|
+
- **`oats capabilities show <name>`** (feature `capability-show`,
|
|
6
|
+
`capabilityShowApi: 1`). It answers what one capability of the catalog
|
|
7
|
+
ships: its inject text exactly as committed, and each skill, enumerated as
|
|
8
|
+
a spawn enumerates it, with its SKILL.md description and its files with
|
|
9
|
+
their sizes. `--file <path>` returns the text of one file the show lists
|
|
10
|
+
(the inject or a skill file) and refuses any other file of the capability.
|
|
11
|
+
The rows are `oats capabilities`'s own (`--member <repoKey>` or `--package
|
|
12
|
+
<id>` picks one when names collide, and `--max-age` is accepted), and the
|
|
13
|
+
reads take the trust path spawn takes: a member at the row's commit, a
|
|
14
|
+
package at its locked commit after spawn's lock check (`E_PACKAGE_INTEGRITY`),
|
|
15
|
+
always through the remote cache, never a working clone. Every text and
|
|
16
|
+
description is untrusted repository content: render it as plain text or
|
|
17
|
+
through a sanitising Markdown renderer. The Desktop's capability page reads
|
|
18
|
+
it ([desktop-cli-api.md](../desktop-cli-api.md#oats-capabilities-show)).
|
|
19
|
+
`oats capabilities --json` is unchanged; an unknown word after
|
|
20
|
+
`oats capabilities` is now `E_BAD_ARGS`. A skill path with a `.git`
|
|
21
|
+
component, which a spawn's fetch refuses, now gives `skills: null` on that
|
|
22
|
+
catalog row too.
|
|
23
|
+
|
|
24
|
+
- **The Desktop's capability page shows what a capability ships.** A new
|
|
25
|
+
*Contents* section, between *Provides* and *Used by*, lists the injected
|
|
26
|
+
instructions and every skill's files on the left and shows the selected
|
|
27
|
+
file on the right: Markdown rendered (a SKILL.md's front matter as a small
|
|
28
|
+
table), other text as highlighted code. A relative link to another listed
|
|
29
|
+
file opens it in place. The content is untrusted repository text, so no raw
|
|
30
|
+
HTML, script or image is rendered. The section reads `oats capabilities
|
|
31
|
+
show` (feature `capability-show`) and needs this release's CLI; with an
|
|
32
|
+
older one it says so and reads nothing. Remote workspaces show it later
|
|
33
|
+
([desktop-file-viewer.md](../../packages/desktop/docs/desktop-file-viewer.md#rendering-rendererviewsmarkdownmjs)).
|
|
34
|
+
*Provides* above it is now one compact card, with every skill, command and
|
|
35
|
+
hook as its own chip.
|
|
36
|
+
|
|
37
|
+
## Changed
|
|
38
|
+
|
|
39
|
+
- **The Desktop's Instance tab says how an instance works in one sentence.**
|
|
40
|
+
"Where it works" becomes **Work**: the work mode's tile and a sentence,
|
|
41
|
+
for example "Works in its own worktree of oats, on branch main." or "Has its
|
|
42
|
+
own folder, not tied to one repository". Folder and Home move into a closed
|
|
43
|
+
**Paths** disclosure, each with Copy. The ahead/behind counts stay on the
|
|
44
|
+
Developer tab. **Messaging & Teams** shows the instance's **Messaging ID**
|
|
45
|
+
with Copy, then its **Teams**, introduced as the messaging teams its soul is
|
|
46
|
+
allowed to join.
|
|
47
|
+
|
|
48
|
+
- **The Desktop accepts OATS CLIs `>=0.25.8 <0.35.0`**, so it runs against
|
|
49
|
+
this release's kernel. Install the CLI and the Desktop 0.34.0 together: the
|
|
50
|
+
Desktop 0.33.x refuses a 0.34 CLI.
|
|
51
|
+
|
|
52
|
+
## Fixed
|
|
53
|
+
|
|
54
|
+
- **`oats status` and `oats workspace status` answer within 12 s of remote
|
|
55
|
+
work, even under load.** Each git call they made had its own 30 s limit, and
|
|
56
|
+
a read could wait up to 11 minutes behind another process fetching the same
|
|
57
|
+
remote, so the Desktop's 30 s limit could end them (`E_CLI_TIMEOUT`). Now
|
|
58
|
+
every remote step of these two reads gets what is left of a 12 s budget: a
|
|
59
|
+
member not read by then is a `cannot-read` row (`… (timeout)`) and its git is
|
|
60
|
+
ended, and the command still answers. A workspace definition not read by then
|
|
61
|
+
fails as an unreadable one does today (`reason: "timeout"`). Another
|
|
62
|
+
process's write to the remote cache is waited for only as long, never taken
|
|
63
|
+
over. Spawn, sync and the other verbs are unchanged.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/capability-show.mjs — `oats capabilities show` (feature capability-show, capabilityShowApi 1): what one
|
|
3
|
+
* catalog row of `oats capabilities` ships — its inject text and each skill's files — and one file's text on
|
|
4
|
+
* request, read at the row's commit through the read path a spawn uses. Contract: docs/desktop-cli-api.md
|
|
5
|
+
* "`oats capabilities show`".
|
|
6
|
+
*
|
|
7
|
+
* - A member row is read from its member repository at the row's commit, a package row at the LOCKED commit,
|
|
8
|
+
* after the lock check a spawn makes (lockedCapability: the package's capability list there is the lock's).
|
|
9
|
+
* The package's full-tree digest is not recomputed: `oats sync` proved the lock's integrity over the tree of
|
|
10
|
+
* exactly that commit, and the commit id content-addresses the tree.
|
|
11
|
+
* - Skills are the spawn's enumeration (capabilitySkills over enumerateSkills, lib/resolve.mjs); a skill's
|
|
12
|
+
* files are every regular file under its directory (listRemoteFiles), at most FILES_PER_SKILL.
|
|
13
|
+
* - Every read goes through lib/remote.mjs at a full commit id: nothing reads a working clone and nothing is
|
|
14
|
+
* written outside the remote cache.
|
|
15
|
+
* - Paths in the answers are POSIX and relative to the capability directory. `--file` reads only a path the
|
|
16
|
+
* show lists (the inject, a listed skill file): no other file of the capability is readable through it.
|
|
17
|
+
*
|
|
18
|
+
* Every text is untrusted repository content; a consumer renders it as plain text or through a sanitising
|
|
19
|
+
* renderer (the docs say so).
|
|
20
|
+
*/
|
|
21
|
+
import { posix } from "node:path";
|
|
22
|
+
import YAML from "yaml";
|
|
23
|
+
import { oatsError } from "./errors.mjs";
|
|
24
|
+
import { bindRemote } from "./packages.mjs";
|
|
25
|
+
import { capabilitySkills, lockedCapability, lockedPackageCapabilities, manifestFilePath, memberRef, unsafeRelPath } from "./resolve.mjs";
|
|
26
|
+
import { memberRowByKey } from "./workspace.mjs";
|
|
27
|
+
|
|
28
|
+
export const CAPABILITY_SHOW_API = 1;
|
|
29
|
+
/** A text is cut to at most this many UTF-8 bytes; binary detection examines this many + 3. */
|
|
30
|
+
export const TEXT_LIMIT = 262144;
|
|
31
|
+
/** A skill lists at most this many files (`filesTruncated` beyond). */
|
|
32
|
+
export const FILES_PER_SKILL = 200;
|
|
33
|
+
/** A skill's `description` is cut to at most this many UTF-8 bytes. */
|
|
34
|
+
export const DESCRIPTION_LIMIT = 1024;
|
|
35
|
+
|
|
36
|
+
function fail(code, message, details) {
|
|
37
|
+
const e = oatsError(code, message, details);
|
|
38
|
+
if (details !== undefined) e.details = details;
|
|
39
|
+
return e;
|
|
40
|
+
}
|
|
41
|
+
const isOatsError = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
|
|
42
|
+
const decoder = () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
43
|
+
const isContinuation = (byte) => (byte & 0xc0) === 0x80;
|
|
44
|
+
|
|
45
|
+
/** The cut of `bytes` at most `limit` long, on a code point boundary (bytes are valid UTF-8 there). */
|
|
46
|
+
function cutAt(bytes, limit) {
|
|
47
|
+
if (bytes.length <= limit) return bytes.length;
|
|
48
|
+
let cut = limit;
|
|
49
|
+
while (cut > 0 && isContinuation(bytes[cut])) cut--;
|
|
50
|
+
return cut;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A file's bytes as the answers carry them: → { text, binary, truncated }. Binary when the examined bytes (the
|
|
55
|
+
* first TEXT_LIMIT + 3) hold a NUL or are not valid UTF-8 (TextDecoder fatal, BOM kept); otherwise `text` is
|
|
56
|
+
* the content cut to at most TEXT_LIMIT bytes on a code point boundary, `truncated` when cut. The examined
|
|
57
|
+
* bytes include the whole code point that straddles the limit, so the cut is decided on the same bytes.
|
|
58
|
+
*/
|
|
59
|
+
export function decodeText(bytes, limit = TEXT_LIMIT) {
|
|
60
|
+
const head = bytes.subarray(0, limit + 3);
|
|
61
|
+
const binary = { text: null, binary: true, truncated: false };
|
|
62
|
+
if (head.includes(0)) return binary;
|
|
63
|
+
// A file longer than the examined bytes may continue a valid sequence they end inside of: `stream` accepts
|
|
64
|
+
// such an incomplete tail, and still refuses every invalid byte among them.
|
|
65
|
+
try { decoder().decode(head, { stream: bytes.length > head.length }); }
|
|
66
|
+
catch { return binary; }
|
|
67
|
+
const cut = cutAt(bytes, limit);
|
|
68
|
+
return { text: decoder().decode(bytes.subarray(0, cut)), binary: false, truncated: cut < bytes.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `text` cut to at most `limit` UTF-8 bytes on a code point boundary. */
|
|
72
|
+
export function cutUtf8(text, limit) {
|
|
73
|
+
const bytes = Buffer.from(text, "utf8");
|
|
74
|
+
return bytes.length <= limit ? text : bytes.subarray(0, cutAt(bytes, limit)).toString("utf8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FRONT_MATTER_RE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
|
|
78
|
+
/** The `description` of a SKILL.md's leading `---` YAML front matter (a string only), cut to DESCRIPTION_LIMIT
|
|
79
|
+
* bytes; null when there is no front matter, it does not parse, or the key is absent or not a string. */
|
|
80
|
+
export function skillDescription(text) {
|
|
81
|
+
if (typeof text !== "string") return null;
|
|
82
|
+
const m = FRONT_MATTER_RE.exec(text.replace(/^\uFEFF/, ""));
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
let data;
|
|
85
|
+
try { data = YAML.parse(m[1] ?? "", { logLevel: "error" }); } catch { return null; }
|
|
86
|
+
if (data === null || typeof data !== "object" || Array.isArray(data) || typeof data.description !== "string") return null;
|
|
87
|
+
return cutUtf8(data.description, DESCRIPTION_LIMIT);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A `--file` path no listing could hold (lib/resolve.mjs unsafeRelPath): absolute, empty, a `.`/`..`/`.git`
|
|
91
|
+
* component (any case), an empty component, a trailing slash, a backslash or a NUL. */
|
|
92
|
+
export const unsafeFilePath = unsafeRelPath;
|
|
93
|
+
|
|
94
|
+
/** The text a SKILL.md's description is parsed from: the whole readable file (its front matter may run past the
|
|
95
|
+
* display cut), null when the file is binary by decodeText's rule. */
|
|
96
|
+
function skillText(bytes) {
|
|
97
|
+
if (decodeText(bytes).binary) return null;
|
|
98
|
+
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The catalog row a show reads: rows named `name`, narrowed by `--member <repoKey>` (a member row of that
|
|
102
|
+
* repository) or `--package <id>` (that package's row). 0 → E_CAPABILITY_UNKNOWN; >1 → E_CAPABILITY_AMBIGUOUS. */
|
|
103
|
+
export function selectCapabilityRow(rows, name, { member = null, package: pkg = null } = {}) {
|
|
104
|
+
let matches = rows.filter((r) => r.name === name);
|
|
105
|
+
if (member !== null) matches = matches.filter((r) => r.kind === "member" && r.repoKey === member);
|
|
106
|
+
if (pkg !== null) matches = matches.filter((r) => r.kind === "package" && r.package === pkg);
|
|
107
|
+
const selector = member !== null ? { member } : pkg !== null ? { package: pkg } : {};
|
|
108
|
+
if (matches.length === 0) {
|
|
109
|
+
const where = member !== null ? ` from member ${member}` : pkg !== null ? ` from package ${pkg}` : "";
|
|
110
|
+
throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(name)}${where} in this workspace's catalog (\`oats capabilities\` lists them; a package's capabilities appear after \`oats sync\`)`, { name, ...selector });
|
|
111
|
+
}
|
|
112
|
+
if (matches.length > 1) {
|
|
113
|
+
const candidates = matches.map((r) => (r.kind === "package" ? { kind: r.kind, package: r.package, origin: r.origin } : { kind: r.kind, repoKey: r.repoKey, origin: r.origin }));
|
|
114
|
+
throw fail("E_CAPABILITY_AMBIGUOUS", `${matches.length} capabilities are named ${JSON.stringify(name)} (${candidates.map((c) => c.origin).join("; ")}): choose one with --member <repoKey> or --package <id>`, { name, candidates });
|
|
115
|
+
}
|
|
116
|
+
return matches[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Where a catalog row is read from: → { name, kind, repoKey, package, version, commit, ref, dir, manifest,
|
|
121
|
+
* missingCode }. A member row from discovery (its capability path and manifest, the member's ref); a package
|
|
122
|
+
* row from its manifests at the locked commit, after the spawn's lock check (E_PACKAGE_INTEGRITY).
|
|
123
|
+
*/
|
|
124
|
+
export async function capabilitySource(row, { discovery, lock, catalog = null, remote, remoteOptions }) {
|
|
125
|
+
if (row.kind === "package") {
|
|
126
|
+
const entry = lock?.packages?.[row.package];
|
|
127
|
+
if (!entry) throw fail("E_CAPABILITY_UNKNOWN", `package ${row.package} is not in the lock — run \`oats sync\``, { name: row.name, package: row.package });
|
|
128
|
+
const { ref, capabilities } = await lockedPackageCapabilities(row.package, entry, { catalog, remote, remoteOptions });
|
|
129
|
+
const cap = lockedCapability(row.name, row.package, entry, capabilities);
|
|
130
|
+
return { name: row.name, kind: "package", repoKey: remote.parseRepoRef(ref).key, package: row.package, version: entry.version, commit: entry.commit, ref, dir: cap.dir, manifest: cap.manifest, missingCode: "E_PACKAGE_MANIFEST" };
|
|
131
|
+
}
|
|
132
|
+
const cap = memberRowByKey(discovery.members, row.repoKey)?.capabilities.find((c) => c.name === row.name);
|
|
133
|
+
if (!cap) throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(row.name)} in member ${row.repoKey}`, { name: row.name, member: row.repoKey });
|
|
134
|
+
return { name: row.name, kind: "member", repoKey: row.repoKey, package: null, version: null, commit: row.commit, ref: memberRef(discovery, remote, row.repoKey), dir: cap.path, manifest: cap.manifest, missingCode: "E_CAPABILITY_MISSING" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const problemOf = (e, path) => ({ code: e.code, message: e.message, path });
|
|
138
|
+
|
|
139
|
+
/** What a source lists, without reading any file's content: → { inject: { path (null when unsafe), safe } | null,
|
|
140
|
+
* skills: [{ name, path, files: [{ path, bytes }] | null, filesTruncated }] | null, problems }. */
|
|
141
|
+
async function listing(source, { remote, remoteOptions }) {
|
|
142
|
+
const r = bindRemote(remote, remoteOptions);
|
|
143
|
+
const { ref, commit, dir, manifest, missingCode } = source;
|
|
144
|
+
const problems = [];
|
|
145
|
+
let inject = null;
|
|
146
|
+
if (typeof manifest.inject === "string" && manifest.inject) {
|
|
147
|
+
const path = manifestFilePath(manifest.inject);
|
|
148
|
+
// An unsafe declared path is never put in a `path` field (every path in the answers is safe): null, and the
|
|
149
|
+
// raw value only inside the problem's message.
|
|
150
|
+
inject = { path, safe: path !== null };
|
|
151
|
+
if (!path) problems.push({ code: missingCode, message: `${source.name} declares inject ${JSON.stringify(manifest.inject)}, which is not a relative path inside the capability`, path: null });
|
|
152
|
+
}
|
|
153
|
+
const { skills: found, problem } = await capabilitySkills({ ref, commit, dir, manifest, remote, remoteOptions, missingCode });
|
|
154
|
+
if (!found) problems.push(problemOf(problem, null));
|
|
155
|
+
const skills = found && [];
|
|
156
|
+
for (const skill of found ?? []) {
|
|
157
|
+
// A skill whose files cannot be listed has `files: null` (and a problem): never "listed nothing".
|
|
158
|
+
// Only the listed files' sizes are learned: nothing past FILES_PER_SKILL is fetched (#409).
|
|
159
|
+
let listed = null;
|
|
160
|
+
try { listed = await r.listRemoteFiles(ref, commit, posix.join(dir, skill.path), { limit: FILES_PER_SKILL }); }
|
|
161
|
+
catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, skill.path)); }
|
|
162
|
+
skills.push({ name: skill.name, path: skill.path, files: listed && listed.files.map((f) => ({ path: `${skill.path}/${f.path}`, bytes: f.size })), filesTruncated: listed !== null && listed.total > FILES_PER_SKILL });
|
|
163
|
+
}
|
|
164
|
+
return { inject, skills, problems };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const header = (source) => ({ capabilityShowApi: CAPABILITY_SHOW_API, name: source.name, kind: source.kind });
|
|
168
|
+
|
|
169
|
+
/** The show answer (capabilityShowApi 1): the inject with its text, each skill with its description and files. */
|
|
170
|
+
export async function capabilityShow(source, { remote, remoteOptions }) {
|
|
171
|
+
const r = bindRemote(remote, remoteOptions);
|
|
172
|
+
const { ref, commit, dir } = source;
|
|
173
|
+
const listed = await listing(source, { remote, remoteOptions });
|
|
174
|
+
const problems = [...listed.problems];
|
|
175
|
+
let inject = null;
|
|
176
|
+
if (listed.inject) {
|
|
177
|
+
inject = { path: listed.inject.path, bytes: null, text: null, binary: false, truncated: false };
|
|
178
|
+
if (listed.inject.safe) {
|
|
179
|
+
try { const { bytes, size } = await r.readRemoteFile(ref, commit, posix.join(dir, inject.path)); inject = { path: inject.path, bytes: size, ...decodeText(bytes) }; }
|
|
180
|
+
catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, inject.path)); }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let skills = null;
|
|
184
|
+
if (listed.skills) {
|
|
185
|
+
skills = [];
|
|
186
|
+
for (const skill of listed.skills) {
|
|
187
|
+
let description = null;
|
|
188
|
+
try {
|
|
189
|
+
const { bytes } = await r.readRemoteFile(ref, commit, posix.join(dir, skill.path, "SKILL.md"));
|
|
190
|
+
description = skillDescription(skillText(bytes));
|
|
191
|
+
} catch (e) { if (!isOatsError(e)) throw e; }
|
|
192
|
+
skills.push({ name: skill.name, path: skill.path, description, files: skill.files, filesTruncated: skill.filesTruncated });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { ...header(source), repoKey: source.repoKey, package: source.package, version: source.version, commit, path: dir, inject, skills, problems };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The `--file` answer: one file the show lists (the inject, or a listed skill file), at the row's commit.
|
|
199
|
+
* E_CAPABILITY_FILE_UNSAFE for a path no listing could hold; E_CAPABILITY_FILE_UNKNOWN for one it does not
|
|
200
|
+
* list; the remote's own refusal (E_REMOTE_FILE_OVERSIZE, …) for one it lists but cannot read. */
|
|
201
|
+
export async function capabilityFile(source, path, { remote, remoteOptions }) {
|
|
202
|
+
if (unsafeFilePath(path)) throw fail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(path)} is not a relative path inside the capability`, { path });
|
|
203
|
+
const listed = await listing(source, { remote, remoteOptions });
|
|
204
|
+
const known = (listed.inject?.safe && listed.inject.path === path) || (listed.skills ?? []).some((s) => (s.files ?? []).some((f) => f.path === path));
|
|
205
|
+
if (!known) throw fail("E_CAPABILITY_FILE_UNKNOWN", `${path} is not a file \`oats capabilities show ${source.name}\` lists (the inject, or a skill's files)`, { path, name: source.name });
|
|
206
|
+
const { bytes, size } = await bindRemote(remote, remoteOptions).readRemoteFile(source.ref, source.commit, posix.join(source.dir, path));
|
|
207
|
+
return { ...header(source), commit: source.commit, file: { path, bytes: size, ...decodeText(bytes) } };
|
|
208
|
+
}
|
package/lib/packages.mjs
CHANGED
|
@@ -452,7 +452,7 @@ export function memoizedRemote(remote) {
|
|
|
452
452
|
export function bindRemote(remote, remoteOptions) {
|
|
453
453
|
if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
|
|
454
454
|
const bound = { ...remote };
|
|
455
|
-
const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
|
|
455
|
+
const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, listRemoteFiles: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
|
|
456
456
|
for (const name of Object.keys(arities)) {
|
|
457
457
|
if (typeof remote[name] !== "function") continue;
|
|
458
458
|
bound[name] = (...args) => {
|
package/lib/remote.mjs
CHANGED
|
@@ -117,7 +117,15 @@
|
|
|
117
117
|
* unref'd while idle and ended by session.close();
|
|
118
118
|
* - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
|
|
119
119
|
* (the observation store below) no older than that;
|
|
120
|
-
* - the head observations used, for the `observation` output block
|
|
120
|
+
* - the head observations used, for the `observation` output block;
|
|
121
|
+
* - `deadline` (DEADLINE; the deployment reads `oats status` and `oats workspace status` only, READ_REMOTE_BUDGET_MS
|
|
122
|
+
* after their session starts): every remote step gets what is left of it, not its own default — each git call's
|
|
123
|
+
* timeout (ls-remote, fetch, ls-tree, the batch readers' answers, every cache plumbing call, the git version
|
|
124
|
+
* probe), the cache write lock's wait, the half-initialised cache's wait and the lock-race backoff. A step the deadline ends is a
|
|
125
|
+
* `timeout` (git's group killed as any timeout kills it; a step not started yet starts no git), so a member
|
|
126
|
+
* not read by then degrades as any unreadable member does (a peel or version the deadline ended is a timeout,
|
|
127
|
+
* never a missing commit or an older git). A wait the deadline cuts never changes what it judges: past it no
|
|
128
|
+
* lock is taken or reclaimed, and a cache directory is not taken for a crash's leftover.
|
|
121
129
|
*
|
|
122
130
|
* PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
|
|
123
131
|
* (repo key, commit) by a given kernel never changes, so it is kept on disk under
|
|
@@ -157,6 +165,9 @@ export const GIT_TIMEOUT_MS = 30_000;
|
|
|
157
165
|
/** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
|
|
158
166
|
* large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
|
|
159
167
|
export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
168
|
+
/** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
|
|
169
|
+
* this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
|
|
170
|
+
export const READ_REMOTE_BUDGET_MS = 12_000;
|
|
160
171
|
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
161
172
|
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
162
173
|
/** `--max-age` bounds (seconds). */
|
|
@@ -376,6 +387,21 @@ function abortError(signal) {
|
|
|
376
387
|
return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
|
|
377
388
|
}
|
|
378
389
|
|
|
390
|
+
/** A git call the session's deadline left no time for: shaped as runGit's own timeout (classified "timeout"). */
|
|
391
|
+
function deadlineError(args) {
|
|
392
|
+
return Object.assign(new Error(`Command failed: git ${args.join(" ")} (the read budget ended before it ran)`),
|
|
393
|
+
{ code: null, killed: false, signal: null, timedOut: true, overflowed: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** `exec(args, opts)` under the session's deadline: its timeout (opts.timeout, else GIT_TIMEOUT_MS) cut to what is
|
|
397
|
+
* left, and no git started once nothing is. Without a deadline, exactly `exec(args, opts)`. */
|
|
398
|
+
function sessionExec(exec, session, args, opts = {}) {
|
|
399
|
+
if (session?.deadline == null) return exec(args, opts);
|
|
400
|
+
const timeout = session.remaining(opts.timeout ?? GIT_TIMEOUT_MS);
|
|
401
|
+
if (timeout <= 0) return Promise.reject(deadlineError(args));
|
|
402
|
+
return exec(args, { ...opts, timeout });
|
|
403
|
+
}
|
|
404
|
+
|
|
379
405
|
function stderrText(error) {
|
|
380
406
|
const s = error?.stderr;
|
|
381
407
|
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
@@ -475,15 +501,17 @@ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|an
|
|
|
475
501
|
* git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
|
|
476
502
|
const LOCK_WAIT_MS = 3_000;
|
|
477
503
|
/** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
|
|
478
|
-
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
|
|
479
|
-
|
|
504
|
+
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
|
|
505
|
+
* A session's deadline cuts the backoff: the next call then ends as its timeout (sessionExec). */
|
|
506
|
+
async function retryLockRace(fn, session = null) {
|
|
480
507
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
481
508
|
for (let attempt = 0; ; attempt++) {
|
|
482
509
|
try { return await fn(); }
|
|
483
510
|
catch (error) {
|
|
484
511
|
if (!isLockRace(error) || error.timedOut) throw error;
|
|
485
512
|
if (Date.now() >= deadline) throw error;
|
|
486
|
-
|
|
513
|
+
const backoff = Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random());
|
|
514
|
+
await sleep(session ? session.remaining(backoff) : backoff);
|
|
487
515
|
}
|
|
488
516
|
}
|
|
489
517
|
}
|
|
@@ -541,8 +569,18 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
541
569
|
try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
|
|
542
570
|
const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
|
|
543
571
|
let held = null, waited = false;
|
|
572
|
+
// The session's deadline ends the wait before anything is taken or reclaimed: the read is a timeout, and the
|
|
573
|
+
// lock (live, stale or unreadable) stays exactly as it is.
|
|
574
|
+
const budgetEnded = () => {
|
|
575
|
+
let now = held;
|
|
576
|
+
try { now = readCacheWriteLock(lock); } catch { /* as last seen */ }
|
|
577
|
+
const holder = now?.owner ? `oats process ${now.owner.pid}` : "another process";
|
|
578
|
+
return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (timeout, ${stage}): the read budget ended ${now ? `while ${holder} held its write lock ${lock}` : `before it took its write lock ${lock}`}`,
|
|
579
|
+
{ url: ref.url, key: ref.key, reason: "timeout", stage, cacheDir: dir, lock, ...(now?.owner ? { holderPid: now.owner.pid } : {}) });
|
|
580
|
+
};
|
|
544
581
|
for (let attempt = 0; ; attempt++) {
|
|
545
582
|
if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
|
|
583
|
+
if (session?.expired()) throw budgetEnded();
|
|
546
584
|
const tmp = `${lock}.${owner.pid}.${owner.token}`;
|
|
547
585
|
try {
|
|
548
586
|
writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
|
|
@@ -550,7 +588,7 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
550
588
|
} catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
|
|
551
589
|
finally { try { unlinkSync(tmp); } catch { /* never written */ } }
|
|
552
590
|
try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
|
|
553
|
-
if (held && isStaleLock(held)) {
|
|
591
|
+
if (held && isStaleLock(held) && !session?.expired()) {
|
|
554
592
|
const abandoned = reclaimCacheWriteLock(lock, held, owner);
|
|
555
593
|
if (abandoned) {
|
|
556
594
|
throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
|
|
@@ -561,8 +599,10 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
561
599
|
throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
|
|
562
600
|
: `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
|
|
563
601
|
}
|
|
602
|
+
if (session?.expired()) throw budgetEnded();
|
|
564
603
|
waited = true;
|
|
565
|
-
|
|
604
|
+
const backoff = Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random());
|
|
605
|
+
await sleep(session ? Math.max(1, session.remaining(backoff)) : backoff);
|
|
566
606
|
}
|
|
567
607
|
try { return await fn({ waited }); }
|
|
568
608
|
finally {
|
|
@@ -627,7 +667,7 @@ function reclaimGitLock(repo, lockFile) {
|
|
|
627
667
|
* the call made once more. Anything else is thrown as git raised it. */
|
|
628
668
|
async function cacheGit(repo, args, opts = {}) {
|
|
629
669
|
const run = () => repo.local(args, opts);
|
|
630
|
-
try { return await retryLockRace(run); }
|
|
670
|
+
try { return await retryLockRace(run, repo.session); }
|
|
631
671
|
catch (error) {
|
|
632
672
|
const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
|
|
633
673
|
if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
|
|
@@ -646,15 +686,16 @@ function redactUrl(url) {
|
|
|
646
686
|
}
|
|
647
687
|
|
|
648
688
|
function repoHandle(dir, exec, session) {
|
|
649
|
-
const local = (args, opts = {}) => exec
|
|
689
|
+
const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
650
690
|
return { dir, exec, local, session };
|
|
651
691
|
}
|
|
652
692
|
|
|
653
693
|
async function cacheRepo(ref, options) {
|
|
654
694
|
const exec = options.exec ?? runGit;
|
|
655
695
|
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
656
|
-
|
|
657
|
-
|
|
696
|
+
const session = sessionOf(options);
|
|
697
|
+
if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec, session);
|
|
698
|
+
return repoHandle(dir, exec, session);
|
|
658
699
|
}
|
|
659
700
|
|
|
660
701
|
/** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
|
|
@@ -669,12 +710,12 @@ const HALF_INIT_WAIT_MS = 2_000;
|
|
|
669
710
|
* moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
|
|
670
711
|
* { reason: "cache", stage: "init", cacheDir }.
|
|
671
712
|
*/
|
|
672
|
-
async function initCacheRepo(ref, dir, exec) {
|
|
713
|
+
async function initCacheRepo(ref, dir, exec, session = null) {
|
|
673
714
|
const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
|
|
674
715
|
const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
675
716
|
try {
|
|
676
717
|
try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
|
|
677
|
-
try { await exec
|
|
718
|
+
try { await sessionExec(exec, session, ["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
|
|
678
719
|
try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
|
|
679
720
|
const deadline = Date.now() + HALF_INIT_WAIT_MS;
|
|
680
721
|
for (;;) {
|
|
@@ -683,7 +724,13 @@ async function initCacheRepo(ref, dir, exec) {
|
|
|
683
724
|
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
|
|
684
725
|
}
|
|
685
726
|
if (existsSync(join(dir, "HEAD"))) return; // another process created it first
|
|
686
|
-
if (Date.now() < deadline) {
|
|
727
|
+
if (Date.now() < deadline) {
|
|
728
|
+
// The session's deadline never shortens the judgment: a directory waited for less than the whole wait is
|
|
729
|
+
// never taken for a leftover. The read ends as a timeout instead.
|
|
730
|
+
if (session?.expired()) throw failInit(deadlineError(["init", "-q", "--bare", dir]));
|
|
731
|
+
await sleep(Math.max(1, Math.min(50, session ? session.remaining(50) : 50)));
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
687
734
|
// No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
|
|
688
735
|
// with ENOENT, which is fine) and take its place.
|
|
689
736
|
const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
@@ -705,8 +752,9 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
|
705
752
|
|
|
706
753
|
class ReadSession {
|
|
707
754
|
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
708
|
-
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
|
|
755
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
|
|
709
756
|
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
757
|
+
if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
|
|
710
758
|
this.maxAge = maxAge;
|
|
711
759
|
this.now = now;
|
|
712
760
|
this.startedAt = new Date(now()).toISOString();
|
|
@@ -715,6 +763,7 @@ class ReadSession {
|
|
|
715
763
|
this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
|
|
716
764
|
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
717
765
|
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
766
|
+
this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
|
|
718
767
|
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
719
768
|
this.observations = new Map(); // memo key → Promise<head observation>
|
|
720
769
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
@@ -793,12 +842,18 @@ class ReadSession {
|
|
|
793
842
|
this.retiring.clear();
|
|
794
843
|
reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
|
|
795
844
|
}
|
|
845
|
+
/** What a wait or a git call of `ms` may take: `ms`, cut to what is left before the deadline (never below 0).
|
|
846
|
+
* Without a deadline, `ms` itself. */
|
|
847
|
+
remaining(ms) { return this.deadline === null ? ms : Math.max(0, Math.min(ms, this.deadline - Date.now())); }
|
|
848
|
+
/** Whether the deadline has passed: every remote step not over by then ends as a `timeout`. */
|
|
849
|
+
expired() { return this.remaining(Infinity) === 0; }
|
|
796
850
|
/** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
|
|
797
851
|
toJSON() { return "[oats read session]"; }
|
|
798
852
|
}
|
|
799
853
|
|
|
800
|
-
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live).
|
|
801
|
-
*
|
|
854
|
+
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
|
|
855
|
+
* milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
|
|
856
|
+
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
|
|
802
857
|
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
803
858
|
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
804
859
|
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
@@ -1106,7 +1161,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1106
1161
|
let peeled = false; // the object store was asked already, and said no
|
|
1107
1162
|
const usable = async (repo) => {
|
|
1108
1163
|
if (!existsSync(join(dir, "HEAD"))) return null;
|
|
1109
|
-
if (!keepsPartialCache(await
|
|
1164
|
+
if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
|
|
1110
1165
|
peeled = true;
|
|
1111
1166
|
return peelCommit(repo, oid);
|
|
1112
1167
|
};
|
|
@@ -1116,7 +1171,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1116
1171
|
// waited for it finds what the holder fetched.
|
|
1117
1172
|
return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
|
|
1118
1173
|
let repo = await cacheRepo(ref, options);
|
|
1119
|
-
const version = await
|
|
1174
|
+
const version = await readVersion(repo, ref);
|
|
1120
1175
|
// A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
|
|
1121
1176
|
if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
|
|
1122
1177
|
const cached = peeled && !waited ? null : await peelCommit(repo, oid);
|
|
@@ -1129,6 +1184,8 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1129
1184
|
if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
|
|
1130
1185
|
const commit = await peelCommit(repo, oid);
|
|
1131
1186
|
if (!commit) {
|
|
1187
|
+
// A peel the session's deadline ended is the read's timeout, never a missing commit.
|
|
1188
|
+
if (session?.expired()) throw unreadable(ref, deadlineError(["rev-parse", `${oid}^{commit}`]), { commit: oid, cacheDir: repo.dir, stage: "fetch" });
|
|
1132
1189
|
const type = await objectType(repo, oid);
|
|
1133
1190
|
throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
|
|
1134
1191
|
}
|
|
@@ -1186,19 +1243,41 @@ async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does
|
|
|
1186
1243
|
const olderGitNotice = (version, ref) =>
|
|
1187
1244
|
`git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
|
|
1188
1245
|
|
|
1189
|
-
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
|
|
1246
|
+
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
|
|
1247
|
+
* `opts` (a timeout, a signal) go to the call that asks. A call that timed out says nothing about this git:
|
|
1248
|
+
* it answers null to its waiters and is asked again next time. */
|
|
1190
1249
|
const gitVersions = new WeakMap();
|
|
1191
|
-
export function gitVersion(exec = runGit) {
|
|
1250
|
+
export function gitVersion(exec = runGit, opts = undefined) {
|
|
1192
1251
|
let version = gitVersions.get(exec);
|
|
1193
1252
|
if (!version) {
|
|
1194
|
-
version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
|
|
1253
|
+
version = Promise.resolve().then(() => exec(["--version"], ...(opts ? [opts] : []))).then((out) => {
|
|
1195
1254
|
const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
|
|
1196
1255
|
return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
|
|
1197
|
-
}, () =>
|
|
1256
|
+
}, (error) => {
|
|
1257
|
+
if (error?.timedOut && gitVersions.get(exec) === version) gitVersions.delete(exec);
|
|
1258
|
+
return null;
|
|
1259
|
+
});
|
|
1198
1260
|
gitVersions.set(exec, version);
|
|
1199
1261
|
}
|
|
1200
1262
|
return version;
|
|
1201
1263
|
}
|
|
1264
|
+
/** gitVersion for a read of `ref` in `repo`'s session. Under a deadline the probe gets what is left of it, and
|
|
1265
|
+
* is waited for no longer (another caller may own the probe): a version the deadline left unknown is the
|
|
1266
|
+
* read's timeout, never taken for an older git (which would rebuild a partial cache). */
|
|
1267
|
+
async function readVersion(repo, ref) {
|
|
1268
|
+
const session = repo.session;
|
|
1269
|
+
if (session?.deadline == null) return gitVersion(repo.exec);
|
|
1270
|
+
const ended = () => unreadable(ref, deadlineError(["--version"]), { cacheDir: repo.dir, stage: "fetch" });
|
|
1271
|
+
const left = session.remaining(GIT_TIMEOUT_MS);
|
|
1272
|
+
if (left <= 0) throw ended();
|
|
1273
|
+
let timer;
|
|
1274
|
+
const late = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise(ended), left); });
|
|
1275
|
+
try {
|
|
1276
|
+
const version = await Promise.race([gitVersion(repo.exec, { timeout: left, signal: session.signal }), late]);
|
|
1277
|
+
if (version === ended || (version === null && session.expired())) throw ended();
|
|
1278
|
+
return version;
|
|
1279
|
+
} finally { clearTimeout(timer); }
|
|
1280
|
+
}
|
|
1202
1281
|
/** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
|
|
1203
1282
|
export function keepsPartialCache(version) {
|
|
1204
1283
|
const [major, minor] = PARTIAL_FETCH_GIT;
|
|
@@ -1443,7 +1522,7 @@ async function observeLive(ref, args, at, options) {
|
|
|
1443
1522
|
const exec = options.exec ?? runGit;
|
|
1444
1523
|
const signal = sessionOf(options)?.signal;
|
|
1445
1524
|
let out;
|
|
1446
|
-
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
1525
|
+
try { out = await sessionExec(exec, sessionOf(options), args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
1447
1526
|
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
1448
1527
|
const parsed = parseLsRemote(out.stdout);
|
|
1449
1528
|
const hit = resolveAt(parsed, at);
|
|
@@ -1578,11 +1657,11 @@ function killChild(child) {
|
|
|
1578
1657
|
* like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
|
|
1579
1658
|
* never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
|
|
1580
1659
|
* body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
|
|
1581
|
-
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS
|
|
1582
|
-
* request with a git-shaped error (classifyRemoteFailure reads it
|
|
1583
|
-
* ends the child; the next read starts a new one.
|
|
1660
|
+
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS (cut to what is left of
|
|
1661
|
+
* `session`'s deadline) rejects EVERY pending request with a git-shaped error (classifyRemoteFailure reads it
|
|
1662
|
+
* as today: timeout → "timeout") and ends the child; the next read starts a new one.
|
|
1584
1663
|
*/
|
|
1585
|
-
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1664
|
+
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS, session = null) {
|
|
1586
1665
|
// Detached, as its own process group: killChild's group kill also ends anything git started.
|
|
1587
1666
|
const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
1588
1667
|
liveChildren.add(child);
|
|
@@ -1596,7 +1675,8 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
1596
1675
|
const arm = () => {
|
|
1597
1676
|
clearTimeout(timer); timer = null;
|
|
1598
1677
|
if (!queue.length) { idle(); return; }
|
|
1599
|
-
|
|
1678
|
+
const ms = session ? session.remaining(timeoutMs) : timeoutMs;
|
|
1679
|
+
timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${ms} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), ms);
|
|
1600
1680
|
};
|
|
1601
1681
|
const die = (error) => {
|
|
1602
1682
|
if (dead) return;
|
|
@@ -1686,7 +1766,8 @@ function catFileBatch(repo) {
|
|
|
1686
1766
|
if (!session || session.closed || repo.exec !== runGit) return null;
|
|
1687
1767
|
let reader = session.batches.get(repo.dir);
|
|
1688
1768
|
if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
|
|
1689
|
-
|
|
1769
|
+
if (session.expired()) return null; // a per-blob read, which ends as the deadline's timeout (sessionExec)
|
|
1770
|
+
reader = openBatch(repo.dir, session.batchTimeoutMs, session);
|
|
1690
1771
|
session.batches.set(repo.dir, reader);
|
|
1691
1772
|
if (session.batches.size > BATCH_LIMIT) {
|
|
1692
1773
|
for (const [dir, other] of session.batches) {
|
|
@@ -1795,6 +1876,42 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
1795
1876
|
return result;
|
|
1796
1877
|
}
|
|
1797
1878
|
|
|
1879
|
+
/**
|
|
1880
|
+
* → { files: [{ path, size }], total } for the regular files (blobs) under <dir> at <commit>, recursively,
|
|
1881
|
+
* relative to <dir>, sorted by path in codepoint order (feature capability-show): `total` of them in all, and
|
|
1882
|
+
* `files` the first `limit` (every one without a limit). Symlinks and gitlinks are omitted; a name that could
|
|
1883
|
+
* escape a checkout ANYWHERE below <dir> is refused (E_REMOTE_TREE_UNSAFE, as listRemoteTree), past the limit
|
|
1884
|
+
* too. One listing (the session's tree index when it has one); the sizes a partial cache lacks are learned with
|
|
1885
|
+
* ONE ensureBlobs call over the listed files only, so nothing past the limit is fetched. A missing dir, or one
|
|
1886
|
+
* that is not a directory → { files: [], total: 0 }. Memoized at the commit (memoAtCommit), per limit.
|
|
1887
|
+
*/
|
|
1888
|
+
export async function listRemoteFiles(refText, commitArg, dir, { limit, ...options } = {}) {
|
|
1889
|
+
const ref = parseRepoRef(refText, options);
|
|
1890
|
+
requireCommit(commitArg);
|
|
1891
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) throw fail("E_REPO_REF", "limit must be a non-negative integer", { limit });
|
|
1892
|
+
const rel = normalizeTreePath(dir, { allowRoot: true });
|
|
1893
|
+
const none = { files: [], total: 0 };
|
|
1894
|
+
return memoAtCommit(ref, commitArg, `files\0${rel}\0${limit ?? "all"}`, async () => {
|
|
1895
|
+
const { repo, commit } = await ensureCommit(ref, commitArg, options);
|
|
1896
|
+
if (rel) {
|
|
1897
|
+
const entry = await entryAt(repo, commit, rel, ref);
|
|
1898
|
+
if (!entry || entry.type !== "tree") return none;
|
|
1899
|
+
}
|
|
1900
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1901
|
+
const entries = index ? indexDescendants(index, rel) : await lsTree(repo, rel ? `${commit}:${rel}` : commit, { flags: ["-r"], ref, commit });
|
|
1902
|
+
if (!entries) return none;
|
|
1903
|
+
const blobs = [];
|
|
1904
|
+
for (const e of entries) {
|
|
1905
|
+
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
1906
|
+
if (e.type === "blob" && e.mode !== "120000") blobs.push({ ...e });
|
|
1907
|
+
}
|
|
1908
|
+
blobs.sort(byPath);
|
|
1909
|
+
const listed = limit === undefined ? blobs : blobs.slice(0, limit);
|
|
1910
|
+
await ensureBlobs(repo, ref, commit, listed);
|
|
1911
|
+
return { files: listed.map((e) => ({ path: e.path, size: e.size })), total: blobs.length };
|
|
1912
|
+
}, options);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1798
1915
|
// ---------------------------------------------------------------------------
|
|
1799
1916
|
// digest
|
|
1800
1917
|
// ---------------------------------------------------------------------------
|
package/lib/resolve.mjs
CHANGED
|
@@ -474,12 +474,29 @@ export function packageRef(id, entry, catalog, remote) {
|
|
|
474
474
|
const SAFE_REL = (rel) => typeof rel === "string" && rel && !posix.isAbsolute(rel) && !/^[\\/]/.test(rel) && !/\0/.test(rel) && !rel.split(/[\\/]/).some((p) => p === "..");
|
|
475
475
|
|
|
476
476
|
/** Normalize a manifest-declared relative path ("./skills/x/" → "skills/x"); null when unsafe. */
|
|
477
|
-
function declaredPath(rel) {
|
|
477
|
+
export function declaredPath(rel) {
|
|
478
478
|
if (!SAFE_REL(rel)) return null;
|
|
479
479
|
const n = posix.normalize(rel).replace(/\/+$/, "");
|
|
480
480
|
return n === "." || n === "" || n.startsWith("../") ? null : n;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
/** A relative path no listing of a checkout can hold: absolute, empty, a `.`/`..`/`.git` (any case) or empty
|
|
484
|
+
* component, a trailing slash, a backslash or a NUL. */
|
|
485
|
+
export function unsafeRelPath(path) {
|
|
486
|
+
if (typeof path !== "string" || path === "" || path.startsWith("/") || path.endsWith("/") || /[\\\0]/.test(path)) return true;
|
|
487
|
+
return path.split("/").some((c) => c === "" || c === "." || c === ".." || c.toLowerCase() === ".git");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A manifest-declared path as the module install reads it (lib/materialize.mjs modulePath: a backslash is a
|
|
491
|
+
* separator, `./` and trailing slashes dropped), and only when the result is a safe POSIX path (unsafeRelPath);
|
|
492
|
+
* else null. What `oats capabilities show` reports for an inject or a skill. */
|
|
493
|
+
export function manifestFilePath(rel) {
|
|
494
|
+
const declared = declaredPath(rel);
|
|
495
|
+
if (!declared) return null;
|
|
496
|
+
const n = posix.normalize(declared.replace(/\\/g, "/")).replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
|
|
497
|
+
return unsafeRelPath(n) ? null : n;
|
|
498
|
+
}
|
|
499
|
+
|
|
483
500
|
/**
|
|
484
501
|
* Skills declared by a manifest: each `skills[]` entry is a directory under the capability that either
|
|
485
502
|
* IS a skill (holds SKILL.md) or holds skill directories (<entry>/<skill>/SKILL.md) — the same reading
|
|
@@ -525,14 +542,31 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
|
|
|
525
542
|
* would), commands and hooks. `skills` is null when its declared skills cannot be listed (a spawn of it
|
|
526
543
|
* would refuse; the listing does not). */
|
|
527
544
|
export async function capabilityProvides({ ref, commit, dir, manifest, remote: injected, remoteOptions }) {
|
|
528
|
-
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
529
545
|
const keys = (o) => (isObject(o) ? Object.keys(o).sort(byCodepoint) : []);
|
|
530
|
-
|
|
546
|
+
const { skills } = await capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions });
|
|
547
|
+
return { skills: skills && skills.map((s) => s.name), commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** A capability's skills as a spawn enumerates them (enumerateSkills), sorted by name in codepoint order:
|
|
551
|
+
* → { skills: [{ name, path }] (path relative to the capability directory), problem: null }, or
|
|
552
|
+
* { skills: null, problem } when the declared skills cannot be enumerated — `problem` the oats error a spawn
|
|
553
|
+
* would meet (`missingCode`: E_CAPABILITY_MISSING for a member, E_PACKAGE_MANIFEST for a package), or a skill
|
|
554
|
+
* path that is not safe (manifestFilePath). Paths are as the install reads them. The one reading of
|
|
555
|
+
* `manifest.skills` behind `oats capabilities` (capabilityProvides) and `oats capabilities show`. */
|
|
556
|
+
export async function capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions, missingCode = "E_CAPABILITY_MISSING" }) {
|
|
557
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
558
|
+
const missing = (raw, why, text) => fail(missingCode, `${manifest.capability} ${text}`, { capability: manifest.capability, skill: raw, why });
|
|
531
559
|
try {
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
560
|
+
const skills = [];
|
|
561
|
+
for (const { name, path } of await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })) {
|
|
562
|
+
// A skill path is reported as the install reads it, and only when it is a safe POSIX path (a `.git`
|
|
563
|
+
// directory the remote serves is one a spawn's fetch refuses).
|
|
564
|
+
const safe = manifestFilePath(path);
|
|
565
|
+
if (!safe) throw missing(path, "unsafe", `declares skill ${show(name)} at ${show(path)}, which is not a safe path inside the capability`);
|
|
566
|
+
skills.push({ name, path: safe });
|
|
567
|
+
}
|
|
568
|
+
return { skills: skills.sort((a, b) => byCodepoint(a.name, b.name)), problem: null };
|
|
569
|
+
} catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return { skills: null, problem: e }; throw e; }
|
|
536
570
|
}
|
|
537
571
|
|
|
538
572
|
/** The capability manifests of a locked package, read at its locked commit (feature desktop-facts). */
|
|
@@ -543,6 +577,20 @@ export async function lockedPackageCapabilities(id, entry, { catalog = null, rem
|
|
|
543
577
|
return { ref, capabilities };
|
|
544
578
|
}
|
|
545
579
|
|
|
580
|
+
/** The lock must describe the tree it names: the capability list a package's manifests declare at the locked
|
|
581
|
+
* commit (`capabilities`, readPackageManifests) must equal the lock entry's, and must hold `name`, else
|
|
582
|
+
* E_PACKAGE_INTEGRITY. → that capability { name, dir, manifest }. Spawn's check (resolveSoul), shared with
|
|
583
|
+
* `oats capabilities show`. */
|
|
584
|
+
export function lockedCapability(name, id, entry, capabilities, details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path }) {
|
|
585
|
+
const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
|
|
586
|
+
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
587
|
+
throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
|
|
588
|
+
}
|
|
589
|
+
const cap = capabilities.find((c) => c.name === name);
|
|
590
|
+
if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
|
|
591
|
+
return cap;
|
|
592
|
+
}
|
|
593
|
+
|
|
546
594
|
/* ───────────────────────────── resolveSoul ────────────────────────────── */
|
|
547
595
|
|
|
548
596
|
/**
|
|
@@ -601,13 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
601
649
|
const ref = packageRef(id, entry, catalog, remote);
|
|
602
650
|
const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
|
|
603
651
|
const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
|
|
604
|
-
|
|
605
|
-
const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
|
|
606
|
-
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
607
|
-
throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
|
|
608
|
-
}
|
|
609
|
-
const cap = capabilities.find((c) => c.name === name);
|
|
610
|
-
if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
|
|
652
|
+
const cap = lockedCapability(name, id, entry, capabilities, details);
|
|
611
653
|
const layer = layerOf(cap.manifest);
|
|
612
654
|
if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
|
|
613
655
|
module = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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",
|