@awebai/oats 0.22.2 → 0.22.4
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 +80 -12
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +211 -6
- package/capabilities/oats-aweb/injects/aweb.md +6 -0
- package/capabilities/oats-aweb/oats.json +52 -7
- package/docs/capability-manifest.schema.json +41 -0
- package/docs/execution-targets.md +29 -0
- package/docs/integrations.md +39 -0
- package/docs/operating-team-migration.md +122 -42
- package/docs/release-notes/v0.22.3.md +80 -0
- package/docs/release-notes/v0.22.4.md +40 -0
- package/docs/servers.md +62 -11
- package/lib/core.mjs +157 -26
- package/lib/servers.mjs +195 -8
- package/lib/session-input.mjs +3 -1
- package/lib/session-viewer.mjs +4 -2
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/bin/recall.mjs +8 -4
- package/packages/record/lib/index-db.mjs +19 -14
package/lib/core.mjs
CHANGED
|
@@ -671,7 +671,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
671
671
|
}
|
|
672
672
|
}
|
|
673
673
|
const ranked = [...list].sort((a, b) => a.specificity - b.specificity || b.scope - a.scope || a.target.localeCompare(b.target));
|
|
674
|
-
const settings =
|
|
674
|
+
const settings = Object.create(null); // manifest-declared names such as "constructor" must read as unset
|
|
675
675
|
// Setting names come from parsed config, so the RANK table — a key→info
|
|
676
676
|
// lookup, unlike `settings` itself, which is data handed to consumers — is
|
|
677
677
|
// null-prototype: an inherited name must not read as an already-taken rank.
|
|
@@ -685,12 +685,28 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
685
685
|
settings[key] = value; settingRank[key] = rank;
|
|
686
686
|
}
|
|
687
687
|
}
|
|
688
|
+
// Declared defaults (manifest `settings: { <name>: { default } }`) are part
|
|
689
|
+
// of the effective settings: a requirement conditional on a setting must
|
|
690
|
+
// see the default for a deployment that never set it, or the default path
|
|
691
|
+
// silently loses its requirements (aweb review of oats.aweb 1.10.0).
|
|
692
|
+
const declaredSettings = manifest.settings && typeof manifest.settings === "object" && !Array.isArray(manifest.settings) ? manifest.settings : {};
|
|
693
|
+
for (const [key, decl] of Object.entries(declaredSettings)) {
|
|
694
|
+
if (!decl || typeof decl !== "object") continue;
|
|
695
|
+
if (settings[key] === undefined && decl.default !== undefined) settings[key] = decl.default;
|
|
696
|
+
}
|
|
688
697
|
const strongest = [...list].sort((a, b) => b.specificity - a.specificity || a.scope - b.scope || a.target.localeCompare(b.target));
|
|
689
698
|
const top = strongest[0];
|
|
690
699
|
const tied = strongest.filter((c) => c.specificity === top.specificity && c.scope === top.scope);
|
|
691
700
|
const enabledValues = new Set(tied.map((c) => c.binding.enabled === undefined ? true : !!c.binding.enabled));
|
|
692
701
|
if (enabledValues.size > 1) throw new Error(`ambiguous enabled/excluded bindings for ${id} at equal specificity (${tied.map((c) => c.target).join(", ")})`);
|
|
693
702
|
if (![...enabledValues][0]) continue;
|
|
703
|
+
// A declared value set is enforced for an ACTIVE capability: a misspelling
|
|
704
|
+
// must not switch a conditional requirement off by matching nothing.
|
|
705
|
+
for (const [key, decl] of Object.entries(declaredSettings)) {
|
|
706
|
+
if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
|
|
707
|
+
throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
694
710
|
const compatibility = capabilityCompatibility(manifest);
|
|
695
711
|
if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
|
|
696
712
|
const trust = capabilityTrust(manifest, contextDir);
|
|
@@ -703,7 +719,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
703
719
|
active.push({
|
|
704
720
|
id, capability: id, manifest, layer: manifest.layer, command: manifest.command,
|
|
705
721
|
level: top.level, origin: manifest._origin, provenance: list.map((c) => `${c.target} @ ${c.level}`),
|
|
706
|
-
settings, skills: capabilitySkillDirs(id, contextDir), inject,
|
|
722
|
+
settings: { ...settings }, skills: capabilitySkillDirs(id, contextDir), inject,
|
|
707
723
|
// What the manifest PROMISES, so preflight can tell "declared nothing"
|
|
708
724
|
// from "declared and missing" — the two are indistinguishable in the
|
|
709
725
|
// resolved lists above.
|
|
@@ -717,6 +733,7 @@ export function resolveCapabilities(contextDir, soulName) {
|
|
|
717
733
|
// what required:true claims to prevent (aggregate review at 798b156).
|
|
718
734
|
requiredHooks: manifestRequiredHooks(manifest),
|
|
719
735
|
environment: trust.trusted ? [...(manifest.environment || [])] : [],
|
|
736
|
+
environmentNamespaces: trust.trusted ? [...(manifest.environmentNamespaces || [])] : [],
|
|
720
737
|
missingRequires: capabilityMissingRequires(id, contextDir), compatibility, trust, executable,
|
|
721
738
|
retirement: trust.trusted ? manifest.retirement : undefined,
|
|
722
739
|
_scope: top.scope,
|
|
@@ -889,6 +906,18 @@ function validateCapabilityManifest(m, mf) {
|
|
|
889
906
|
const vendor = id.match(/^([a-z][a-z0-9]*)\./)?.[1];
|
|
890
907
|
if (!vendor) throw new Error(`capability ${id} must use a lowercase dotted vendor ID to declare launch environment`);
|
|
891
908
|
const prefix = `${vendor.toUpperCase()}_`;
|
|
909
|
+
// A capability may declare additional namespaces it speaks for, e.g.
|
|
910
|
+
// the official oats.aweb integration setting the aweb extensions'
|
|
911
|
+
// AWEB_ variables. Declared, never implied: the names are disclosed at
|
|
912
|
+
// trust time with the rest of the launch environment, and the reserved
|
|
913
|
+
// core and bootstrap namespaces can never be claimed this way.
|
|
914
|
+
const extraNamespaces = m.environmentNamespaces === undefined ? [] : m.environmentNamespaces;
|
|
915
|
+
if (!Array.isArray(extraNamespaces) || extraNamespaces.some((ns) => typeof ns !== "string")) throw new Error(`capability ${id} manifest environmentNamespaces must be an array of prefixes`);
|
|
916
|
+
for (const ns of extraNamespaces) {
|
|
917
|
+
if (!/^[A-Z][A-Z0-9]*_$/.test(ns)) throw new Error(`capability ${id} manifest environmentNamespaces entry ${JSON.stringify(ns)} must be an uppercase prefix ending in an underscore`);
|
|
918
|
+
if (ns === "OATS_" || ns === "PI_AGENT_" || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => ns.startsWith(reserved) || reserved.startsWith(ns))) throw new Error(`capability ${id} manifest environmentNamespaces entry ${ns} is a reserved namespace`);
|
|
919
|
+
}
|
|
920
|
+
const allowed = [prefix, ...extraNamespaces];
|
|
892
921
|
for (const name of m.environment) {
|
|
893
922
|
if (!PORTABLE_ENV_NAME_RE.test(name)) throw new Error(`capability ${id} manifest environment name ${JSON.stringify(name)} is invalid`);
|
|
894
923
|
if (CORE_LAUNCH_ENV.has(name) || name.startsWith("OATS_") || name.startsWith("PI_AGENT_")) {
|
|
@@ -897,7 +926,7 @@ function validateCapabilityManifest(m, mf) {
|
|
|
897
926
|
if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
|
|
898
927
|
throw new Error(`capability ${id} manifest environment name ${name} collides with a reserved process bootstrap variable`);
|
|
899
928
|
}
|
|
900
|
-
if (!name.startsWith(
|
|
929
|
+
if (!allowed.some((ns) => name.startsWith(ns))) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""} (declare another in environmentNamespaces)`);
|
|
901
930
|
}
|
|
902
931
|
}
|
|
903
932
|
}
|
|
@@ -1309,6 +1338,7 @@ export function capabilityTrust(a, b) {
|
|
|
1309
1338
|
commands: Object.keys(manifest?.commands || {}),
|
|
1310
1339
|
hooks: Object.keys(manifest?.hooks || {}),
|
|
1311
1340
|
environment: [...(manifest?.environment || [])],
|
|
1341
|
+
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
1312
1342
|
};
|
|
1313
1343
|
return { ...t, package: t.package || manifest?._package, executableSurface: surface };
|
|
1314
1344
|
}
|
|
@@ -2591,6 +2621,7 @@ function executableSurfaceOf(manifest) {
|
|
|
2591
2621
|
commands: Object.keys(manifest?.commands || {}),
|
|
2592
2622
|
hooks: Object.keys(manifest?.hooks || {}),
|
|
2593
2623
|
environment: [...(manifest?.environment || [])],
|
|
2624
|
+
...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
|
|
2594
2625
|
};
|
|
2595
2626
|
}
|
|
2596
2627
|
function hasExecutableSurface(manifest) {
|
|
@@ -4254,6 +4285,27 @@ export function packageSpecIdentity(spec) {
|
|
|
4254
4285
|
/** What the runtime reports about a required package: is it there, where did it
|
|
4255
4286
|
* land, and does the user's entry filter its resources? A settings row alone is
|
|
4256
4287
|
* NOT proof the capability's extension will load (reviewer-8518c49). */
|
|
4288
|
+
/** The installed version of a runtime package: neither pi's nor Claude's
|
|
4289
|
+
* listing reports one, so it is read from the package.json under the
|
|
4290
|
+
* install directory the listing names; undefined when there is none. */
|
|
4291
|
+
export function installedRuntimePackageVersion(runtime, spec, status) {
|
|
4292
|
+
const dir = status?.dir;
|
|
4293
|
+
if (dir && existsSync(join(dir, "package.json"))) {
|
|
4294
|
+
try {
|
|
4295
|
+
const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
|
|
4296
|
+
if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) return v.match(/^\d+\.\d+\.\d+/)[0];
|
|
4297
|
+
} catch { /* unreadable manifest: unknowable */ }
|
|
4298
|
+
}
|
|
4299
|
+
return undefined;
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
export function compareVersionTriples(a, b) {
|
|
4303
|
+
const pa = String(a).split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
4304
|
+
const pb = String(b).split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
4305
|
+
for (let i = 0; i < 3; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
|
|
4306
|
+
return 0;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4257
4309
|
export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}) {
|
|
4258
4310
|
const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
|
|
4259
4311
|
if (!mgr) return { installed: false };
|
|
@@ -4342,6 +4394,12 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4342
4394
|
throw new HookEnvironmentContractError(`${capabilityID} hook env requires a dotted lowercase alphanumeric vendor component; hyphen, @, and / forms cannot claim an environment namespace`);
|
|
4343
4395
|
}
|
|
4344
4396
|
const prefix = `${vendorComponent.toUpperCase()}_`;
|
|
4397
|
+
// The runtime half of the same contract the manifest validator enforces:
|
|
4398
|
+
// a hook may set names under its vendor prefix or under a namespace its
|
|
4399
|
+
// trusted manifest declared (environmentNamespaces), and only names the
|
|
4400
|
+
// manifest listed. Manifest and runtime must permit exactly the same set.
|
|
4401
|
+
const declared = declarations.get(capabilityID) || { names: new Set(), namespaces: [] };
|
|
4402
|
+
const allowed = [prefix, ...(declared.namespaces || [])];
|
|
4345
4403
|
const accepted = {};
|
|
4346
4404
|
for (const name of Object.keys(value).sort()) {
|
|
4347
4405
|
const envValue = value[name];
|
|
@@ -4354,10 +4412,10 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4354
4412
|
if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
|
|
4355
4413
|
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} collides with a reserved process bootstrap variable`);
|
|
4356
4414
|
}
|
|
4357
|
-
if (!name.startsWith(
|
|
4358
|
-
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${
|
|
4415
|
+
if (!allowed.some((ns) => name.startsWith(ns))) {
|
|
4416
|
+
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""}`);
|
|
4359
4417
|
}
|
|
4360
|
-
if (!
|
|
4418
|
+
if (!declared.names.has(name)) {
|
|
4361
4419
|
throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is not declared in its trusted manifest environment`);
|
|
4362
4420
|
}
|
|
4363
4421
|
if (typeof envValue !== "string") {
|
|
@@ -4382,7 +4440,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4382
4440
|
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
|
|
4383
4441
|
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
|
|
4384
4442
|
const envOwners = new Map();
|
|
4385
|
-
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, new Set(cap.environment || [])]));
|
|
4443
|
+
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
|
|
4386
4444
|
const caps = [...(resolved.capabilities || [])];
|
|
4387
4445
|
if (event === "retire") caps.reverse();
|
|
4388
4446
|
for (const cap of caps) {
|
|
@@ -4999,6 +5057,11 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
4999
5057
|
for (const cap of resolved.capabilities || []) {
|
|
5000
5058
|
for (const raw of cap.manifest?.requires || []) {
|
|
5001
5059
|
if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
|
|
5060
|
+
// A requirement may be conditional on the capability's effective
|
|
5061
|
+
// settings (`when: { delivery: "channel" }`): rows whose condition does
|
|
5062
|
+
// not hold are not requirements of this spawn at all.
|
|
5063
|
+
if (raw.when !== undefined && (!raw.when || typeof raw.when !== "object" || Array.isArray(raw.when))) { problems.push(`${cap.id}: a requirement's \`when\` must be an object of setting names to values`); continue; }
|
|
5064
|
+
if (raw.when && !Object.entries(raw.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v))) continue;
|
|
5002
5065
|
const spec = raw.package;
|
|
5003
5066
|
if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
|
|
5004
5067
|
if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
|
|
@@ -5007,7 +5070,10 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
5007
5070
|
const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
|
|
5008
5071
|
const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
|
|
5009
5072
|
const remedy = `run \`oats install --accept-requirement ${runtime}:${runtimePackageIdentity(runtime, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
|
|
5010
|
-
|
|
5073
|
+
// `ifInstalled: true`: the row constrains a package that may be absent
|
|
5074
|
+
// (an ambient extension must honour a contract IF it is there); absence
|
|
5075
|
+
// satisfies it. Without the flag, absence fails as before.
|
|
5076
|
+
if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
|
|
5011
5077
|
// A settings row is not proof the extension loads. Both of these leave the
|
|
5012
5078
|
// capability silently absent, which is the loss this gate exists to stop.
|
|
5013
5079
|
if (status.unverified) { problems.push(`${cap.id} requires the ${runtime} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
|
|
@@ -5019,6 +5085,16 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
|
|
|
5019
5085
|
}
|
|
5020
5086
|
if (status.disabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || runtime} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
|
|
5021
5087
|
if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
|
|
5088
|
+
// A floor on the installed version (`minVersion`), checked once presence
|
|
5089
|
+
// and loadability are settled: a package whose manifest states an older
|
|
5090
|
+
// version, or none, fails closed with the same remedy, since an old
|
|
5091
|
+
// extension that ignores a newer contract (AWEB_DELIVERY) is exactly
|
|
5092
|
+
// what the floor guards.
|
|
5093
|
+
if (raw.minVersion) {
|
|
5094
|
+
const have = installedRuntimePackageVersion(runtime, spec, status);
|
|
5095
|
+
if (!have) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
|
|
5096
|
+
if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
|
|
5097
|
+
}
|
|
5022
5098
|
if (status.extensionsFilter?.length) {
|
|
5023
5099
|
// Unverifiable, not merely auditable: proving the filter selects this
|
|
5024
5100
|
// capability's extension means implementing pi's glob semantics, and
|
|
@@ -5803,6 +5879,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5803
5879
|
settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
|
|
5804
5880
|
hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
|
|
5805
5881
|
...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
|
|
5882
|
+
...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
|
|
5806
5883
|
})),
|
|
5807
5884
|
skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
|
|
5808
5885
|
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
@@ -5845,7 +5922,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
5845
5922
|
},
|
|
5846
5923
|
capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
|
|
5847
5924
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
5848
|
-
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
5925
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
5849
5926
|
missingRequires: cap.missingRequires, trust: cap.trust,
|
|
5850
5927
|
executable: cap.executable,
|
|
5851
5928
|
})),
|
|
@@ -6099,7 +6176,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
|
|
|
6099
6176
|
outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
|
|
6100
6177
|
capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
|
|
6101
6178
|
id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
|
|
6102
|
-
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
|
|
6179
|
+
hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
|
|
6103
6180
|
missingRequires: cap.missingRequires,
|
|
6104
6181
|
trust: cap.trust, executable: cap.executable,
|
|
6105
6182
|
})),
|
|
@@ -6414,17 +6491,44 @@ const RECOVERABLE_GIT_ADMIN = [
|
|
|
6414
6491
|
"BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
|
|
6415
6492
|
];
|
|
6416
6493
|
|
|
6494
|
+
/** Object ids among `oids` that `repo` does not have, from one
|
|
6495
|
+
* `cat-file --batch-check` process. */
|
|
6496
|
+
export function missingGitObjects(repo, oids) {
|
|
6497
|
+
if (!oids.length) return [];
|
|
6498
|
+
const out = execFileSync("git", ["-C", repo, "cat-file", "--batch-check=%(objectname) %(objecttype)"], { input: oids.join("\n") + "\n", encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
|
|
6499
|
+
const missing = [];
|
|
6500
|
+
for (const line of out.split("\n")) {
|
|
6501
|
+
if (!line.endsWith(" missing")) continue;
|
|
6502
|
+
missing.push(line.slice(0, line.indexOf(" ")));
|
|
6503
|
+
}
|
|
6504
|
+
return missing;
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6417
6507
|
function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
6418
6508
|
const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6419
6509
|
const recoveredGit = join(recoveredRepo, ".git");
|
|
6420
6510
|
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
|
|
6511
|
+
// The staged blobs the recovered index will point at. The clone already
|
|
6512
|
+
// holds every blob reachable from a commit; only content that is staged but
|
|
6513
|
+
// never committed is missing, so ask once which objects the recovered
|
|
6514
|
+
// repository lacks (one process for the whole index) and copy only those.
|
|
6515
|
+
// Copying every row cost two Git processes per index entry: a clean 9,500
|
|
6516
|
+
// file tree took ~19,000 launches and looked like a hang. Gitlink rows
|
|
6517
|
+
// (mode 160000) name commits of nested repositories, which
|
|
6518
|
+
// materializeNestedRepositories restores; they are never blobs here.
|
|
6519
|
+
const staged = new Set();
|
|
6421
6520
|
for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
|
|
6422
|
-
const match = row.match(
|
|
6423
|
-
if (!match) continue;
|
|
6424
|
-
|
|
6521
|
+
const match = row.match(/^(\d+) ([0-9a-f]+) \d+\t/);
|
|
6522
|
+
if (!match || match[1] === "160000") continue;
|
|
6523
|
+
staged.add(match[2]);
|
|
6524
|
+
}
|
|
6525
|
+
for (const oid of missingGitObjects(recoveredRepo, [...staged])) {
|
|
6526
|
+
const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", oid], { maxBuffer: GIT_MAX_BUFFER });
|
|
6425
6527
|
const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6426
|
-
if (restored !==
|
|
6528
|
+
if (restored !== oid) throw new Error(`recovered Git object ${restored} did not match source ${oid}`);
|
|
6427
6529
|
}
|
|
6530
|
+
const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
|
|
6531
|
+
if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
|
|
6428
6532
|
copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
|
|
6429
6533
|
for (const name of RECOVERABLE_GIT_ADMIN) {
|
|
6430
6534
|
const source = join(sourceGit, name);
|
|
@@ -6479,12 +6583,22 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6479
6583
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
6480
6584
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
6481
6585
|
try {
|
|
6586
|
+
// A home-only change (notes, runtime files, credentials) needs a home
|
|
6587
|
+
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
6588
|
+
// In-progress Git operations retain the full standalone recovery even
|
|
6589
|
+
// when porcelain status has no changed paths.
|
|
6590
|
+
let homeOnly = observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
|
|
6591
|
+
&& meta.work === "worktree" && existsSync(observation.work);
|
|
6592
|
+
if (homeOnly) {
|
|
6593
|
+
const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6594
|
+
homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
6595
|
+
}
|
|
6482
6596
|
const recoveredHome = join(staging, "home");
|
|
6483
6597
|
copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
|
|
6484
6598
|
if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
|
|
6485
6599
|
throw new Error("home recovery verification disagreed with the source");
|
|
6486
6600
|
}
|
|
6487
|
-
if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6601
|
+
if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6488
6602
|
const recoveredRepo = join(staging, "repo");
|
|
6489
6603
|
execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6490
6604
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
@@ -6508,10 +6622,11 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
6508
6622
|
const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6509
6623
|
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
6510
6624
|
}
|
|
6511
|
-
|
|
6625
|
+
const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
|
|
6626
|
+
writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
6512
6627
|
mkdirSync(dirname(recovery), { recursive: true });
|
|
6513
6628
|
renameSync(staging, recovery);
|
|
6514
|
-
return { path: recovery, classes: observation.classes };
|
|
6629
|
+
return { path: recovery, classes: observation.classes, ...(repoCopy ? { repoCopy } : {}) };
|
|
6515
6630
|
} catch (e) {
|
|
6516
6631
|
rmSync(staging, { recursive: true, force: true });
|
|
6517
6632
|
throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
|
|
@@ -6562,12 +6677,12 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
6562
6677
|
const intent = {
|
|
6563
6678
|
instance: name, agent: found.agent.name, root: resolve(root),
|
|
6564
6679
|
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
6565
|
-
options: { deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
6680
|
+
options: { home: found.home, deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
6566
6681
|
};
|
|
6567
6682
|
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
6568
6683
|
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
6569
6684
|
rmSync(resultPath, { force: true });
|
|
6570
|
-
const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name}\``);
|
|
6685
|
+
const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name} --home ${found.home}\``);
|
|
6571
6686
|
let fd, child;
|
|
6572
6687
|
try {
|
|
6573
6688
|
fd = openSync(logPath, "w");
|
|
@@ -6586,7 +6701,7 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
6586
6701
|
// debt instead of an uncaught exception behind a success message.
|
|
6587
6702
|
child.on("error", (e) => {
|
|
6588
6703
|
try {
|
|
6589
|
-
writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name}` }, null, 2) + "\n");
|
|
6704
|
+
writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name} --home ${found.home}` }, null, 2) + "\n");
|
|
6590
6705
|
} catch { /* the marker alone then shows RETIRING; status names its age */ }
|
|
6591
6706
|
});
|
|
6592
6707
|
child.unref();
|
|
@@ -6625,20 +6740,21 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
6625
6740
|
let result;
|
|
6626
6741
|
try {
|
|
6627
6742
|
result = retireInstance(intent.root, intent.instance, {
|
|
6628
|
-
deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6743
|
+
home: intent.options?.home, deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6629
6744
|
});
|
|
6630
6745
|
} catch (e) {
|
|
6631
|
-
record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}` });
|
|
6746
|
+
record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
6632
6747
|
console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
|
|
6633
6748
|
return false;
|
|
6634
6749
|
}
|
|
6635
6750
|
if (result.rollbackIncomplete) {
|
|
6636
|
-
record({ ok: false, result, retry: `oats retire ${intent.instance}` });
|
|
6751
|
+
record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
6637
6752
|
console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
|
|
6638
6753
|
return false;
|
|
6639
6754
|
}
|
|
6640
6755
|
if (intent.options?.keepDir) {
|
|
6641
|
-
|
|
6756
|
+
// The kept home is the one the intent names, never a same-named twin.
|
|
6757
|
+
const retained = intent.options?.home ? { home: intent.options.home } : findInstanceHome(intent.root, intent.instance);
|
|
6642
6758
|
if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
|
|
6643
6759
|
}
|
|
6644
6760
|
// Success leaves nothing beside the home: the home is gone, the marker went
|
|
@@ -6655,8 +6771,23 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6655
6771
|
// retirement is deferred to a detached external completion (below); with
|
|
6656
6772
|
// --keep-dir the old in-process path runs and kills the window LAST.
|
|
6657
6773
|
const self = o.self === true;
|
|
6658
|
-
|
|
6659
|
-
|
|
6774
|
+
// Names are unique per agent dir only: two agents can own an instance of
|
|
6775
|
+
// the same name (dev --purpose foo-1 and agent dev-foo). Retirement is
|
|
6776
|
+
// destructive, so a name that resolves to several homes is refused unless
|
|
6777
|
+
// the caller says which home (--home), and a home is accepted only when it
|
|
6778
|
+
// is one of that name's homes under this root.
|
|
6779
|
+
const matches = findInstanceHomes(root, name);
|
|
6780
|
+
if (!matches.length) throw new Error(`no instance named "${name}"`);
|
|
6781
|
+
const sameHome = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
6782
|
+
let found;
|
|
6783
|
+
if (o.home) {
|
|
6784
|
+
found = matches.find((m) => sameHome(m.home, o.home));
|
|
6785
|
+
if (!found) throw oatsError("E_HOME_MISMATCH", `instance "${name}" has no home at ${o.home} under this agents root (its home${matches.length === 1 ? " is" : "s are"} ${matches.map((m) => m.home).join(", ")})`);
|
|
6786
|
+
} else if (matches.length > 1) {
|
|
6787
|
+
throw oatsError("E_AMBIGUOUS_INSTANCE", `"${name}" names ${matches.length} instances under this agents root (${matches.map((m) => `${m.agent.name}: ${m.home}`).join("; ")}); retire with --home <path> to say which`);
|
|
6788
|
+
} else {
|
|
6789
|
+
found = matches[0];
|
|
6790
|
+
}
|
|
6660
6791
|
const metaPath = join(found.home, "instance.json");
|
|
6661
6792
|
// A QUARANTINED home (spawn failed after a required hook and compensation did
|
|
6662
6793
|
// not finish) has no instance.json — it never got that far. Its marker carries
|