@awebai/oats 0.34.0 → 0.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +53 -28
- package/docs/capabilities.md +6 -1
- package/docs/desktop-cli-api.md +6 -6
- package/docs/execution-targets.md +17 -5
- package/docs/implementation.md +24 -2
- package/docs/integrations.md +2 -2
- package/docs/knowledge.md +7 -4
- package/docs/official-catalog.md +4 -4
- package/docs/packages.md +10 -10
- package/docs/release-notes/v0.34.1.md +57 -0
- package/docs/release-notes/v0.34.2.md +68 -0
- package/docs/souls-and-instances.md +18 -11
- package/docs/workspaces.md +13 -2
- package/lib/canonical-json.mjs +3 -1
- package/lib/capability-contract.mjs +1 -1
- package/lib/config-data.mjs +4 -2
- package/lib/core.mjs +67 -15
- package/lib/instance-resolution.mjs +26 -4
- package/lib/remote.mjs +116 -9
- package/lib/workspace.mjs +3 -2
- package/package-catalog.json +3 -3
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
|
@@ -129,7 +129,7 @@ full copy** of every capability the soul resolved to:
|
|
|
129
129
|
.oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
|
|
130
130
|
.oats/bin/oats → <kernel>/bin/oats.mjs # the kernel that last launched this home: first on the harness's PATH
|
|
131
131
|
work/ # worktree, checkout symlink, attached tree, or private directory
|
|
132
|
-
TASK.md # briefing and task
|
|
132
|
+
TASK.md # briefing and task (0600; the home itself is 0700)
|
|
133
133
|
instance.json # provenance (below); `soulDir` = the soul directory hooks get as OATS_SOUL
|
|
134
134
|
STATE.md, log.md, notes/ # optional, from the knowledge capability
|
|
135
135
|
```
|
|
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
|
|
|
152
152
|
"commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
|
|
153
153
|
},
|
|
154
154
|
"oats.okf": {
|
|
155
|
-
"from": { "kind": "package", "package": "oats.okf", "version": "4.0.
|
|
156
|
-
"commit": "
|
|
155
|
+
"from": { "kind": "package", "package": "oats.okf", "version": "4.0.6", "commit": "2a62df8e…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
|
|
156
|
+
"commit": "2a62df8e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"providers": {
|
|
@@ -245,8 +245,7 @@ OATS codex session does not appear in `codex agents`. So that the environment
|
|
|
245
245
|
does not depend on this, a codex launch also sets it for tool
|
|
246
246
|
commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
|
|
247
247
|
|
|
248
|
-
- the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME
|
|
249
|
-
`PI_AGENT_HOME`;
|
|
248
|
+
- the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`;
|
|
250
249
|
- every capability's launch environment (for example the messaging
|
|
251
250
|
provider's identity home and delivery mode);
|
|
252
251
|
- the launch configuration's literal values. A reference's value never goes
|
|
@@ -258,9 +257,9 @@ runs tool commands through the user's login shell, and a profile that prepends
|
|
|
258
257
|
directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
|
|
259
258
|
directory is found first.
|
|
260
259
|
|
|
261
|
-
A capability command (`oats <namespace> …`)
|
|
262
|
-
`OATS_INSTANCE_HOME
|
|
263
|
-
from the working directory. It uses the nearest enclosing directory laid out as
|
|
260
|
+
A capability command (`oats <namespace> …`) runs in the instance home that
|
|
261
|
+
`OATS_INSTANCE_HOME` names, else `OATS_HOME`. With neither set, it finds its
|
|
262
|
+
instance from the working directory. It uses the nearest enclosing directory laid out as
|
|
264
263
|
`<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
|
|
265
264
|
name, and validates it like a home named by the environment. The walk uses the
|
|
266
265
|
directory as the shell names it (`$PWD`). That matters for an attached
|
|
@@ -268,6 +267,13 @@ instance, whose `work/` links into its owner's tree: below it, the physical
|
|
|
268
267
|
path is the owner's. A process that has no `$PWD` there would act as the
|
|
269
268
|
owner, so an attached instance runs capability commands from its home.
|
|
270
269
|
|
|
270
|
+
Inside an instance home the namespace is that home's. A `--soul` naming
|
|
271
|
+
another soul is refused (`E_HOME_MISMATCH`), and a namespace the home does
|
|
272
|
+
not have is `E_UNKNOWN_COMMAND`. Both name the home and what chose it (the
|
|
273
|
+
variable, or the working directory). To run a command as a spawn of another
|
|
274
|
+
soul would, run it from the deployment with `OATS_INSTANCE_HOME` and
|
|
275
|
+
`OATS_HOME` unset.
|
|
276
|
+
|
|
271
277
|
## Lifecycle
|
|
272
278
|
|
|
273
279
|
### Spawn
|
|
@@ -561,9 +567,10 @@ Every instance is told its own home as **`OATS_INSTANCE_HOME`** (absolute), and
|
|
|
561
567
|
instructions refer to it as `<instance-home>`. The two environments differ, so
|
|
562
568
|
they are stated separately:
|
|
563
569
|
|
|
564
|
-
- **Runtime session**: `OATS_INSTANCE_HOME` and `
|
|
565
|
-
|
|
566
|
-
|
|
570
|
+
- **Runtime session**: `OATS_INSTANCE_HOME` and `OATS_INSTANCE`, for every
|
|
571
|
+
harness. The pi extension reads `OATS_INSTANCE_HOME` too; the `PI_AGENT_*`
|
|
572
|
+
names are not set (they stay reserved, so a launch configuration cannot set
|
|
573
|
+
them).
|
|
567
574
|
- **Lifecycle hooks**: `OATS_INSTANCE_HOME` and `OATS_HOME`, alongside the rest of
|
|
568
575
|
the hook contract. `OATS_HOME` predates `OATS_INSTANCE_HOME` and is kept because
|
|
569
576
|
shipped capability hooks read it; it is **not** exported to harness sessions.
|
package/docs/workspaces.md
CHANGED
|
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
57
|
oats.framework: v1.4.1 # bare version → resolves through the official catalog
|
|
58
|
-
oats.okf: v4.0.
|
|
58
|
+
oats.okf: v4.0.6
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
|
61
61
|
teams: # SHARED teams: the same provider team for everyone
|
|
@@ -174,7 +174,18 @@ from *outside* that boundary, and **declaring one in the workspace's
|
|
|
174
174
|
access context.** The kernel reads both halves over the remotes
|
|
175
175
|
(`git ls-remote`, shallow fetches, the operator's own credential helpers,
|
|
176
176
|
never a prompt). A half that cannot be read makes the member *unconfirmed*,
|
|
177
|
-
never a half-success.
|
|
177
|
+
never a half-success. A remote's current head is read with Git's protocol v0
|
|
178
|
+
(one round trip) unless your Git configuration sets `protocol.version`, which
|
|
179
|
+
is then used as set; a tag or branch is read as before. A remote whose v0 ref
|
|
180
|
+
advertisement is over 4 MiB is read with protocol v2 from then on: the 4 MiB
|
|
181
|
+
bounds what OATS keeps, not the download, so the first read transfers that
|
|
182
|
+
advertisement once before falling back. A remote that times out under v0
|
|
183
|
+
fails as before and is read with protocol v2 for the next 7 days; a read
|
|
184
|
+
ended by the 12 s budget of `oats status` or `oats workspace status` is not
|
|
185
|
+
the remote's own timeout, and is neither remembered nor warned about. One that
|
|
186
|
+
fails under v0 for another reason than an authentication refusal, a missing
|
|
187
|
+
repository or the local cache is read again with v2. Each case prints one
|
|
188
|
+
`oats: warning`; the first two are remembered in the remote cache. `oats workspace status` and `oats sync` show each member
|
|
178
189
|
as `confirmed` or the reason it is not:
|
|
179
190
|
|
|
180
191
|
| status | meaning |
|
package/lib/canonical-json.mjs
CHANGED
|
@@ -103,6 +103,8 @@ export function canonicalJson(value, options) {
|
|
|
103
103
|
return chunks.join("");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** The 1-based line of character `offset` in `text`: where a reader's error points. */
|
|
107
|
+
export function lineAt(text, offset) { return text.slice(0, offset).split("\n").length; }
|
|
106
108
|
/** JSON.parse alone cannot detect duplicate decoded keys. This small JSON
|
|
107
109
|
* decoder preserves that check and budgets; YAML must use it for JSON input,
|
|
108
110
|
* not maintain another permissive JSON reader. Objects have null prototypes. */
|
|
@@ -114,7 +116,7 @@ export function parseStrictJson(input, options) {
|
|
|
114
116
|
}
|
|
115
117
|
const text = decodeUtf8(data);
|
|
116
118
|
let at = 0, entries = 0;
|
|
117
|
-
const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}
|
|
119
|
+
const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}`, { offset: at }); };
|
|
118
120
|
const space = () => { while (at < text.length && /[\x20\t\r\n]/.test(text[at])) at++; };
|
|
119
121
|
const string = () => {
|
|
120
122
|
const start = at++;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
export const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
|
|
14
14
|
export const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
15
15
|
export const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
16
|
-
export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"
|
|
16
|
+
export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
|
|
17
17
|
export const PROCESS_BOOTSTRAP_ENV = new Set([
|
|
18
18
|
"PATH", "HOME", "SHELL", "TMPDIR", "TMP", "TEMP", "PWD", "OLDPWD", "SHLVL", "_",
|
|
19
19
|
"ENV", "BASH_ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "IFS", "PROMPT_COMMAND", "PS4", "ZDOTDIR",
|
package/lib/config-data.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Legacy core readers are unchanged until the explicit consumer migration. */
|
|
4
4
|
import { Composer, CST, Lexer, Parser, isAlias, isMap, isScalar, isSeq } from "yaml";
|
|
5
5
|
import { bytesIntegrity } from "./digest.mjs";
|
|
6
|
-
import { byteView, canonicalJson, dataLimits, decodeUtf8, parseStrictJson } from "./canonical-json.mjs";
|
|
6
|
+
import { byteView, canonicalJson, dataLimits, decodeUtf8, lineAt, parseStrictJson } from "./canonical-json.mjs";
|
|
7
7
|
import { oatsError } from "./errors.mjs";
|
|
8
8
|
|
|
9
9
|
const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
@@ -71,7 +71,9 @@ export function parseConfigData(input, { format = "auto", origin = null, limits:
|
|
|
71
71
|
}
|
|
72
72
|
if (document.errors.length || document.warnings.length || document.directives.yaml.version !== "1.2") {
|
|
73
73
|
const issue = document.errors[0] ?? document.warnings[0];
|
|
74
|
-
|
|
74
|
+
const offset = issue?.pos?.[0];
|
|
75
|
+
const line = Number.isInteger(offset) ? lineAt(source, offset) : undefined;
|
|
76
|
+
throw oatsError("invalid-declaration", `invalid portable YAML${issue ? ` (${issue.code}${line ? ` at line ${line}` : ""})` : " version"}`);
|
|
75
77
|
}
|
|
76
78
|
const decode = (node, pointer, depth) => {
|
|
77
79
|
mark(pointer, depth, node);
|
package/lib/core.mjs
CHANGED
|
@@ -63,7 +63,7 @@ import { validateBindingInterface } from "./provider-binding.mjs";
|
|
|
63
63
|
/** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
|
|
64
64
|
export { fingerprintTree };
|
|
65
65
|
|
|
66
|
-
import { canonicalJson, parseStrictJson } from "./canonical-json.mjs";
|
|
66
|
+
import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
|
|
67
67
|
import { readPortableBytes } from "./bounded-read.mjs";
|
|
68
68
|
import { copyTreeSafe } from "./tree-copy.mjs";
|
|
69
69
|
/** The package and capability id grammar (namespaced, lowercase): an id names a
|
|
@@ -89,6 +89,10 @@ function capabilityAgentDirs(root) {
|
|
|
89
89
|
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !RESERVED.has(e.name) && !existsSync(join(root, e.name, "soul", "soul.yaml")))
|
|
90
90
|
.map((e) => ({ name: e.name, dir: join(root, e.name) }));
|
|
91
91
|
}
|
|
92
|
+
/** The names of the non-hidden directories in `d` ([] when it cannot be read). */
|
|
93
|
+
function subdirs(d) {
|
|
94
|
+
try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; }
|
|
95
|
+
}
|
|
92
96
|
/** What OATS 0.25 left under <scope>/local-agents/ (and the nested <root>/local-agents|
|
|
93
97
|
* tmp-agents) for the agents root `root`: one `legacy-local-agents` problem naming the
|
|
94
98
|
* instance homes there, or null. Names only — nothing there is read, spawned into or
|
|
@@ -97,7 +101,6 @@ export function legacyLocalAgents(root) {
|
|
|
97
101
|
if (!root) return null;
|
|
98
102
|
const bases = [join(dirname(root), LEGACY_LOCAL_AGENTS_DIR), join(root, LEGACY_LOCAL_AGENTS_DIR), join(root, "tmp-agents")];
|
|
99
103
|
const dirs = [], instances = [];
|
|
100
|
-
const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
|
|
101
104
|
for (const base of bases) {
|
|
102
105
|
let st; try { st = lstatSync(base); } catch { continue; }
|
|
103
106
|
if (!st.isDirectory()) continue;
|
|
@@ -109,12 +112,32 @@ export function legacyLocalAgents(root) {
|
|
|
109
112
|
return { code: "legacy-local-agents", dirs, instances,
|
|
110
113
|
message: `${instances.length} instance home${instances.length === 1 ? "" : "s"} under local-agents/ ${instances.length === 1 ? "is" : "are"} from OATS 0.25 and ${instances.length === 1 ? "is" : "are"} not managed by this kernel; retire ${instances.length === 1 ? "it" : "them"} with the 0.25 kernel or delete the directory once ${instances.length === 1 ? "it is" : "they are"} stopped${instances.length ? ` (${instances.join(", ")})` : ""}` };
|
|
111
114
|
}
|
|
115
|
+
/** The instance homes under the agents root `root` (a directory <root>/<agent>/instances/<name>
|
|
116
|
+
* whose instance.json records that name) that other users on the machine can read or enter (any
|
|
117
|
+
* group or other permission bit): one `home-readable` problem naming them, with the exact chmod,
|
|
118
|
+
* or null. A home holds identity keys, TASK.md and transcripts; spawn creates it 0700. Only
|
|
119
|
+
* reported: the operator decides. */
|
|
120
|
+
export function readableInstanceHomes(root) {
|
|
121
|
+
if (!root) return null;
|
|
122
|
+
const homes = [];
|
|
123
|
+
for (const agent of subdirs(root)) for (const name of subdirs(join(root, agent, "instances"))) {
|
|
124
|
+
const home = join(root, agent, "instances", name);
|
|
125
|
+
try {
|
|
126
|
+
if (JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.instance !== name) continue;
|
|
127
|
+
if (lstatSync(home).mode & 0o077) homes.push(home);
|
|
128
|
+
} catch { /* no readable instance.json, or gone meanwhile: not a home */ }
|
|
129
|
+
}
|
|
130
|
+
if (!homes.length) return null;
|
|
131
|
+
homes.sort();
|
|
132
|
+
const fix = `chmod 700 ${homes.map(shq).join(" ")}`;
|
|
133
|
+
return { code: "home-readable", homes, fix,
|
|
134
|
+
message: `${homes.length} instance home${homes.length === 1 ? " can" : "s can"} be read by other users on this machine (${homes.length === 1 ? "it holds" : "they hold"} identity keys, TASK.md and transcripts); fix: ${fix}` };
|
|
135
|
+
}
|
|
112
136
|
/** The captured homes (the 0.24–0.25 captured/portable path, removed in 0.26) under the
|
|
113
137
|
* agents root `root`: one `legacy-captured-home` problem naming them, or null. They have
|
|
114
138
|
* no 0.26 runtime (start, inspect and in-home commands refuse them); retire still works. */
|
|
115
139
|
export function legacyCapturedHomes(root) {
|
|
116
140
|
if (!root) return null;
|
|
117
|
-
const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
|
|
118
141
|
const homes = [];
|
|
119
142
|
for (const agent of subdirs(root)) for (const inst of subdirs(join(root, agent, "instances"))) {
|
|
120
143
|
const home = join(root, agent, "instances", inst);
|
|
@@ -883,11 +906,21 @@ function readCatalogFile() {
|
|
|
883
906
|
// catalog belongs to the kernel install.
|
|
884
907
|
const override = !!process.env.OATS_PACKAGE_CATALOG;
|
|
885
908
|
if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
|
|
886
|
-
let doc;
|
|
887
909
|
const text = readFileSync(file, "utf8");
|
|
888
910
|
if (override) recordLocalInput(file, text);
|
|
911
|
+
return parsePackageCatalog(text, file);
|
|
912
|
+
}
|
|
913
|
+
/** A package catalog's text, parsed and checked as the kernel reads it (named `file` in errors):
|
|
914
|
+
* { packages, capabilities, file }, both maps null-prototype; invalid-source when it is broken. */
|
|
915
|
+
export function parsePackageCatalog(text, file) {
|
|
916
|
+
let doc;
|
|
889
917
|
try { doc = JSON.parse(text); }
|
|
890
|
-
catch (e) {
|
|
918
|
+
catch (e) {
|
|
919
|
+
// JSON.parse does not say where; the kernel's strict reader (same grammar) locates the error.
|
|
920
|
+
let line;
|
|
921
|
+
try { parseStrictJson(text, { maxBytes: 64 * 1024 * 1024 }); } catch (s) { if (Number.isInteger(s.provenance?.offset)) line = lineAt(text, s.provenance.offset); }
|
|
922
|
+
throw oatsError("invalid-source", `broken package catalog ${file}${line ? ` (line ${line})` : ""}: ${e.message}`);
|
|
923
|
+
}
|
|
891
924
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
|
|
892
925
|
const out = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
893
926
|
const packages = doc.packages;
|
|
@@ -2082,6 +2115,12 @@ export function upgradeHomeMeta(meta, home) {
|
|
|
2082
2115
|
return { ...rest, ...(runtime !== undefined || meta.harness !== undefined ? { harness: meta.harness ?? runtime } : {}), ...(meta.launch !== undefined ? { launch: upgradeLaunchRecipe(meta.launch) } : {}) };
|
|
2083
2116
|
}
|
|
2084
2117
|
const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
|
|
2118
|
+
/** The fixed prompt a codex launch starts with: it names the task file, never its text (argv is
|
|
2119
|
+
* readable by every local user). Codex reads the file with a tool. */
|
|
2120
|
+
export const CODEX_TASK_PROMPT = "Read TASK.md in this directory first: it is your briefing and your task.";
|
|
2121
|
+
/** The prompt each harness gets, so the task's text never travels in argv: Claude Code inlines an
|
|
2122
|
+
* `@file` mention; codex is pointed at the file. pi takes `@TASK.md` among its own arguments. */
|
|
2123
|
+
const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT });
|
|
2085
2124
|
|
|
2086
2125
|
/** The executable a launch uses: a configuration's declared one (a bare name
|
|
2087
2126
|
* on PATH; a path against the deployment directory when relative) or the
|
|
@@ -2354,7 +2393,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
|
|
|
2354
2393
|
// The launch's environment, in prefix order: the instance, the capabilities' env, the
|
|
2355
2394
|
// configuration's env (a reference by reference, never by value).
|
|
2356
2395
|
const hookEnv = recipe.hooks?.env || {};
|
|
2357
|
-
const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]
|
|
2396
|
+
const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]].map(([name, value]) => ({ name, value }));
|
|
2358
2397
|
for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
|
|
2359
2398
|
for (const name of Object.keys(recipe.env || {}).sort()) {
|
|
2360
2399
|
const v = recipe.env[name];
|
|
@@ -2362,7 +2401,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
|
|
|
2362
2401
|
}
|
|
2363
2402
|
let cmdline;
|
|
2364
2403
|
if (harness === "claude") {
|
|
2365
|
-
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} --
|
|
2404
|
+
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.claude)}`;
|
|
2366
2405
|
} else if (harness === "codex") {
|
|
2367
2406
|
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
2368
2407
|
// Codex may run tool commands outside the session's process (its shared app-server daemon),
|
|
@@ -2370,7 +2409,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
|
|
|
2370
2409
|
// never goes on argv, and PATH is the execution's (CODEX_TOOL_PATH: the shim first).
|
|
2371
2410
|
const toolEnvArgs = env.filter((e) => !e.reference && e.name !== "PATH")
|
|
2372
2411
|
.map((e) => ` -c ${shq(`shell_environment_policy.set.${e.name}=${JSON.stringify(e.value)}`)}`).join("");
|
|
2373
|
-
cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} --
|
|
2412
|
+
cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.codex)}`;
|
|
2374
2413
|
} else {
|
|
2375
2414
|
// Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
|
|
2376
2415
|
// .agents/skills up the tree, so the instance's copied capability skills are
|
|
@@ -2387,7 +2426,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
|
|
|
2387
2426
|
* with the home's kernel shim first on PATH (every launch runs through here). */
|
|
2388
2427
|
function nativeRecordCommand(command, home, harness) {
|
|
2389
2428
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
2390
|
-
const args = tokens.slice(binary + 1).
|
|
2429
|
+
const args = tokens.slice(binary + 1).map(t => t.value ?? t.text);
|
|
2391
2430
|
const id = prepareNativeStart(home, harness);
|
|
2392
2431
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
2393
2432
|
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${executionArgv(tokens, binary, harness).join(" ")}`;
|
|
@@ -2561,7 +2600,7 @@ export function describeLaunchCommand(command) {
|
|
|
2561
2600
|
* stay references); a command the parser refuses is withheld whole. */
|
|
2562
2601
|
/** The identity environment every launch carries: public facts (instance
|
|
2563
2602
|
* name, home path), shown in public renderings; everything else is withheld. */
|
|
2564
|
-
export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"
|
|
2603
|
+
export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
|
|
2565
2604
|
export function redactLaunchCommand(command) {
|
|
2566
2605
|
try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
|
|
2567
2606
|
catch { return "<unparseable launch command withheld>"; }
|
|
@@ -2720,7 +2759,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2720
2759
|
// decision: an attached agent shares its owner's work tree and is ALWAYS the
|
|
2721
2760
|
// owner's child — relation flags that say anything else are contradictory and
|
|
2722
2761
|
// rejected. Ambient env
|
|
2723
|
-
// (OATS_INSTANCE
|
|
2762
|
+
// (OATS_INSTANCE) is deliberately NOT consulted: any shell
|
|
2724
2763
|
// opened inside an agent's tmux window inherits those vars, and env inheritance
|
|
2725
2764
|
// is not evidence of intent — human spawns from such shells were misattributed
|
|
2726
2765
|
// as instance-origin. Manual spawns land top-level unless a relation is
|
|
@@ -3102,7 +3141,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3102
3141
|
// apply of the same decision, or anything else) got here first, and this one
|
|
3103
3142
|
// has touched nothing.
|
|
3104
3143
|
mkdirSync(dirname(home), { recursive: true });
|
|
3105
|
-
|
|
3144
|
+
// 0700: the home holds the instance's identity keys, TASK.md and transcripts.
|
|
3145
|
+
try { mkdirSync(home, { mode: 0o700 }); }
|
|
3106
3146
|
catch (e) {
|
|
3107
3147
|
if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
|
|
3108
3148
|
throw e;
|
|
@@ -3606,7 +3646,7 @@ You are instance "${instance}" of agent "${agent.name}".
|
|
|
3606
3646
|
- Home: ${home}
|
|
3607
3647
|
- Work tree: ./work — ${workDesc}
|
|
3608
3648
|
- Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${harness === "codex" ? "\n## Harness notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
|
|
3609
|
-
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}
|
|
3649
|
+
${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`, { mode: 0o600 }); // human text: the owner's only
|
|
3610
3650
|
|
|
3611
3651
|
// Launch command. Spawn IS session start: the recipe is persisted in
|
|
3612
3652
|
// instance.json beside its rendering, which is executed in the instance's
|
|
@@ -4564,8 +4604,18 @@ export function inputInstanceSession(home, text) {
|
|
|
4564
4604
|
|
|
4565
4605
|
// ---------------------------------------------------------------- session start
|
|
4566
4606
|
|
|
4567
|
-
/** The
|
|
4607
|
+
/** The prompt token of a recorded claude or codex command that hands the harness the task's own
|
|
4608
|
+
* text in argv, where any local user can read it: `"$(cat TASK.md)"`. It is parsed, and replaced
|
|
4609
|
+
* when the home starts (withSafeTaskPrompt). */
|
|
4568
4610
|
const LAUNCH_PROMPT_TOKEN = '"$(cat TASK.md)"';
|
|
4611
|
+
/** A recorded command with its harness's safe task prompt: a `"$(cat TASK.md)"` token becomes
|
|
4612
|
+
* TASK_PROMPT[harness]. Applied when a home starts from its recorded command, which is saved so. */
|
|
4613
|
+
export function withSafeTaskPrompt(command, harness) {
|
|
4614
|
+
const { tokens } = parseLaunchCommand(command);
|
|
4615
|
+
if (!tokens.some((t) => t.kind === "prompt")) return command;
|
|
4616
|
+
if (!Object.hasOwn(TASK_PROMPT, harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `a ${harness} command never carried the task as "$(cat TASK.md)"; inspect the saved command in instance.json`);
|
|
4617
|
+
return renderLaunchCommand(tokens.map((t) => t.kind === "prompt" ? { kind: "word", value: TASK_PROMPT[harness], quoted: true, text: shq(TASK_PROMPT[harness]) } : t));
|
|
4618
|
+
}
|
|
4569
4619
|
|
|
4570
4620
|
/** Tokenize a persisted OATS launch command. The grammar is exactly what
|
|
4571
4621
|
* spawn renders: space-separated tokens that are env assignments NAME='v',
|
|
@@ -5004,6 +5054,8 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5004
5054
|
command = withLaunchModel(command, resolved);
|
|
5005
5055
|
model = resolved; explicitModelFrom = "start";
|
|
5006
5056
|
} else parseLaunchCommand(command);
|
|
5057
|
+
// A recorded command starts, and is saved, with its harness's safe task prompt.
|
|
5058
|
+
if (!launchPlan) command = withSafeTaskPrompt(command, harness);
|
|
5007
5059
|
// References recorded for this home must resolve on this host on every
|
|
5008
5060
|
// start path, and the source variables go to the pane, not the command.
|
|
5009
5061
|
const recipeForEnv = launchPlan?.recipe || (meta.launch && typeof meta.launch === "object" ? meta.launch : null);
|
|
@@ -5098,7 +5150,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5098
5150
|
}
|
|
5099
5151
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5100
5152
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5101
|
-
// A transient child (for example
|
|
5153
|
+
// A transient child (for example the native-start recorder) is not proof that startup
|
|
5102
5154
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
5103
5155
|
try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
|
|
5104
5156
|
catch (e) {
|
|
@@ -85,21 +85,43 @@ export function qualifiedSoulName(entry) {
|
|
|
85
85
|
return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** A soul name as an operator gives it: `[repoPart, soulPart]`, repoPart null for a bare name. */
|
|
89
|
+
function splitSoulName(name) {
|
|
90
|
+
return name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
|
|
91
|
+
}
|
|
92
|
+
/** Whether `name` names the member or package soul `{ name, repoKey }` / `{ name, package }`: a
|
|
93
|
+
* bare name, `<repo>/<soul>` with the member's key, a key suffix or its member name, or
|
|
94
|
+
* `<package>/<soul>` for a package soul. findSoulEntry's rule for each candidate. */
|
|
95
|
+
export function soulNameMatches(name, soul) {
|
|
96
|
+
const [repoPart, soulPart] = splitSoulName(name);
|
|
97
|
+
if (soulPart !== soul.name) return false;
|
|
98
|
+
if (!repoPart) return true;
|
|
99
|
+
if (typeof soul.package === "string") return repoPart === soul.package;
|
|
100
|
+
const key = soul.repoKey ?? "";
|
|
101
|
+
return key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
102
|
+
}
|
|
103
|
+
/** Whether `name` names the soul an instance home was spawned from (its instance.json `meta`): any name
|
|
104
|
+
* a spawn accepts for it, or the home's agent directory name. */
|
|
105
|
+
export function homeSoulMatches(name, meta) {
|
|
106
|
+
if (name === meta?.agent) return true;
|
|
107
|
+
const soul = meta?.workspace?.soul ?? {};
|
|
108
|
+
return soulNameMatches(name, soul.package && typeof soul.package === "object" ? { name: soul.name, package: soul.package.id } : { name: meta?.agent, repoKey: soul.repoKey });
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
/** Find the soul named `name` in a discovery: confirmed members, external souls and package
|
|
89
112
|
* souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
|
|
90
113
|
* qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
|
|
91
114
|
* or an external source, `<package>/<soul>` a package soul. */
|
|
92
115
|
export function findSoulEntry(discovery, name) {
|
|
93
|
-
const [repoPart, soulPart] =
|
|
116
|
+
const [repoPart, soulPart] = splitSoulName(name);
|
|
94
117
|
const hits = [];
|
|
95
118
|
// A standalone view's one row is the repo's own (unconfirmed by definition — the
|
|
96
119
|
// workspace could not be read); resolveSoul admits exactly that case.
|
|
97
120
|
const standaloneOwn = discovery.standalone === true ? discovery.key : null;
|
|
98
|
-
const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
99
121
|
for (const m of discovery.members || []) {
|
|
100
122
|
if (!m.confirmed && m.key !== standaloneOwn) continue;
|
|
101
123
|
for (const s of m.souls || []) {
|
|
102
|
-
if (s.name
|
|
124
|
+
if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
|
|
103
125
|
hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
|
|
104
126
|
}
|
|
105
127
|
}
|
|
@@ -107,7 +129,7 @@ export function findSoulEntry(discovery, name) {
|
|
|
107
129
|
if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
|
|
108
130
|
}
|
|
109
131
|
for (const s of discovery.packageSouls || []) {
|
|
110
|
-
if (
|
|
132
|
+
if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
|
|
111
133
|
}
|
|
112
134
|
if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
|
|
113
135
|
if (hits.length > 1) {
|
package/lib/remote.mjs
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* APPROACH (one approach, used for every remote kind — local bare repos and
|
|
5
5
|
* https/ssh remotes alike):
|
|
6
6
|
* 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
|
|
7
|
-
* never a fetch when the caller already gave a full OID.
|
|
7
|
+
* never a fetch when the caller already gave a full OID. A HEAD observation
|
|
8
|
+
* speaks protocol v0 unless the operator pinned `protocol.version` (one round
|
|
9
|
+
* trip instead of v2's two; what is kept of the advertisement is bounded, and a
|
|
10
|
+
* remote over budget, or one that timed out under v0, is observed under v2:
|
|
11
|
+
* observeLive).
|
|
8
12
|
* 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
|
|
9
13
|
* commit locally. `ensureCommit` does a shallow, partial
|
|
10
14
|
* `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
|
|
@@ -168,6 +172,10 @@ export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
|
168
172
|
/** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
|
|
169
173
|
* this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
|
|
170
174
|
export const READ_REMOTE_BUDGET_MS = 12_000;
|
|
175
|
+
/** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
|
|
176
|
+
* reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
|
|
177
|
+
* and the remote observed under v2, its server-side filter, from then on (observeLive). */
|
|
178
|
+
export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
|
|
171
179
|
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
172
180
|
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
173
181
|
/** `--max-age` bounds (seconds). */
|
|
@@ -752,7 +760,7 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
|
752
760
|
|
|
753
761
|
class ReadSession {
|
|
754
762
|
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
755
|
-
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
|
|
763
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
|
|
756
764
|
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
765
|
if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
|
|
758
766
|
this.maxAge = maxAge;
|
|
@@ -764,6 +772,8 @@ class ReadSession {
|
|
|
764
772
|
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
765
773
|
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
766
774
|
this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
|
|
775
|
+
this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
|
|
776
|
+
this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
|
|
767
777
|
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
768
778
|
this.observations = new Map(); // memo key → Promise<head observation>
|
|
769
779
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
@@ -853,7 +863,8 @@ class ReadSession {
|
|
|
853
863
|
|
|
854
864
|
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
|
|
855
865
|
* milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
|
|
856
|
-
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs
|
|
866
|
+
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
|
|
867
|
+
* `v0AdvertisementBudget`. */
|
|
857
868
|
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
858
869
|
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
859
870
|
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
@@ -1390,7 +1401,7 @@ function parseLsRemote(stdout) {
|
|
|
1390
1401
|
}
|
|
1391
1402
|
|
|
1392
1403
|
function resolveAt(parsed, at) {
|
|
1393
|
-
if (at
|
|
1404
|
+
if (isHeadAt(at)) {
|
|
1394
1405
|
const oid = parsed.oids.get("HEAD");
|
|
1395
1406
|
return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
|
|
1396
1407
|
}
|
|
@@ -1434,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
|
|
|
1434
1445
|
|
|
1435
1446
|
/** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
|
|
1436
1447
|
function lsRemoteArgs(ref, at) {
|
|
1437
|
-
const wantHead = at
|
|
1448
|
+
const wantHead = isHeadAt(at);
|
|
1438
1449
|
if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
|
|
1439
1450
|
const args = ["ls-remote", "--symref", ref.url];
|
|
1440
1451
|
if (wantHead) args.push("HEAD");
|
|
@@ -1518,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
|
|
|
1518
1529
|
} finally { session.observeDone(); }
|
|
1519
1530
|
}
|
|
1520
1531
|
|
|
1532
|
+
/** Whether `at` asks for the remote's default branch (a HEAD observation). */
|
|
1533
|
+
const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
|
|
1534
|
+
|
|
1535
|
+
/** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
|
|
1536
|
+
* `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
|
|
1537
|
+
* once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
|
|
1538
|
+
* included) → true: today's argv, never an error. */
|
|
1539
|
+
const protocolPins = new WeakMap();
|
|
1540
|
+
function protocolPinned(exec, session) {
|
|
1541
|
+
const memo = session?.protocolPins ?? protocolPins;
|
|
1542
|
+
let pinned = memo.get(exec);
|
|
1543
|
+
if (!pinned) {
|
|
1544
|
+
pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
|
|
1545
|
+
.then(() => true, (error) => error?.code !== 1);
|
|
1546
|
+
memo.set(exec, pinned);
|
|
1547
|
+
}
|
|
1548
|
+
return pinned;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
|
|
1552
|
+
* beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
|
|
1553
|
+
* root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
|
|
1554
|
+
* good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
|
|
1555
|
+
* file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
|
|
1556
|
+
* Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
|
|
1557
|
+
* that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
|
|
1558
|
+
const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
|
|
1559
|
+
const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
|
|
1560
|
+
function lsRemoteV2Recorded(root, ref, now) {
|
|
1561
|
+
const record = (reason) => {
|
|
1562
|
+
const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
|
|
1563
|
+
return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
|
|
1564
|
+
};
|
|
1565
|
+
if (record("overflow")) return true;
|
|
1566
|
+
const rec = record("timeout");
|
|
1567
|
+
const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
|
|
1568
|
+
return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
|
|
1569
|
+
}
|
|
1570
|
+
function recordLsRemoteV2(root, ref, reason, now) {
|
|
1571
|
+
writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
|
|
1575
|
+
* such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
|
|
1576
|
+
const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
|
|
1580
|
+
* operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
|
|
1581
|
+
* advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
|
|
1582
|
+
* filtered answer gives, HEAD's symref included (v0's symref capability).
|
|
1583
|
+
* - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
|
|
1584
|
+
* advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
|
|
1585
|
+
* recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
|
|
1586
|
+
* - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
|
|
1587
|
+
* - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
|
|
1588
|
+
* and said once if the retry succeeds.
|
|
1589
|
+
* `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
|
|
1590
|
+
* and branches always use it.
|
|
1591
|
+
*/
|
|
1521
1592
|
async function observeLive(ref, args, at, options) {
|
|
1522
1593
|
const exec = options.exec ?? runGit;
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1594
|
+
const session = sessionOf(options);
|
|
1595
|
+
const signal = session?.signal;
|
|
1596
|
+
// Every git call takes what is left of the session's deadline, if it has one (sessionExec).
|
|
1597
|
+
const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
|
|
1598
|
+
const root = cacheRootOf(options);
|
|
1599
|
+
const now = () => (session ? session.now() : Date.now());
|
|
1600
|
+
const url = redactUrl(ref.url);
|
|
1601
|
+
const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
|
|
1602
|
+
let out = null, retried = null;
|
|
1603
|
+
const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
|
|
1604
|
+
// A command given up while its protocol was asked starts no ls-remote.
|
|
1605
|
+
if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
|
|
1606
|
+
if (v0) {
|
|
1607
|
+
const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
|
|
1608
|
+
// A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
|
|
1609
|
+
// which says nothing about the remote: its timeout is not recorded.
|
|
1610
|
+
const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
|
|
1611
|
+
try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
|
|
1612
|
+
catch (error) {
|
|
1613
|
+
if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
1614
|
+
recordLsRemoteV2(root, ref, "overflow", now());
|
|
1615
|
+
say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
|
|
1616
|
+
} else {
|
|
1617
|
+
const reason = classifyRemoteFailure(error);
|
|
1618
|
+
const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
|
|
1619
|
+
if (!aborted && reason === "timeout" && !cut) {
|
|
1620
|
+
recordLsRemoteV2(root, ref, "timeout", now());
|
|
1621
|
+
say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
|
|
1622
|
+
}
|
|
1623
|
+
if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
|
|
1624
|
+
retried = reason;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (!out) {
|
|
1629
|
+
try { out = await run(args); }
|
|
1630
|
+
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
1631
|
+
}
|
|
1527
1632
|
const parsed = parseLsRemote(out.stdout);
|
|
1528
1633
|
const hit = resolveAt(parsed, at);
|
|
1529
1634
|
if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1530
1635
|
if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1531
1636
|
const { commit } = await ensureCommit(ref, hit.commit, options);
|
|
1637
|
+
if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
|
|
1532
1638
|
return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
|
|
1533
1639
|
}
|
|
1640
|
+
const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
1534
1641
|
|
|
1535
1642
|
// ---------------------------------------------------------------------------
|
|
1536
1643
|
// tree reading
|
package/lib/workspace.mjs
CHANGED
|
@@ -334,8 +334,9 @@ function schemaHint(kind, value) {
|
|
|
334
334
|
}
|
|
335
335
|
return "";
|
|
336
336
|
}
|
|
337
|
-
/** Parse + validate a declaration file
|
|
338
|
-
|
|
337
|
+
/** Parse + validate a declaration file (`kind` workspace | membership | soul | local) → { value } |
|
|
338
|
+
* { problems }. Never throws. The reader `oats sync` uses, exported for the repository's validate. */
|
|
339
|
+
export function readDeclaration(kind, bytes, origin, validateOptions) {
|
|
339
340
|
const decoded = decodeDocument(bytes, origin);
|
|
340
341
|
if (decoded.problems) return decoded;
|
|
341
342
|
const problems = FILE_KINDS[kind].validate(decoded.value, validateOptions);
|