@awebai/oats 0.41.1 → 0.42.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 +3 -19
- package/docs/capabilities.md +73 -2
- package/docs/capability-manifest.schema.json +38 -0
- package/docs/desktop-cli-api.md +89 -6
- package/docs/desktop.md +15 -5
- package/docs/execution-targets.md +202 -45
- package/docs/implementation.md +3 -1
- package/docs/release-notes/v0.42.0.md +398 -0
- package/docs/souls-and-instances.md +315 -2
- package/lib/capability-contract.mjs +56 -0
- package/lib/core.mjs +1214 -264
- package/lib/instance-lifecycle.mjs +12 -1
- package/lib/launch-preference.mjs +9 -0
- package/lib/login-environment.mjs +212 -0
- package/lib/retire-output.mjs +50 -0
- package/lib/tree-copy.mjs +4 -2
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -43,10 +43,11 @@ import { attachSessionTarget } from "./session-viewer.mjs";
|
|
|
43
43
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
44
44
|
import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
|
|
45
45
|
import { killGroup } from "./process-group.mjs";
|
|
46
|
+
import { captureLoginEnvironment } from "./login-environment.mjs";
|
|
46
47
|
|
|
47
48
|
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
48
49
|
import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
|
|
49
|
-
import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
|
|
50
|
+
import { PANE_PATH_FIX, harnessNotOnPanePath, harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
|
|
50
51
|
import { claudeTrusts, codexTrustsRoot, harnessTrustWarning } from "./harness-trust.mjs";
|
|
51
52
|
async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
|
|
52
53
|
// Capability rows for a PREPARED spawn (workspace model): the one function that
|
|
@@ -60,15 +61,17 @@ import { GIT_FETCH_TIMEOUT_MS, GIT_TIMEOUT_MS, gitEnv } from "./remote.mjs";
|
|
|
60
61
|
import { loadLocal } from "./workspace.mjs";
|
|
61
62
|
import { parseConfigData } from "./config-data.mjs";
|
|
62
63
|
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
63
|
-
import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems } from "./capability-contract.mjs";
|
|
64
|
+
import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems, disposableHomeRootProblem, disposableHomeRootMatches } from "./capability-contract.mjs";
|
|
64
65
|
import { validateBindingInterface } from "./provider-binding.mjs";
|
|
65
|
-
/** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home)
|
|
66
|
-
|
|
66
|
+
/** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home),
|
|
67
|
+
* the exact digest a copied path is compared with (exported so a test holds it to the copier),
|
|
68
|
+
* and the receipts the stored digest passes over in a home (exported so a test holds the manifest grammar to refusing each of them). */
|
|
69
|
+
export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
|
|
67
70
|
|
|
68
71
|
import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
|
|
69
72
|
import { readPortableBytes } from "./bounded-read.mjs";
|
|
70
73
|
import { copyTreeSafe } from "./tree-copy.mjs";
|
|
71
|
-
import { assertSameWorktreeHead, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
|
|
74
|
+
import { assertSameWorktreeHead, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
|
|
72
75
|
/** The package and capability id grammar (namespaced, lowercase): an id names a
|
|
73
76
|
* directory (a home's module copy), so no path spelling fits it. */
|
|
74
77
|
const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -572,7 +575,9 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
572
575
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
573
576
|
/** Environment the kernel sets for every launch (identity, home, roots) and
|
|
574
577
|
* its reference aliases: a configuration may not name them. */
|
|
575
|
-
export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"
|
|
578
|
+
export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT",
|
|
579
|
+
// The test seam that replaces the login shell (lib/login-environment.mjs): never a launch's to set.
|
|
580
|
+
"OATS_TEST_LOGIN_SHELL"]);
|
|
576
581
|
export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
|
|
577
582
|
const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
|
|
578
583
|
export function validateLaunchConfig(name, entry, where) {
|
|
@@ -681,19 +686,10 @@ function validateCapabilityManifest(m, mf) {
|
|
|
681
686
|
// has one flat name space per organisation instead (duplicate → E_CAPABILITY_AMBIGUOUS
|
|
682
687
|
// at resolution), so the grammar is the only requirement.
|
|
683
688
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`capability ID must match ^[a-z0-9][a-z0-9._-]*$: "${id}" (${mf})`);
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const unknown = Object.keys(m.retirement).filter((key) => key !== "disposable");
|
|
687
|
-
const scopes = Object.keys(m.retirement.disposable).filter((key) => !["home", "work"].includes(key));
|
|
688
|
-
if (unknown.length || scopes.length) throw new Error(`capability ${id} manifest retirement has unsupported keys: ${[...unknown, ...scopes].join(", ")}`);
|
|
689
|
-
for (const scope of ["home", "work"]) {
|
|
690
|
-
const roots = m.retirement.disposable[scope];
|
|
691
|
-
if (roots !== undefined && (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))) throw new Error(`capability ${id} manifest retirement.disposable.${scope} must be an array of relative roots`);
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
// Launch environment and hooks: the shared manifest contract (lib/capability-contract.mjs).
|
|
689
|
+
// Launch environment, hooks and retirement: the shared manifest contract
|
|
690
|
+
// (lib/capability-contract.mjs). The error carries the problem's JSON pointer.
|
|
695
691
|
const problem = manifestContractProblems(m)[0];
|
|
696
|
-
if (problem) throw new Error(problem.message);
|
|
692
|
+
if (problem) throw Object.assign(new Error(problem.message), { pointer: problem.pointer });
|
|
697
693
|
return id;
|
|
698
694
|
}
|
|
699
695
|
|
|
@@ -2002,12 +1998,15 @@ function withoutKernelEnvironment(source) {
|
|
|
2002
1998
|
* backtick, a quote and a backslash are written the same by every version read, and are carried.
|
|
2003
1999
|
*
|
|
2004
2000
|
* Kept: an entry whose NAME is a plain identifier and whose VALUE is none of the above; an `unset`
|
|
2005
|
-
* entry carries nothing
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2001
|
+
* entry carries nothing, and its name is added to `cleared` when the caller passes one (tmux keeps
|
|
2002
|
+
* such an entry, and it masks the variable: a session's `unset PATH;` gives its panes no PATH). An
|
|
2003
|
+
* entry is left out only once it is framed whole (its name, its value to the closing quote, the
|
|
2004
|
+
* exact `; export NAME;` tail); its name is added to `omitted` when the caller passes one. So a
|
|
2005
|
+
* name is in the result, in `cleared`, in `omitted`, or absent from the text. null for bytes that
|
|
2006
|
+
* are not valid UTF-8 or that this grammar does not consume to the end: a caller refuses, it never
|
|
2007
|
+
* uses a partial result.
|
|
2009
2008
|
*/
|
|
2010
|
-
export function parseTmuxShellEnvironment(bytes, omitted) {
|
|
2009
|
+
export function parseTmuxShellEnvironment(bytes, omitted, cleared) {
|
|
2011
2010
|
let text;
|
|
2012
2011
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; }
|
|
2013
2012
|
const env = {};
|
|
@@ -2017,6 +2016,7 @@ export function parseTmuxShellEnvironment(bytes, omitted) {
|
|
|
2017
2016
|
if (take("unset ")) {
|
|
2018
2017
|
const end = text.indexOf(";", i);
|
|
2019
2018
|
if (end <= i || /[\s"=]/.test(text.slice(i, end))) return null;
|
|
2019
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text.slice(i, end))) cleared?.push(text.slice(i, end));
|
|
2020
2020
|
i = end + 1;
|
|
2021
2021
|
} else {
|
|
2022
2022
|
const assign = text.indexOf("=\"", i);
|
|
@@ -2074,11 +2074,19 @@ function callerInstance() {
|
|
|
2074
2074
|
function creatorTmux(session) {
|
|
2075
2075
|
const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server: ${why}, and the tmux that creates a session is the one this process's own PATH finds; the session was not created. Run this command with a PATH that holds tmux`);
|
|
2076
2076
|
if (process.env.PATH === undefined) throw refuse("PATH is not set in this process");
|
|
2077
|
-
|
|
2078
|
-
|
|
2077
|
+
const found = lookupOnPath("tmux", process.env.PATH);
|
|
2078
|
+
if (found) return found;
|
|
2079
|
+
throw refuse("no tmux was found on this process's PATH");
|
|
2080
|
+
}
|
|
2081
|
+
/** `name` as an exec looks it up on `path`: the first entry, in order, that holds a regular
|
|
2082
|
+
* executable file of that name, as an absolute path; null when none does. A relative entry, and
|
|
2083
|
+
* an empty one (an empty PATH is one empty entry), are `cwd`. Nothing is run. */
|
|
2084
|
+
function lookupOnPath(name, path, cwd = process.cwd()) {
|
|
2085
|
+
for (const dir of path.split(":")) {
|
|
2086
|
+
const candidate = resolve(cwd, dir || ".", name);
|
|
2079
2087
|
try { accessSync(candidate, fsConstants.X_OK); if (statSync(candidate).isFile()) return candidate; } catch { /* not here */ }
|
|
2080
2088
|
}
|
|
2081
|
-
|
|
2089
|
+
return null;
|
|
2082
2090
|
}
|
|
2083
2091
|
/**
|
|
2084
2092
|
* The environment a `new-session` on the OATS server runs with: the only tmux call that can start
|
|
@@ -2087,80 +2095,188 @@ function creatorTmux(session) {
|
|
|
2087
2095
|
* every pane created on it later inherits, and a new session takes the client's variables that
|
|
2088
2096
|
* `update-environment` names.
|
|
2089
2097
|
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2098
|
+
* When no server answered the lookup (`startsServer`), so that this new-session starts it, the
|
|
2099
|
+
* environment is the user's login environment (lib/login-environment.mjs), for every creator: what
|
|
2100
|
+
* the login shell from the password database sets up, from a seed that holds nothing of the creator
|
|
2101
|
+
* but its locale and, when it is no instance, its session variables (an instance's come from the
|
|
2102
|
+
* server its home records, then from the platform's user session). The kernel's names and the
|
|
2103
|
+
* instance-identity names are removed from what it answers, and the operator's OATS configuration
|
|
2104
|
+
* (OATS_ and PI_AGENTS_ names that are not the kernel's) is the creator's (an instance's: its recorded
|
|
2105
|
+
* server's). When it cannot be read (a creator whose HOME is not the user's home directory, a
|
|
2106
|
+
* timeout, a non-zero exit, a partial or malformed answer, no HOME or PATH, a shell that is not bash,
|
|
2107
|
+
* zsh or fish), one line on stderr says so and names the fallback, never a value, and the creator falls
|
|
2108
|
+
* back to what follows. That fallback is degraded: neither a login environment nor a working agent
|
|
2109
|
+
* socket is assured.
|
|
2110
|
+
*
|
|
2111
|
+
* Otherwise (the server runs, or the login environment could not be read): a process inside an OATS
|
|
2112
|
+
* instance never passes its own environment (its harness's variables, its credentials, its
|
|
2113
|
+
* identity). It passes the global environment of the tmux server its home records: an existing
|
|
2114
|
+
* baseline, chosen because a window that instance opened on that server got exactly it; not proof
|
|
2115
|
+
* that it holds nothing old. The home's receipt is checked against instance.json first, as every
|
|
2116
|
+
* session verb checks its endpoint, and the text is read with the strict reader above, which can
|
|
2117
|
+
* leave values out. When any of that fails, or no home is identified, the session is not created:
|
|
2118
|
+
* no fallback to the caller's environment, to another server or to a built-in list. Any other
|
|
2119
|
+
* creator (an operator's shell, a schedule runner, the Desktop) passes its own environment. In
|
|
2120
|
+
* every case the kernel's names are removed from the final set.
|
|
2099
2121
|
*
|
|
2100
2122
|
* Environment and endpoint are chosen separately: which server is reached depends only on the
|
|
2101
|
-
* creator (its TMUX_TMPDIR, kept or absent as the creator has it
|
|
2102
|
-
* process gets (HOME and so its configuration file, PATH,
|
|
2103
|
-
* environment, with no ambient value filling in. Values
|
|
2104
|
-
* tmux process: never in an argument, a message, an event or
|
|
2105
|
-
* owns the rule.
|
|
2123
|
+
* creator (its TMUX_TMPDIR, kept or absent as the creator has it, whatever the selected environment
|
|
2124
|
+
* says; its tmux); what the server process gets (HOME and so its configuration file, PATH,
|
|
2125
|
+
* everything else) comes from the selected environment, with no ambient value filling in. Values
|
|
2126
|
+
* travel only as the environment of that tmux process: never in an argument, a message, an event or
|
|
2127
|
+
* a file. docs/execution-targets.md owns the rule.
|
|
2106
2128
|
*/
|
|
2107
2129
|
const TMUX_SERVER_START_ENV = ["HOME", "XDG_CONFIG_HOME", "PATH", "SHELL"];
|
|
2108
|
-
|
|
2130
|
+
/** The operator's configuration names, carried into a server started with the login environment
|
|
2131
|
+
* (the kernel's own names among them are removed first). */
|
|
2132
|
+
const OPERATOR_CONFIG_ENV = /^(OATS_|PI_AGENTS_)/;
|
|
2133
|
+
function oatsSessionEnvironment(session, io, { startsServer = false } = {}) {
|
|
2109
2134
|
const caller = callerInstance();
|
|
2135
|
+
let recorded;
|
|
2136
|
+
const recordedServer = () => (recorded ??= recordedServerEnvironment(caller, session, io));
|
|
2137
|
+
const creatorsTmpdir = (env) => { if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR; return env; };
|
|
2138
|
+
if (startsServer) {
|
|
2139
|
+
const login = captureLoginEnvironment({ creatorEnv: process.env, instance: !!caller, recorded: caller ? recordedServer().read : null, shell: process.env.OATS_TEST_LOGIN_SHELL || undefined });
|
|
2140
|
+
if (login.env) {
|
|
2141
|
+
for (const note of login.notes) process.stderr.write(`oats: warning: ${note}\n`);
|
|
2142
|
+
const env = withoutKernelEnvironment(login.env);
|
|
2143
|
+
for (const name of INSTANCE_IDENTITY_ENV) delete env[name];
|
|
2144
|
+
// The operator's own OATS configuration (OATS_HOME_DIR, OATS_TMUX_SESSION, OATS_PACKAGE_CATALOG,
|
|
2145
|
+
// any other OATS_ or PI_AGENTS_ name that is not the kernel's) stays the creator's, over what
|
|
2146
|
+
// the login shell set: a creator outside every instance gives its own; an instance, what the
|
|
2147
|
+
// server its home records has, never its own.
|
|
2148
|
+
const configured = caller ? recordedServer().read : process.env;
|
|
2149
|
+
for (const [name, value] of Object.entries(withoutKernelEnvironment(configured ?? {}))) if (OPERATOR_CONFIG_ENV.test(name)) env[name] = value;
|
|
2150
|
+
return creatorsTmpdir(env);
|
|
2151
|
+
}
|
|
2152
|
+
const fallback = !caller ? "this process's own environment" : recordedServer().error ? null : "a copy of the environment of the tmux server this instance's home records";
|
|
2153
|
+
process.stderr.write(`oats: warning: could not read your login environment (${login.failure}); ${fallback ? `the OATS tmux server is started with ${fallback} instead: a fallback, neither a login environment nor a working agent socket is assured` : "and there is no fallback from inside an instance"}\n`);
|
|
2154
|
+
}
|
|
2110
2155
|
if (!caller) return withoutKernelEnvironment(process.env);
|
|
2156
|
+
const { env, error } = recordedServer();
|
|
2157
|
+
if (error) throw error;
|
|
2158
|
+
return creatorsTmpdir(env);
|
|
2159
|
+
}
|
|
2160
|
+
/** The global environment of the tmux server an instance's home records, read strictly: `read` (what
|
|
2161
|
+
* the reader kept, or null), and `env` (a copy without the kernel's names) or `error` (the refusal
|
|
2162
|
+
* a creation from inside an instance gets when it cannot use it). */
|
|
2163
|
+
function recordedServerEnvironment(caller, session, io) {
|
|
2111
2164
|
const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server from inside an OATS instance: ${why}. A tmux server and its sessions keep the environment of the process that creates them, and an instance's own environment must not become theirs; the session was not created. Create it from your own shell, outside every instance home (tmux -L ${OATS_TMUX_SERVER} new-session -d -s ${shellWord(session)} -n hq), or run this command there. tmux resolves -L ${OATS_TMUX_SERVER} with TMUX_TMPDIR: use the same TMUX_TMPDIR as this process, if it has one`);
|
|
2112
|
-
if (!caller.home)
|
|
2165
|
+
if (!caller.home) return { read: null, error: refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`) };
|
|
2113
2166
|
let target;
|
|
2114
2167
|
try { target = instanceSessionTarget(caller.home).target; }
|
|
2115
|
-
catch (e) { if (!oatsCoded(e)) throw e;
|
|
2116
|
-
if (!target?.socket)
|
|
2168
|
+
catch (e) { if (!oatsCoded(e)) throw e; return { read: null, error: refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`) }; }
|
|
2169
|
+
if (!target?.socket) return { read: null, error: refuse(`the home of ${basename(caller.home)} records no tmux server`) };
|
|
2117
2170
|
// Bytes, not text; and nothing of a failed read (tmux's output, its error) goes into the refusal.
|
|
2118
|
-
let
|
|
2171
|
+
let read = null;
|
|
2119
2172
|
const omitted = [];
|
|
2120
2173
|
try {
|
|
2121
2174
|
const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", target.socket, "show-environment", "-g", "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
2122
|
-
|
|
2175
|
+
read = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
|
|
2123
2176
|
} catch (e) { if (oatsCoded(e)) throw e; }
|
|
2124
|
-
if (
|
|
2177
|
+
if (read === null) return { read, error: refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`) };
|
|
2125
2178
|
// A variable the reader left out is never filled in from this process, and a server is never
|
|
2126
2179
|
// started when the one left out decides which configuration it loads or which programs it runs.
|
|
2127
2180
|
// (A recorded server that simply has none of them is copied as it is.)
|
|
2128
2181
|
const undecided = TMUX_SERVER_START_ENV.find((name) => omitted.includes(name));
|
|
2129
|
-
if (undecided)
|
|
2130
|
-
|
|
2131
|
-
if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR;
|
|
2132
|
-
return env;
|
|
2182
|
+
if (undecided) return { read, error: refuse(`the tmux server that ${basename(caller.home)} is recorded on has a ${undecided} that cannot be carried over (its value holds a $, a line break or a character tmux prints encoded), and a tmux server's configuration and programs are chosen by it`) };
|
|
2183
|
+
return { read, env: withoutKernelEnvironment(read) };
|
|
2133
2184
|
}
|
|
2134
2185
|
/** The environment a `new-window` client runs with, and the `respawn-pane` client of a restart in
|
|
2135
|
-
* place
|
|
2136
|
-
* nothing else
|
|
2137
|
-
*
|
|
2138
|
-
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2186
|
+
* place, for every creator (an instance, the Desktop, an operator's shell): the three names tmux's
|
|
2187
|
+
* client reads its locale from, and nothing else. tmux's client does not start without a UTF-8
|
|
2188
|
+
* locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one; tmux gives
|
|
2189
|
+
* the pane none of them. A pane's environment is its session's over the server's global one
|
|
2190
|
+
* (spawn.c environ_for_session), PATH included: tmux puts the client's PATH in its place only when
|
|
2191
|
+
* the client has one, and this client has none. So a PATH and the state that interprets it (a
|
|
2192
|
+
* version manager's bookkeeping, `__MISE_DIFF`, nvm's) come from one environment, the server's or
|
|
2193
|
+
* the session's; OATS no longer overlays a creator's PATH onto it. That does not make an existing
|
|
2194
|
+
* or external server's environment, a session override or a launch configuration's PATH coherent:
|
|
2195
|
+
* OATS uses them as they are. The client is run by an absolute tmux (tmuxOn), since it has no PATH
|
|
2196
|
+
* to look one up with. docs/execution-targets.md owns the rule. */
|
|
2144
2197
|
const TMUX_CLIENT_LOCALE_ENV = ["LANG", "LC_ALL", "LC_CTYPE"];
|
|
2145
2198
|
function oatsWindowEnvironment() {
|
|
2146
|
-
if (!callerInstance()) return withoutKernelEnvironment(process.env);
|
|
2147
2199
|
const env = {};
|
|
2148
|
-
const dirs = process.env.PATH === undefined ? [] : process.env.PATH.split(":").filter((dir) => !/(?:^|\/)instances\/[^/]+\/\.oats\/bin\/?$/.test(dir));
|
|
2149
|
-
if (dirs.length) env.PATH = dirs.join(":");
|
|
2150
2200
|
for (const name of TMUX_CLIENT_LOCALE_ENV) if (process.env[name] !== undefined) env[name] = process.env[name];
|
|
2151
2201
|
return env;
|
|
2152
2202
|
}
|
|
2153
2203
|
/**
|
|
2154
|
-
*
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2157
|
-
*
|
|
2158
|
-
*
|
|
2159
|
-
* ensureOatsTmuxSession decides by what it finds when it creates
|
|
2204
|
+
* What a spawn or a start knows, BEFORE it creates, stops or writes anything, about the session it
|
|
2205
|
+
* will open a window in: `{ server, present }` from the lookup and, when `session` is not on the
|
|
2206
|
+
* OATS server and this is no preview, `env`: the environment its creation needs, read now, so that a
|
|
2207
|
+
* caller that cannot create it is refused with nothing left behind; the answer goes to
|
|
2208
|
+
* ensureOatsTmuxSession. It is also where the harness is first looked up (expectedPanePath). Not the
|
|
2209
|
+
* guarantee: ensureOatsTmuxSession decides by what it finds when it creates, and the harness is
|
|
2210
|
+
* looked up again on the session it returns; and an environment read for a running server is read
|
|
2211
|
+
* again there when that server has gone. undefined when this process has no tmux to run: the
|
|
2212
|
+
* caller's backend check refuses that.
|
|
2213
|
+
*/
|
|
2214
|
+
function planOatsTmuxSession(session, io, { preview = false } = {}) {
|
|
2215
|
+
if (!io?.exec && !which("tmux")) return undefined;
|
|
2216
|
+
let server, present;
|
|
2217
|
+
try { ({ server, present } = oatsTmuxSessionSocket(session, io)); }
|
|
2218
|
+
catch (e) { if (preview) return undefined; throw e; } // a preview reports, it does not refuse on a read
|
|
2219
|
+
if (present || preview) return { server, present };
|
|
2220
|
+
return { server, present, env: oatsSessionEnvironment(session, io, { startsServer: !server }), startsServer: !server };
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* The PATH a pane created in `session` on `socket` starts with, read as tmux decides it (spawn.c,
|
|
2224
|
+
* environ.c), never computed from this process: the session's own entry when it has one, otherwise
|
|
2225
|
+
* the server's global PATH. `session` null reads the global one only (a session about to be created
|
|
2226
|
+
* on a running server). Both are read with the strict reader, as bytes.
|
|
2227
|
+
* → `{ path, source }`, or `{ source, problem }` when the pane's PATH cannot be established: the
|
|
2228
|
+
* session entry is an explicit clear (`unset PATH;`: the pane has no PATH at all; never substituted
|
|
2229
|
+
* by the global one or this process's), a value the strict reader leaves out (unknown), a read that
|
|
2230
|
+
* failed, or a server with no PATH (tmux would fall back to a compiled-in default, which OATS does not
|
|
2231
|
+
* assume). `source` names where the PATH comes from; no value is ever in it or in `problem`.
|
|
2160
2232
|
*/
|
|
2161
|
-
function
|
|
2162
|
-
|
|
2163
|
-
|
|
2233
|
+
function panePath(socket, session, io, cwd) {
|
|
2234
|
+
const read = (scope) => {
|
|
2235
|
+
const omitted = [], cleared = [];
|
|
2236
|
+
let env = null;
|
|
2237
|
+
try {
|
|
2238
|
+
const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, "show-environment", ...scope, "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
2239
|
+
env = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted, cleared);
|
|
2240
|
+
} catch (e) { if (oatsCoded(e)) throw e; }
|
|
2241
|
+
return { env, omitted, cleared };
|
|
2242
|
+
};
|
|
2243
|
+
const decide = ({ env, omitted, cleared }, source, unset) => {
|
|
2244
|
+
if (env === null) return { source, problem: `${source} could not be read` };
|
|
2245
|
+
if (Object.hasOwn(env, "PATH")) return { path: env.PATH, source, cwd };
|
|
2246
|
+
if (cleared.includes("PATH")) return { source, problem: unset };
|
|
2247
|
+
if (omitted.includes("PATH")) return { source, problem: `${source} cannot be read as data (its value holds a $, a line break or a character tmux prints encoded)` };
|
|
2248
|
+
return null;
|
|
2249
|
+
};
|
|
2250
|
+
if (session) {
|
|
2251
|
+
const own = decide(read(["-t", `=${session}`]), `the PATH of tmux session ${session}`, `tmux session ${session} clears PATH (unset PATH), so its panes start with no PATH at all`);
|
|
2252
|
+
if (own) return own;
|
|
2253
|
+
}
|
|
2254
|
+
const source = `the global PATH of the OATS tmux server (${socket})`;
|
|
2255
|
+
return decide(read(["-g"]), source, `the OATS tmux server (${socket}) clears PATH (unset PATH), so its panes start with no PATH at all`)
|
|
2256
|
+
?? { source, problem: `the OATS tmux server (${socket}) has no PATH, and tmux then gives a pane a compiled-in default that OATS does not assume` };
|
|
2257
|
+
}
|
|
2258
|
+
/** Where a launch's pane is expected to look its harness up, from a plan (planOatsTmuxSession): the
|
|
2259
|
+
* session's or the server's PATH when a server runs, else the PATH of the environment the server
|
|
2260
|
+
* will be created with. undefined when that is not known (no plan, or a preview with no server: a
|
|
2261
|
+
* preview reads no creation environment); the lookup then uses this process's PATH, and the launch
|
|
2262
|
+
* looks again on the session it actually gets. */
|
|
2263
|
+
function expectedPanePath(plan, session, io, cwd) {
|
|
2264
|
+
if (!plan) return undefined;
|
|
2265
|
+
if (plan.server) return panePath(plan.server, plan.present ? session : null, io, cwd);
|
|
2266
|
+
if (!plan.env) return undefined;
|
|
2267
|
+
const source = "the PATH the OATS tmux server is created with";
|
|
2268
|
+
return plan.env.PATH === undefined ? { source, problem: `${source} is not set, and tmux then gives a pane a compiled-in default that OATS does not assume` } : { path: plan.env.PATH, source, cwd };
|
|
2269
|
+
}
|
|
2270
|
+
/** The PATH a launch's harness is looked up on, by precedence: a PATH set in the launch
|
|
2271
|
+
* configuration (a literal, or a reference resolved from `env`) is what the pane's command line
|
|
2272
|
+
* applies, so the lookup uses it; otherwise the pane's own PATH (`pane`, from panePath or
|
|
2273
|
+
* expectedPanePath; a function is called only when it is needed). A declared absolute or relative
|
|
2274
|
+
* executable is never looked up (resolveLaunchExecutable). undefined: look up on this process's PATH. */
|
|
2275
|
+
function launchLookup(configEnv, env, pane, configName, cwd) {
|
|
2276
|
+
const own = configEnv?.PATH;
|
|
2277
|
+
if (typeof own === "string") return { path: own, source: `the PATH of launch configuration ${configName}`, cwd };
|
|
2278
|
+
if (own && typeof own === "object" && own.fromEnv) return env[own.fromEnv] === undefined ? undefined : { path: env[own.fromEnv], source: `the PATH of launch configuration ${configName} (from ${own.fromEnv})`, cwd };
|
|
2279
|
+
return typeof pane === "function" ? pane() : pane;
|
|
2164
2280
|
}
|
|
2165
2281
|
/**
|
|
2166
2282
|
* Make sure `session` exists on the OATS server and return that server's absolute socket: the one
|
|
@@ -2182,7 +2298,9 @@ export function ensureOatsTmuxSession(session, hq, io, plan) {
|
|
|
2182
2298
|
// The executable is chosen here, where the creation is about to run, and nowhere earlier: a
|
|
2183
2299
|
// session that exists needs none, so nothing is refused for it.
|
|
2184
2300
|
const tmux = creatorTmux(session);
|
|
2185
|
-
|
|
2301
|
+
// The plan's environment, unless it was read for a running server that has gone since: a server
|
|
2302
|
+
// this call starts gets what a server start gets.
|
|
2303
|
+
const env = plan?.env && (plan.startsServer || socket) ? plan.env : oatsSessionEnvironment(session, io, { startsServer: !socket });
|
|
2186
2304
|
const address = socket ? ["-S", socket] : ["-L", OATS_TMUX_SERVER];
|
|
2187
2305
|
let created;
|
|
2188
2306
|
try { created = (io?.exec || execFileSync)(tmux, ["-u", ...address, "new-session", "-d", "-s", session, "-n", "hq", "-c", hq, "-P", "-F", "#{socket_path}\t#{window_id}"], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], env }).trim(); }
|
|
@@ -2469,19 +2587,31 @@ const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT
|
|
|
2469
2587
|
/** The executable a launch uses: a configuration's declared one (a bare name
|
|
2470
2588
|
* on PATH; a path against the deployment directory when relative) or the
|
|
2471
2589
|
* harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
|
|
2472
|
-
* of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares.
|
|
2473
|
-
|
|
2590
|
+
* of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares.
|
|
2591
|
+
* `lookup` (launchLookup: `{ path, source, cwd }` or `{ source, problem }`) is the PATH a bare name is
|
|
2592
|
+
* looked up on, the one the pane runs it with; without one, this process's PATH. A result looked up
|
|
2593
|
+
* there carries `lookup`, so a refusal can name where it looked. */
|
|
2594
|
+
export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir, lookup }) {
|
|
2474
2595
|
if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
|
|
2475
|
-
if (declared) {
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2596
|
+
if (declared?.includes("/")) {
|
|
2597
|
+
const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
|
|
2598
|
+
return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
|
|
2599
|
+
}
|
|
2600
|
+
const name = declared || harness;
|
|
2601
|
+
if (lookup) {
|
|
2602
|
+
const found = lookup.problem ? null : lookupOnPath(name, lookup.path, lookup.cwd);
|
|
2603
|
+
return { path: found, declared: declared || null, resolvedFrom: "PATH", lookup, missing: found ? undefined : lookup.problem ?? `${name} was not found on ${lookup.source}` };
|
|
2604
|
+
}
|
|
2605
|
+
const found = which(name);
|
|
2606
|
+
return { path: found || null, declared: declared || null, resolvedFrom: "PATH", missing: found ? undefined : `${name} binary not found on PATH` };
|
|
2607
|
+
}
|
|
2608
|
+
/** The refusal for an executable a launch looked up where its pane looks it up (`executable.lookup`)
|
|
2609
|
+
* and did not find, or could not look up: E_HARNESS_UNAVAILABLE for the harness's own name,
|
|
2610
|
+
* E_LAUNCH_EXECUTABLE for a configuration's declared name. Names the source and the remedies, never
|
|
2611
|
+
* the PATH's contents. */
|
|
2612
|
+
function executableNotOnPanePath(executable, { harness, config, from, at }) {
|
|
2613
|
+
if (!executable.declared) return harnessNotOnPanePath({ harness, from, at, why: executable.missing });
|
|
2614
|
+
return oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name}: ${executable.missing}; ${PANE_PATH_FIX}`);
|
|
2485
2615
|
}
|
|
2486
2616
|
/** null when `path` is a regular executable file; otherwise why not. */
|
|
2487
2617
|
export function checkLaunchExecutable(path) {
|
|
@@ -2816,7 +2946,7 @@ function requirementsWithArgsMessage(harness, providers, config) {
|
|
|
2816
2946
|
* and not at all for a preview. A home
|
|
2817
2947
|
* that records no launch recipe (an earlier kernel spawned it) is not planned:
|
|
2818
2948
|
* E_LAUNCH_LEGACY, re-spawn it from the deployment. */
|
|
2819
|
-
export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null }) {
|
|
2949
|
+
export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null, panePath: expectedPane }) {
|
|
2820
2950
|
assertRoots?.();
|
|
2821
2951
|
const problems = [];
|
|
2822
2952
|
const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
|
|
@@ -2842,16 +2972,23 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2842
2972
|
// the default no longer decides (a bare --launch-config none, another harness).
|
|
2843
2973
|
const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
|
|
2844
2974
|
const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
|
|
2975
|
+
// A frozen recipe keeps its recorded path. Otherwise a bare name is looked up where the pane will
|
|
2976
|
+
// look it up (launchLookup: the configuration's PATH, else `expectedPane`, the start's expected
|
|
2977
|
+
// session or server PATH; a start looks again on the session it gets).
|
|
2845
2978
|
const executable = config?.frozen
|
|
2846
2979
|
? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
|
|
2847
|
-
: resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
|
|
2848
|
-
|
|
2980
|
+
: resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir, lookup: config?.executable?.includes("/") ? undefined : launchLookup(config?.env, env, expectedPane, config?.name, home) });
|
|
2981
|
+
const notOnPane = !executable.path && executable.lookup ? executableNotOnPanePath(executable, { harness, config, from: launchChoice?.from, at: launchChoice?.at }) : null;
|
|
2982
|
+
if (notOnPane) {
|
|
2983
|
+
if (!preview) throw notOnPane;
|
|
2984
|
+
problems.push({ check: "executable", ok: false, detail: notOnPane.message, code: notOnPane.code });
|
|
2985
|
+
} else if (!executable.path && !config?.executable && launchChoice) {
|
|
2849
2986
|
const e = harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing });
|
|
2850
2987
|
if (!preview) throw e;
|
|
2851
2988
|
problems.push({ check: "executable", ok: false, detail: e.message, code: e.code });
|
|
2852
2989
|
}
|
|
2853
|
-
const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : !config?.executable && launchChoice ? null : executable.missing;
|
|
2854
|
-
if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
|
|
2990
|
+
const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : notOnPane || (!config?.executable && launchChoice) ? null : executable.missing;
|
|
2991
|
+
if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else if (!notOnPane) problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
|
|
2855
2992
|
// Capability contributions: recorded at spawn with provenance; a harness
|
|
2856
2993
|
// switch needs the new harness's launch args from the same capabilities.
|
|
2857
2994
|
let hooks = { launch: {}, env: {}, contributions: [], pending: true };
|
|
@@ -3106,10 +3243,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3106
3243
|
if (holder) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken in this deployment (${holder}); instance names are unique across every soul — pick another --name`), { instance, home: holder });
|
|
3107
3244
|
if (oatsTmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
|
|
3108
3245
|
}
|
|
3109
|
-
// A caller
|
|
3110
|
-
//
|
|
3111
|
-
//
|
|
3112
|
-
|
|
3246
|
+
// A caller that will have to create the tmux session reads the environment for it here, before
|
|
3247
|
+
// any scaffold, work tree, identity or hook, so that an instance that cannot is refused with
|
|
3248
|
+
// nothing left behind; the harness is first looked up on the PATH this plan expects (below). A
|
|
3249
|
+
// preview reads only what exists.
|
|
3250
|
+
const tmuxSessionPlan = launch ? planOatsTmuxSession(session, undefined, { preview: o.preview === true }) : undefined;
|
|
3113
3251
|
|
|
3114
3252
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
3115
3253
|
// anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
|
|
@@ -3393,7 +3531,12 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3393
3531
|
// reconciliation, so this spawn-time check is the authoritative one.
|
|
3394
3532
|
|
|
3395
3533
|
// Prerequisites must fail before creating a home, worktree, or identity.
|
|
3396
|
-
|
|
3534
|
+
// A launched harness is looked up where its pane will look it up (launchLookup): this is the
|
|
3535
|
+
// preflight against the expected PATH; the launch looks again on the session it gets. Without a
|
|
3536
|
+
// launch (or a known destination) the lookup is this process's PATH, as before.
|
|
3537
|
+
const lookupFor = (pane) => launch && !launchConfig?.executable?.includes("/") ? launchLookup(launchConfig?.env, process.env, pane, launchConfig?.name, home) : undefined;
|
|
3538
|
+
const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs, lookup: lookupFor(() => expectedPanePath(tmuxSessionPlan, session, undefined, home)) });
|
|
3539
|
+
if (!executable.path && executable.lookup) { previewPreflightBudget = null; throw executableNotOnPanePath(executable, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at }); }
|
|
3397
3540
|
// No executable for the chosen harness (none declared by a configuration): E_HARNESS_UNAVAILABLE names the
|
|
3398
3541
|
// layer that chose it and the fix. Never a fallback to another harness.
|
|
3399
3542
|
if (!executable.path && !launchConfig?.executable) { previewPreflightBudget = null; throw harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing }); }
|
|
@@ -3902,7 +4045,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3902
4045
|
assertDirectoryRoots(home, homeReal);
|
|
3903
4046
|
const path = join(home, "work");
|
|
3904
4047
|
if (!readdirSync(path).length) return;
|
|
3905
|
-
const directoryFingerprint =
|
|
4048
|
+
const directoryFingerprint = directoryBytes(path);
|
|
3906
4049
|
if (lastDirectoryFingerprint === directoryFingerprint) return;
|
|
3907
4050
|
const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
|
|
3908
4051
|
directoryRecoveries.push(receipt.path);
|
|
@@ -4034,7 +4177,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
4034
4177
|
if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
|
|
4035
4178
|
const folderTrust = launchFolderTrust({ harness, home, meta: { workspace: { deployment: o.prepared?.deployment } }, yolo });
|
|
4036
4179
|
if (folderTrust.warning) warnings.push(folderTrust.warning);
|
|
4037
|
-
|
|
4180
|
+
let cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
|
|
4038
4181
|
|
|
4039
4182
|
// Module skills as materialize landed them (flat, .agents/skills/<skill>/),
|
|
4040
4183
|
// beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
|
|
@@ -4133,11 +4276,26 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
4133
4276
|
|
|
4134
4277
|
spawnTmux = meta.tmux;
|
|
4135
4278
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
4136
|
-
|
|
4279
|
+
let executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
|
|
4137
4280
|
if (launch) {
|
|
4138
4281
|
// The session on the OATS server, then everything on the one socket that answered: the collision
|
|
4139
4282
|
// check, the records, the window and (in compensateSpawn) its removal.
|
|
4140
4283
|
const socket = ensureOatsTmuxSession(session, existsSync(root) ? root : workspaceOf(root), undefined, tmuxSessionPlan);
|
|
4284
|
+
// The harness looked up again on the PATH this window's pane will actually start with, whoever
|
|
4285
|
+
// created the session and with whatever environment (a race lost to another creator, a session
|
|
4286
|
+
// that overrides PATH). Not there: refused, and the spawn is compensated. Found elsewhere than
|
|
4287
|
+
// the preflight found it: the launch runs that one.
|
|
4288
|
+
if (executable.lookup) {
|
|
4289
|
+
const actual = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, lookup: lookupFor(() => panePath(socket, session, undefined, home)) });
|
|
4290
|
+
if (!actual.path) throw executableNotOnPanePath(actual, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at });
|
|
4291
|
+
const bad = checkLaunchExecutable(actual.path);
|
|
4292
|
+
if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(harness default)"}: ${bad}`);
|
|
4293
|
+
if (actual.path !== recipe.executable) {
|
|
4294
|
+
recipe.executable = actual.path;
|
|
4295
|
+
meta.command = cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
|
|
4296
|
+
executionCommand = nativeRecordCommand(cmdline, home, harness);
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4141
4299
|
let present;
|
|
4142
4300
|
try { present = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
|
|
4143
4301
|
catch (e) { throw new Error(`could not list the windows of tmux session ${session} on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`); }
|
|
@@ -4692,43 +4850,147 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
|
4692
4850
|
|
|
4693
4851
|
/** Kernel-owned receipts the kernel itself appends to a home after the spawn
|
|
4694
4852
|
* baseline (events log, stop/restart receipts). They are evidence about the
|
|
4695
|
-
* instance, not the instance's work, so
|
|
4696
|
-
* otherwise every stop or event write would
|
|
4853
|
+
* instance, not the instance's work, so the stored digest a spawn baseline is
|
|
4854
|
+
* compared with passes over them — otherwise every stop or event write would
|
|
4855
|
+
* read as "changed home bytes". The home copy carries them, so the exact
|
|
4856
|
+
* digest, which says whether the retire hooks moved the home, holds them.
|
|
4857
|
+
* A new kernel-written top-level home entry, whether or not it belongs here,
|
|
4858
|
+
* MUST be added in the same change to the names a capability may not declare
|
|
4859
|
+
* as its own (`retirement.disposable.home`: KERNEL_HOME_NAMES and
|
|
4860
|
+
* KERNEL_HOME_NAME_PREFIXES in lib/capability-contract.mjs, and the schema). */
|
|
4697
4861
|
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
4698
4862
|
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
4699
4863
|
/** Harness project settings in the home are configuration, not work: the
|
|
4700
4864
|
* home's `.claude/` is harness layout the kernel already shapes (the skills
|
|
4701
4865
|
* alias), and capabilities keep their own entries current in its
|
|
4702
|
-
* settings.json at every launch.
|
|
4703
|
-
*
|
|
4866
|
+
* settings.json at every launch. The stored digest passes over exactly that
|
|
4867
|
+
* path, home-relative; the exact digest holds it, as the home copy does. */
|
|
4704
4868
|
const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
|
|
4705
|
-
|
|
4706
|
-
|
|
4869
|
+
/** The refusal for an entry that is not a file, a directory or a symbolic link
|
|
4870
|
+
* (a socket, a FIFO, a device): it has no bytes to read or to copy. One
|
|
4871
|
+
* sentence for every caller of fingerprintTree, at spawn and at retire, so it
|
|
4872
|
+
* names neither. It does not say "remove": the entry may be a live endpoint. */
|
|
4873
|
+
const unsupportedEntry = (path) => oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type: it is not a file, a directory or a symbolic link, so it cannot be read or copied. Safely stop the process or resource that owns it, or move the entry elsewhere, before retrying`);
|
|
4874
|
+
const PATH_SEPARATOR_BYTES = Buffer.from(sep);
|
|
4875
|
+
/** The digests of a tree, from one walk that reads each file once → { stored,
|
|
4876
|
+
* exact }; each only when asked for (`stored` by default).
|
|
4877
|
+
*
|
|
4878
|
+
* Both hash, for each entry in order, its path under the root, its
|
|
4879
|
+
* permission bits, its kind, and a file's bytes or a link's target. Entry
|
|
4880
|
+
* names and link targets are read and hashed as bytes, never as decoded
|
|
4881
|
+
* text: two trees that differ only in bytes that are not UTF-8 have two
|
|
4882
|
+
* digests, and an entry with such a name is read, not missed. This is about
|
|
4883
|
+
* what a digest tells apart. It does not make such a tree copyable:
|
|
4884
|
+
* copyTreeSafe reads entry names as text and cannot carry a file name that
|
|
4885
|
+
* is not UTF-8.
|
|
4886
|
+
*
|
|
4887
|
+
* A name's text (its bytes decoded as UTF-8, the way readdirSync decodes
|
|
4888
|
+
* them: replacement characters for bytes that are not UTF-8, a byte order
|
|
4889
|
+
* mark kept) is used only to order the entries and to test membership: the
|
|
4890
|
+
* names a digest passes over and `instance.json`, as they have always been
|
|
4891
|
+
* tested. Both digests pass over what a copy does not carry: `excludeRoot`
|
|
4892
|
+
* and, with `excludeGitMetadata`, `.git`. Only the stored digest also passes
|
|
4893
|
+
* over the kernel's receipts and the harness settings of a home.
|
|
4894
|
+
*
|
|
4895
|
+
* `stored` is the digest a spawn baseline holds, written by this kernel or
|
|
4896
|
+
* by an older one, so it never changes: its framing (each field followed by
|
|
4897
|
+
* NUL, no lengths), its order, and a home's `instance.json` without the
|
|
4898
|
+
* fields the kernel writes after spawn (kernelNeutralInstanceJson). For a
|
|
4899
|
+
* tree whose names and link targets are all valid UTF-8 it is what it was
|
|
4900
|
+
* when they were read as text: hashing a text hashes its UTF-8, which is
|
|
4901
|
+
* those bytes. It is what is compared with a baseline. Without lengths it
|
|
4902
|
+
* reads alike a file whose bytes spell the entry that follows it and those
|
|
4903
|
+
* two entries, and the neutral form reads alike two `instance.json` files
|
|
4904
|
+
* that parse to the same value.
|
|
4905
|
+
*
|
|
4906
|
+
* `exact` is for every comparison of a tree with itself later, or with its
|
|
4907
|
+
* copy: whether the retire hooks left it as it was. It is kept nowhere. It
|
|
4908
|
+
* holds every entry the copy carries: of a home, the kernel's receipts and
|
|
4909
|
+
* the harness settings too, which the copy carries and a retire hook can
|
|
4910
|
+
* write. Two different trees cannot give it the same stream: an entry is its
|
|
4911
|
+
* path, its mode, its kind and its content, where the path and the content
|
|
4912
|
+
* carry their length and the mode and the kind end in NUL, so the stream can
|
|
4913
|
+
* be read back into its entries in one way only, and a tree is the set of
|
|
4914
|
+
* its entries. A home's `instance.json` goes in as the bytes it has.
|
|
4915
|
+
*
|
|
4916
|
+
* The root's own permission bits, which are no entry of the walk, are in
|
|
4917
|
+
* the exact digest: a path the recovery copier copies is compared with its
|
|
4918
|
+
* own bits, because copyTreeSafe ends with a chmod of what it made.
|
|
4919
|
+
* `children` is for a root the copier creates itself (a home, a worktree):
|
|
4920
|
+
* its copy is made inside a directory the copier creates, so the root's bits
|
|
4921
|
+
* are no part of what it carries, and the root is compared through its
|
|
4922
|
+
* children alone. The stored digest never holds the root's bits. */
|
|
4923
|
+
function fingerprintTrees(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false, stored = true, exact = false, children = false } = {}) {
|
|
4924
|
+
const s = stored ? createHash("sha256") : null, x = exact ? createHash("sha256") : null;
|
|
4925
|
+
const digests = () => ({ stored: s ? `sha256:${s.digest("hex")}` : undefined, exact: x ? `sha256:${x.digest("hex")}` : undefined });
|
|
4926
|
+
/** A field of the exact digest whose length is not fixed: its length, NUL, its bytes. */
|
|
4927
|
+
const sized = (bytes) => { x.update(String(bytes.length)); x.update("\0"); x.update(bytes); };
|
|
4707
4928
|
const rootStat = lstatSync(root);
|
|
4708
4929
|
if (!rootStat.isDirectory()) {
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4930
|
+
const mode = String(rootStat.mode & 0o7777);
|
|
4931
|
+
s?.update(mode); s?.update("\0");
|
|
4932
|
+
x?.update("entry\0"); x?.update(mode); x?.update("\0");
|
|
4933
|
+
if (rootStat.isSymbolicLink()) { const target = readlinkSync(root, "buffer"); s?.update("link\0"); s?.update(target); if (x) { x.update("link\0"); sized(target); } }
|
|
4934
|
+
else if (rootStat.isFile()) { const bytes = readFileSync(root); s?.update("file\0"); s?.update(bytes); if (x) { x.update("file\0"); sized(bytes); } }
|
|
4935
|
+
else throw unsupportedEntry(root);
|
|
4936
|
+
return digests();
|
|
4937
|
+
}
|
|
4938
|
+
x?.update(children ? "tree\0" : `tree with its mode\0${rootStat.mode & 0o7777}\0`);
|
|
4939
|
+
// `dir` and `rel` are bytes: the directory, and its path under the root
|
|
4940
|
+
// (empty at the root). `relText` is `rel` as text, for the name tests.
|
|
4941
|
+
// `outer` is the stored hash, or null inside an entry it passes over.
|
|
4942
|
+
const walk = (dir, rel, relText, outer) => {
|
|
4943
|
+
// The order is the one it has always been: the names as text, compared
|
|
4944
|
+
// by localeCompare, on the listing as readdirSync returns it. The sort is
|
|
4945
|
+
// stable and the listing is the same whether its names come as text or
|
|
4946
|
+
// as bytes, so a tree whose names are all valid UTF-8 is walked in the
|
|
4947
|
+
// order it always was. Names that decode alike (two that are not UTF-8,
|
|
4948
|
+
// or one and a name that really holds the replacement character) keep
|
|
4949
|
+
// the listing's order. Where that order is not fixed, two reads of one
|
|
4950
|
+
// directory can differ, which adds a copy attempt; two different trees
|
|
4951
|
+
// cannot read alike for it, because the bytes of every name are hashed.
|
|
4952
|
+
const entries = readdirSync(dir, { encoding: "buffer" }).map((bytes) => ({ bytes, name: bytes.toString("utf8") }));
|
|
4953
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
4954
|
+
for (const { bytes: nameBytes, name } of entries) {
|
|
4955
|
+
if ((!relText && excludeRoot.has(name)) || (excludeGitMetadata && name === ".git")) continue;
|
|
4956
|
+
const childRelText = relText ? join(relText, name) : name;
|
|
4957
|
+
const storedPassesOver = instanceHome && ((!relText && (KERNEL_HOME_RECEIPTS.has(name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(name)))) || HARNESS_HOME_SETTINGS.has(childRelText));
|
|
4958
|
+
if (storedPassesOver && !x) continue;
|
|
4959
|
+
const s = storedPassesOver ? null : outer;
|
|
4960
|
+
const childRel = rel.length ? Buffer.concat([rel, PATH_SEPARATOR_BYTES, nameBytes]) : nameBytes;
|
|
4961
|
+
const path = Buffer.concat([dir, PATH_SEPARATOR_BYTES, nameBytes]);
|
|
4721
4962
|
const st = lstatSync(path);
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4963
|
+
const mode = String(st.mode & 0o7777);
|
|
4964
|
+
s?.update(childRel); s?.update("\0"); s?.update(mode); s?.update("\0");
|
|
4965
|
+
if (x) { sized(childRel); x.update(mode); x.update("\0"); }
|
|
4966
|
+
if (st.isSymbolicLink()) {
|
|
4967
|
+
const target = readlinkSync(path, "buffer");
|
|
4968
|
+
s?.update("link\0"); s?.update(target); s?.update("\0");
|
|
4969
|
+
if (x) { x.update("link\0"); sized(target); }
|
|
4970
|
+
} else if (st.isFile()) {
|
|
4971
|
+
const bytes = readFileSync(path);
|
|
4972
|
+
// Kernel-field neutrality applies ONLY when the tree IS an instance home, and only to the
|
|
4973
|
+
// stored digest; a work tree's instance.json is the agent's bytes.
|
|
4974
|
+
s?.update("file\0"); s?.update(instanceHome && !relText && name === "instance.json" ? kernelNeutralInstanceJson(bytes) : bytes); s?.update("\0");
|
|
4975
|
+
if (x) { x.update("file\0"); sized(bytes); }
|
|
4976
|
+
} else if (st.isDirectory()) { s?.update("dir\0"); x?.update("dir\0"); walk(path, childRel, childRelText, s); }
|
|
4977
|
+
else throw unsupportedEntry(join(root, childRelText));
|
|
4727
4978
|
}
|
|
4728
4979
|
};
|
|
4729
|
-
walk(root);
|
|
4730
|
-
return
|
|
4980
|
+
walk(Buffer.from(root), Buffer.alloc(0), "", s);
|
|
4981
|
+
return digests();
|
|
4982
|
+
}
|
|
4983
|
+
/** The stored digest of a tree (fingerprintTrees): what a spawn baseline holds and is compared with. */
|
|
4984
|
+
function fingerprintTree(root, options = {}) {
|
|
4985
|
+
return fingerprintTrees(root, { ...options, stored: true, exact: false }).stored;
|
|
4731
4986
|
}
|
|
4987
|
+
/** The exact digest of a tree (fingerprintTrees): what a tree is compared with itself later, or with its copy. */
|
|
4988
|
+
function exactTreeDigest(root, options = {}) {
|
|
4989
|
+
return fingerprintTrees(root, { ...options, stored: false, exact: true }).exact;
|
|
4990
|
+
}
|
|
4991
|
+
/** The bytes of a directory instance's work/, as their exact digest, with the
|
|
4992
|
+
* permission bits of work/ itself: what a copy of it carries. Kept nowhere. */
|
|
4993
|
+
const directoryBytes = (work) => exactTreeDigest(work);
|
|
4732
4994
|
|
|
4733
4995
|
/** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
|
|
4734
4996
|
* admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
|
|
@@ -4745,13 +5007,24 @@ function worktreeAdminMissing(work) {
|
|
|
4745
5007
|
if (!m) return true;
|
|
4746
5008
|
return !existsSync(join(resolve(work, m[1]), "HEAD"));
|
|
4747
5009
|
}
|
|
4748
|
-
|
|
5010
|
+
/** A repository's status as Git printed it, as bytes. A path that is not
|
|
5011
|
+
* UTF-8 is in there as it is: the work state compares these bytes
|
|
5012
|
+
* (repositoryGitState). Output that does not decode is not a failure; only
|
|
5013
|
+
* a `git status` that fails is. */
|
|
5014
|
+
function worktreeStatusBytes(repo) {
|
|
4749
5015
|
try {
|
|
4750
|
-
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], {
|
|
5016
|
+
return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
4751
5017
|
} catch (e) {
|
|
4752
5018
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
|
|
4753
5019
|
}
|
|
4754
5020
|
}
|
|
5021
|
+
/** The same status as text, for what reads its rows: the classes, the rows a
|
|
5022
|
+
* spawn baseline was taken from, the comparison of a copy with its source.
|
|
5023
|
+
* Bytes that are not UTF-8 read as replacement characters here, so this
|
|
5024
|
+
* text is never what proves a worktree unchanged. */
|
|
5025
|
+
function worktreeStatus(repo) {
|
|
5026
|
+
return worktreeStatusBytes(repo).toString("utf8");
|
|
5027
|
+
}
|
|
4755
5028
|
|
|
4756
5029
|
/** `git status --porcelain=v1 -z` as Map<path, XY>; a rename/copy row names its source (`R ← old`). */
|
|
4757
5030
|
function statusRows(z) {
|
|
@@ -4825,24 +5098,90 @@ function retirementDisposableRoots(work, capabilities) {
|
|
|
4825
5098
|
const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
|
|
4826
5099
|
function kernelNeutralInstanceJson(bytes) {
|
|
4827
5100
|
try {
|
|
4828
|
-
|
|
5101
|
+
// Bytes that are not UTF-8 would each read as the replacement character,
|
|
5102
|
+
// and two such files would then read alike: a file that does not decode
|
|
5103
|
+
// and encode back to the same bytes is hashed as it is, like one that is
|
|
5104
|
+
// not JSON.
|
|
5105
|
+
const text = String(bytes);
|
|
5106
|
+
if (!Buffer.from(text).equals(bytes)) return bytes;
|
|
5107
|
+
const m = JSON.parse(text);
|
|
4829
5108
|
if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
|
|
4830
5109
|
for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
|
|
4831
5110
|
return Buffer.from(JSON.stringify(m));
|
|
4832
5111
|
} catch { return bytes; }
|
|
4833
5112
|
}
|
|
5113
|
+
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
5114
|
+
/** The provider-owned home entries the active capabilities declare
|
|
5115
|
+
* (`retirement.disposable.home`, lib/capability-contract.mjs) → [{ owner, root }],
|
|
5116
|
+
* sorted by owner then root. Recorded in the spawn baseline: retirement reads
|
|
5117
|
+
* them from there and from nowhere else. */
|
|
5118
|
+
function retirementDisposableHome(capabilities) {
|
|
5119
|
+
const rows = new Map();
|
|
5120
|
+
for (const cap of capabilities || []) {
|
|
5121
|
+
for (const root of cap?.retirement?.disposable?.home || []) {
|
|
5122
|
+
if (disposableHomeRootProblem(root)) throw new Error(`retirement disposable home entry from ${cap.id} is not declarable: ${JSON.stringify(root)}`);
|
|
5123
|
+
rows.set(`${cap.id}\0${root}`, { owner: cap.id, root });
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
return [...rows.values()].sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
|
|
5127
|
+
}
|
|
5128
|
+
/** A baseline's `disposableHome`, or no exclusions at all: it must be an array
|
|
5129
|
+
* of { owner, root } whose every root passes the manifest grammar. Anything
|
|
5130
|
+
* else (absent, an older baseline, a malformed or widened row) excludes
|
|
5131
|
+
* nothing, so the home is copied whole. Never the manifest in the home's
|
|
5132
|
+
* module copy, the capability as it is today, or instance.json. */
|
|
5133
|
+
function baselineDisposableHome(baseline) {
|
|
5134
|
+
const rows = baseline?.disposableHome;
|
|
5135
|
+
if (!Array.isArray(rows) || !rows.every((r) => isPlainObject(r) && nonEmptyString(r.owner) && !disposableHomeRootProblem(r.root))) return [];
|
|
5136
|
+
return rows.map((r) => ({ owner: r.owner, root: r.root })).sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
|
|
5137
|
+
}
|
|
5138
|
+
/** One pass's resolved exclusion set: the home's top-level entries that
|
|
5139
|
+
* `disposableHome` rows cover, matched by name (a symlink is its name; it is
|
|
5140
|
+
* never followed). The fingerprint, the copy and the copy's verification of
|
|
5141
|
+
* that pass all use this one set. → { excludeRoot: Set (work/ and the covered
|
|
5142
|
+
* names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
|
|
5143
|
+
* and owners only. Two owners of one entry: the first in owner order. */
|
|
5144
|
+
function resolveHomeExclusions(home, disposableHome = []) {
|
|
5145
|
+
const excludeRoot = new Set(["work"]);
|
|
5146
|
+
const notCopied = [];
|
|
5147
|
+
if (!disposableHome.length) return { excludeRoot, notCopied };
|
|
5148
|
+
for (const name of readdirSync(home).sort(byCodeUnit)) {
|
|
5149
|
+
const row = name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name));
|
|
5150
|
+
if (!row) continue;
|
|
5151
|
+
excludeRoot.add(name);
|
|
5152
|
+
notCopied.push({ scope: "home", path: name, owner: row.owner });
|
|
5153
|
+
}
|
|
5154
|
+
return { excludeRoot, notCopied };
|
|
5155
|
+
}
|
|
5156
|
+
function unionNotCopied(...lists) {
|
|
5157
|
+
const byPath = new Map();
|
|
5158
|
+
for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
|
|
5159
|
+
return [...byPath.values()].sort((a, b) => byCodeUnit(a.path, b.path));
|
|
5160
|
+
}
|
|
5161
|
+
function retirementBaselineValid(baseline, home) {
|
|
5162
|
+
return baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
|
|
5163
|
+
}
|
|
5164
|
+
/** What a retire plan may say about recovery without hashing anything: where a
|
|
5165
|
+
* recovery would be written, and the home entries the spawn baseline declares
|
|
5166
|
+
* as not copied (none without a valid baseline). */
|
|
5167
|
+
export function retirementRecoveryFacts(home) {
|
|
5168
|
+
const baseline = readJsonOrUndefined(retirementBaselinePath(home));
|
|
5169
|
+
return { recoveryRoot: join(retirementStateRoot(home), "recovery"), disposableHome: retirementBaselineValid(baseline, home) ? baselineDisposableHome(baseline) : [] };
|
|
5170
|
+
}
|
|
4834
5171
|
function writeRetirementBaseline(home, work, mode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
|
|
4835
5172
|
if (mode === "directory") assertDirectoryRoots(home);
|
|
4836
5173
|
const isWorktree = mode === "worktree";
|
|
4837
5174
|
const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
|
|
4838
5175
|
const disposableReceipts = isWorktree ? retirementDisposableRoots(work, capabilities) : [];
|
|
5176
|
+
const disposableHome = retirementDisposableHome(capabilities);
|
|
4839
5177
|
const baseline = {
|
|
4840
5178
|
version: RETIRE_BASELINE_VERSION,
|
|
4841
5179
|
...(incarnationId ? { incarnationId, executionBinding } : {}),
|
|
4842
5180
|
...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
|
|
4843
5181
|
home: realPathOrNearest(home),
|
|
4844
|
-
homeFingerprint: fingerprintTree(home, { excludeRoot:
|
|
5182
|
+
homeFingerprint: fingerprintTree(home, { excludeRoot: resolveHomeExclusions(home, disposableHome).excludeRoot, instanceHome: true }),
|
|
4845
5183
|
disposableReceipts,
|
|
5184
|
+
disposableHome,
|
|
4846
5185
|
generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
|
|
4847
5186
|
runtime: {
|
|
4848
5187
|
launched: runtime?.launched === true,
|
|
@@ -5163,8 +5502,16 @@ export function withLaunchModel(command, model) {
|
|
|
5163
5502
|
return renderLaunchCommand(tokens);
|
|
5164
5503
|
}
|
|
5165
5504
|
|
|
5505
|
+
/** One tmux command on `socket`. With `env` (a window client: oatsWindowEnvironment, which has no
|
|
5506
|
+
* PATH) the tmux is the absolute one this process's own PATH finds, so its lookup never depends on
|
|
5507
|
+
* the environment it is passed; none found is reported as tmux being unavailable (ENOENT). */
|
|
5166
5508
|
function tmuxOn(socket, args, io, env) {
|
|
5167
|
-
|
|
5509
|
+
let tmux = "tmux";
|
|
5510
|
+
if (env) {
|
|
5511
|
+
tmux = process.env.PATH === undefined ? null : lookupOnPath("tmux", process.env.PATH);
|
|
5512
|
+
if (!tmux) throw Object.assign(new Error("no tmux was found on this process's PATH"), { code: "ENOENT" });
|
|
5513
|
+
}
|
|
5514
|
+
return (io?.exec || execFileSync)(tmux, ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
|
|
5168
5515
|
}
|
|
5169
5516
|
|
|
5170
5517
|
function writeJsonAtomic(path, value, mode) {
|
|
@@ -5545,6 +5892,28 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5545
5892
|
// --reselect-launch (feature launch-preference): apply the home's launch layers now (souls.launch,
|
|
5546
5893
|
// the recorded soul's launch, the host default) instead of the frozen recipe.
|
|
5547
5894
|
const reselect = o.reselectLaunch === true ? homeLaunchLayers(realHome, meta) : null;
|
|
5895
|
+
// The session this start opens its window in when it has to create one, and what its creation
|
|
5896
|
+
// needs (planOatsTmuxSession), read at most once: by the plan's lookup or by the creation's preflight.
|
|
5897
|
+
const planSession = receipt.target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
5898
|
+
let sessionPlanRead;
|
|
5899
|
+
const sessionPlan = () => {
|
|
5900
|
+
if (!sessionPlanRead) { try { sessionPlanRead = { plan: planOatsTmuxSession(planSession, o.io) }; } catch (e) { sessionPlanRead = { error: e }; } }
|
|
5901
|
+
if (sessionPlanRead.error) throw sessionPlanRead.error;
|
|
5902
|
+
return sessionPlanRead.plan;
|
|
5903
|
+
};
|
|
5904
|
+
// The recorded target, observed once: by the plan's lookup, which must know whether the pane will
|
|
5905
|
+
// be reused before it chooses where to look, or by the gate below. A lost server is a stopped one;
|
|
5906
|
+
// any other failure is the gate's refusal (E_SESSION_UNKNOWN), and the lookup does not decide it.
|
|
5907
|
+
let recordedRead;
|
|
5908
|
+
const recordedState = () => {
|
|
5909
|
+
if (!recordedRead) {
|
|
5910
|
+
try { recordedRead = { state: receipt.target ? inspectSessionTarget(receipt.target, o.io) : { present: false, state: "not-launched" } }; }
|
|
5911
|
+
catch (e) { recordedRead = lostTmuxServer(e) ? { state: { present: false, state: "stopped" } } : { error: e }; }
|
|
5912
|
+
}
|
|
5913
|
+
return recordedRead;
|
|
5914
|
+
};
|
|
5915
|
+
// A pane the start reuses where it is: one the recorded target shows, running or left as a shell.
|
|
5916
|
+
const reusable = (state) => !!(state?.paneId && (state.present || state.state === "stopped"));
|
|
5548
5917
|
const selected = reselect !== null || o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
|
|
5549
5918
|
const hasRecipe = meta.launch && typeof meta.launch === "object";
|
|
5550
5919
|
let launchPlan = null, launchHooksPass = null, hookMeta, explicitModelFrom = null, warnings = [];
|
|
@@ -5559,8 +5928,23 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5559
5928
|
// the launch hook re-checks joined memberships against them.
|
|
5560
5929
|
const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, defaultTeam: o.defaultTeam, teamsSource: o.teamsSource });
|
|
5561
5930
|
let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
|
|
5562
|
-
|
|
5563
|
-
|
|
5931
|
+
// Where the plan looks a harness up by name: a pane the start will reuse, on its own recorded
|
|
5932
|
+
// session, whatever the OATS server holds; otherwise the session's or the server's PATH when a
|
|
5933
|
+
// server runs; when none does, the PATH of the environment this start will create the server
|
|
5934
|
+
// with (read once, here, for the plan and for the creation below). For a creator that may not
|
|
5935
|
+
// create the server the plan looks on this process's PATH, and its refusal comes at the
|
|
5936
|
+
// creation's own place, after the start's other preflights.
|
|
5937
|
+
const pane = () => {
|
|
5938
|
+
const recorded = recordedState();
|
|
5939
|
+
if (reusable(recorded.state)) return panePath(receipt.target.socket, receipt.target.session, o.io, realHome);
|
|
5940
|
+
if (recorded.error) return undefined; // the gate refuses below; the lookup does not decide it
|
|
5941
|
+
const preview = planOatsTmuxSession(planSession, o.io, { preview: true });
|
|
5942
|
+
if (!preview || preview.server) return expectedPanePath(preview, planSession, o.io, realHome);
|
|
5943
|
+
try { return expectedPanePath(sessionPlan(), planSession, o.io, realHome); } catch (e) { if (oatsCoded(e)) return undefined; throw e; }
|
|
5944
|
+
};
|
|
5945
|
+
const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots,
|
|
5946
|
+
panePath: pane });
|
|
5947
|
+
launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, executable: plan.executable, config: plan.config, launchChoice: plan.launchChoice, trustHome: plan.trustHome, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
|
|
5564
5948
|
...(plan.launchChoice ? { launchFrom: plan.launchChoice.from, launchAt: plan.launchChoice.at, launchDeclared: plan.launchChoice.declared } : {}) };
|
|
5565
5949
|
// The plan ran preview-aware launch hooks as a preview: their real run, over the planned
|
|
5566
5950
|
// contributions, waits for the rest of preflight.
|
|
@@ -5585,19 +5969,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5585
5969
|
if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
|
|
5586
5970
|
const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
|
|
5587
5971
|
const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
5588
|
-
checkRoots(); // preparation has run; no backend has been
|
|
5972
|
+
checkRoots(); // preparation has run; no backend has been changed
|
|
5589
5973
|
let target = receipt.target;
|
|
5590
|
-
|
|
5591
|
-
if (
|
|
5592
|
-
|
|
5593
|
-
catch (e) {
|
|
5594
|
-
if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
|
|
5595
|
-
else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
|
|
5596
|
-
}
|
|
5597
|
-
}
|
|
5974
|
+
const recorded = recordedState();
|
|
5975
|
+
if (recorded.error) { const e = recorded.error; throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
5976
|
+
let state = recorded.state;
|
|
5598
5977
|
if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
|
|
5599
|
-
// A caller
|
|
5600
|
-
//
|
|
5978
|
+
// A caller that will have to create the tmux session reads the environment for it here, unless
|
|
5979
|
+
// the plan's lookup read it already with no server running (an instance that cannot is refused
|
|
5980
|
+
// here either way): after the start's preflights and its planning,
|
|
5601
5981
|
// so each of their refusals still answers first, and before the real run of preview-aware
|
|
5602
5982
|
// launch hooks, a stop and any write of the home's launch state (record, receipt, pending
|
|
5603
5983
|
// start). A launch hook that does not declare launchPreview has already run and its warnings
|
|
@@ -5606,8 +5986,27 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5606
5986
|
// prepareLaunchHooks) allows such a hook idempotent provider registration, which the next
|
|
5607
5987
|
// start repeats. Only when this start will have to create a window: a retained pane is reused
|
|
5608
5988
|
// where it is. A restart whose window goes away during its stop is decided at the creation.
|
|
5609
|
-
const
|
|
5610
|
-
|
|
5989
|
+
const retained = reusable(state);
|
|
5990
|
+
const tmuxSessionPlan = retained ? undefined : sessionPlan();
|
|
5991
|
+
// A harness the plan looked up by name is looked up again where its pane will look it up, before
|
|
5992
|
+
// anything is stopped: the retained pane's own session, or the session this start will create
|
|
5993
|
+
// its window in (as planned). Once more on the session ensureOatsTmuxSession returns (below).
|
|
5994
|
+
// Not there: refused. Found elsewhere: the launch runs that one, and records it. Every executable
|
|
5995
|
+
// named bare (the harness's name, a declared name) is looked up again, also one the plan could only
|
|
5996
|
+
// look up on this process's PATH; a recorded (frozen) or a declared path never is.
|
|
5997
|
+
const lookAgain = (pane) => {
|
|
5998
|
+
if (!launchPlan || launchPlan.config?.frozen || launchPlan.config?.executable?.includes("/")) return false;
|
|
5999
|
+
const { config, launchChoice } = launchPlan;
|
|
6000
|
+
const actual = resolveLaunchExecutable({ harness: launchPlan.harness, declared: config?.executable, declaringDir: config?.source, lookup: launchLookup(config?.env, o.env || process.env, pane, config?.name, realHome) });
|
|
6001
|
+
if (!actual.path) throw executableNotOnPanePath(actual, { harness: launchPlan.harness, config, from: launchChoice?.from, at: launchChoice?.at });
|
|
6002
|
+
const bad = checkLaunchExecutable(actual.path);
|
|
6003
|
+
if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${bad}; nothing was started`);
|
|
6004
|
+
if (actual.path === launchPlan.recipe.executable) return false;
|
|
6005
|
+
launchPlan.recipe = { ...launchPlan.recipe, executable: actual.path };
|
|
6006
|
+
command = launchPlan.command = renderLaunchRecipe(launchPlan.recipe, { home: realHome, instance: meta.instance, trustHome: launchPlan.trustHome });
|
|
6007
|
+
return true;
|
|
6008
|
+
};
|
|
6009
|
+
lookAgain(retained ? () => panePath(target.socket, target.session, o.io, realHome) : () => expectedPanePath(tmuxSessionPlan, planSession, o.io, realHome));
|
|
5611
6010
|
// Every preflight has passed: the preview-aware launch hooks run for real
|
|
5612
6011
|
// (they may register the home with their provider), before a restart's
|
|
5613
6012
|
// stop, and must contribute exactly what they contributed as a preview,
|
|
@@ -5655,13 +6054,12 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5655
6054
|
const startedAt = new Date().toISOString();
|
|
5656
6055
|
const id = randomUUID();
|
|
5657
6056
|
checkRoots();
|
|
5658
|
-
const
|
|
5659
|
-
const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
6057
|
+
const windowCommand = () => paneCommand(`${nativeRecordCommand(command, realHome, launchPlan?.harness || harness)}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`);
|
|
5660
6058
|
let reused = "new";
|
|
5661
6059
|
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
5662
6060
|
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
5663
6061
|
let socket = target?.socket || meta.tmux?.socket;
|
|
5664
|
-
|
|
6062
|
+
let windowCmd = windowCommand();
|
|
5665
6063
|
let moved = null;
|
|
5666
6064
|
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
5667
6065
|
// the agent's own pane: the command runs there, on the server the home
|
|
@@ -5681,6 +6079,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5681
6079
|
// on the OATS server, wherever the home was recorded: one socket from here to the record.
|
|
5682
6080
|
const recordedSocket = socket ? resolve(socket) : null;
|
|
5683
6081
|
socket = ensureOatsTmuxSession(session, hq, o.io, tmuxSessionPlan);
|
|
6082
|
+
if (lookAgain(() => panePath(socket, session, o.io, realHome))) { planExtra.launch = launchPlan.recipe; windowCmd = windowCommand(); }
|
|
5684
6083
|
let names;
|
|
5685
6084
|
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
5686
6085
|
catch (e) {
|
|
@@ -5722,6 +6121,287 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5722
6121
|
}
|
|
5723
6122
|
}
|
|
5724
6123
|
|
|
6124
|
+
/** One Git call of a retirement inspection → its stdout, as bytes: a text
|
|
6125
|
+
* with one code unit per byte (latin1), so that no byte is replaced. Git
|
|
6126
|
+
* prints ref names and paths as the bytes they are, and decoded as UTF-8 two
|
|
6127
|
+
* names that differ only in bytes that are not UTF-8 would read alike: a
|
|
6128
|
+
* state a hook changed would compare equal, and a copy would be skipped.
|
|
6129
|
+
* What is compared is this text, never a decoded one. A value that is also
|
|
6130
|
+
* used as a path is decoded where it is used (repositoryGitState).
|
|
6131
|
+
* With `absent`, Git's quiet exit 1 ("no such ref", nothing on stderr)
|
|
6132
|
+
* gives null. Any other failure throws E_WORK_INSPECTION_FAILED: a state
|
|
6133
|
+
* that could not be read is never taken for an unchanged one. What a failed
|
|
6134
|
+
* read of the state means for the retire is inspectRetirementWork's
|
|
6135
|
+
* decision: not provable. */
|
|
6136
|
+
function inspectGit(repo, args, { absent = false, env } = {}) {
|
|
6137
|
+
try {
|
|
6138
|
+
return execFileSync("git", ["-C", repo, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, ...(env ? { env: { ...process.env, ...env } } : {}) }).toString("latin1");
|
|
6139
|
+
} catch (e) {
|
|
6140
|
+
const detail = String(e.stderr ?? "").trim();
|
|
6141
|
+
if (absent && e.status === 1 && !detail) return null;
|
|
6142
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${detail || String(e.message ?? "").trim() || `git ${args[0]} failed`}`);
|
|
6143
|
+
}
|
|
6144
|
+
}
|
|
6145
|
+
/** Whether an entry is there, for a read of the Git state. Only "not there"
|
|
6146
|
+
* is absence: a test that fails for any other reason (no permission on a
|
|
6147
|
+
* directory above it, for example) throws, so an entry the kernel cannot see
|
|
6148
|
+
* is never taken for one that does not exist. A link is followed, as
|
|
6149
|
+
* existsSync follows it. */
|
|
6150
|
+
function inspectedEntryExists(path) {
|
|
6151
|
+
try { statSync(path); return true; }
|
|
6152
|
+
catch (e) {
|
|
6153
|
+
if (e.code === "ENOENT") return false;
|
|
6154
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
/** How the recovery copier resolves what it reads from a repository, read by
|
|
6158
|
+
* the copier and by the comparison alike (repositoryGitState), so that the
|
|
6159
|
+
* comparison holds the files the copy carries, found where the copy finds
|
|
6160
|
+
* them. Git's answer decoded as UTF-8 and trimmed of white space at both
|
|
6161
|
+
* ends: a path that ends in white space is read without it, which Git itself
|
|
6162
|
+
* does not do. That is the copier's own reading, kept as it is; a copy of
|
|
6163
|
+
* the file Git reads would be a change to the copier.
|
|
6164
|
+
* A worktree whose `core.worktree` (per-worktree configuration) names a
|
|
6165
|
+
* directory other than `<home>/work` is outside what the retire is
|
|
6166
|
+
* specified for: Git's status then describes that other directory while
|
|
6167
|
+
* the retire reads, copies and removes `<home>/work`. copierExcludesFile
|
|
6168
|
+
* resolves the excludes base the copier's way for it all the same; no test
|
|
6169
|
+
* pins it. */
|
|
6170
|
+
const copierGitText = (repo, args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6171
|
+
/** The Git directory the copier copies the index and the operation entries from. */
|
|
6172
|
+
const copierGitDir = (repo) => copierGitText(repo, ["rev-parse", "--absolute-git-dir"]);
|
|
6173
|
+
/** The common directory the copier reads info/exclude, info/attributes and the stash's log from. */
|
|
6174
|
+
const copierCommonDir = (repo) => copierGitText(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
6175
|
+
/** The file the copier reads for core.excludesFile; null when it reads none:
|
|
6176
|
+
* the value is unset, cannot be read, or is only white space. */
|
|
6177
|
+
function copierExcludesFile(repo) {
|
|
6178
|
+
let value = "";
|
|
6179
|
+
try { value = copierGitText(repo, ["config", "--path", "--get", "core.excludesFile"]); } catch { /* unset: Git's default applies to both repositories alike */ }
|
|
6180
|
+
return value ? resolve(copierGitText(repo, ["rev-parse", "--show-toplevel"]), value) : null;
|
|
6181
|
+
}
|
|
6182
|
+
/** What a recovery's work copy holds of the worktree's repository beside the
|
|
6183
|
+
* bytes of its files: everything copyRecoveryWork carries or restores, read
|
|
6184
|
+
* the way it reads it, without reading the files. The rule: the work is
|
|
6185
|
+
* unchanged only if everything its copy would carry is byte-for-byte equal,
|
|
6186
|
+
* and anything that cannot be compared exactly counts as changed.
|
|
6187
|
+
* - its status text (the copy is verified against it);
|
|
6188
|
+
* - its HEAD as the copier reads it (headName): the whole ref, as its
|
|
6189
|
+
* bytes, which decides whether the copy is cloned on a branch or
|
|
6190
|
+
* detached; and the commit;
|
|
6191
|
+
* - the index the copier copies, by what it holds and not by its bytes:
|
|
6192
|
+
* its entries as `git ls-files -s -v` lists them (mode, object, stage and path, with the
|
|
6193
|
+
* skip-worktree and assume-unchanged marks), its resolve-undo records
|
|
6194
|
+
* (`git ls-files --resolve-undo`: resolving a conflict can add them while
|
|
6195
|
+
* every entry, status row and byte ends up as it was), and the file's
|
|
6196
|
+
* mode, which copyFileSync gives the copy. Its bytes also hold a stat
|
|
6197
|
+
* cache that a read-only Git command rewrites, and extensions derived
|
|
6198
|
+
* from the entries: those are a deliberate semantic exception, never
|
|
6199
|
+
* compared;
|
|
6200
|
+
* - each path the copier copies from the Git directories (COPIED_GIT_PATHS:
|
|
6201
|
+
* an operation in progress, info/attributes, the stash's log), by what its
|
|
6202
|
+
* copy carries, the permission bits included (copiedGitPathDigest);
|
|
6203
|
+
* - its tags: a clone brings them, and removing the remote leaves them;
|
|
6204
|
+
* - the stash ref (detachRecoveryClone fetches it);
|
|
6205
|
+
* - its exclude rules as carryExcludes reads them: the core.excludesFile
|
|
6206
|
+
* value, the file carryExcludes reads for it (copierExcludesFile), and
|
|
6207
|
+
* info/exclude. They are written into the copy as new text, so their
|
|
6208
|
+
* bytes are what it carries;
|
|
6209
|
+
* - its status settings as carryStatusConfig reads them: the effective
|
|
6210
|
+
* value of each STATUS_CONFIG key. The keys, not the configuration file:
|
|
6211
|
+
* Git rewrites that file for an upstream or a remote, which no copy
|
|
6212
|
+
* carries;
|
|
6213
|
+
* - `cloneHead`, for a worktree whose HEAD the copier copies detached (no
|
|
6214
|
+
* branch, a ref that is not a branch, a name that is not UTF-8): that copy
|
|
6215
|
+
* is cloned without --branch, so it keeps a local branch for the HEAD of
|
|
6216
|
+
* the repository it is cloned from, and none when that HEAD is not on a
|
|
6217
|
+
* branch. That HEAD is read as its whole ref.
|
|
6218
|
+
* The repository's other branches are not here: they outlive the worktree,
|
|
6219
|
+
* and the copy does not hold them. Its tags, stash, rules and settings are
|
|
6220
|
+
* the shared repository's: one made there by anyone while the retire hooks
|
|
6221
|
+
* run adds a copy attempt. The repository's objects and the settings a clone
|
|
6222
|
+
* of it is served under (its shallow boundary, grafts, hidden refs) stay in
|
|
6223
|
+
* it and are not compared: a custody boundary, which holds because no
|
|
6224
|
+
* retire removes that repository or deletes a branch.
|
|
6225
|
+
* Every read is inspectGit's, a file read or an existence test
|
|
6226
|
+
* (inspectedEntryExists): only Git's quiet "not set" and an entry that is
|
|
6227
|
+
* not there are absence; any other failure throws, and the worktree is then
|
|
6228
|
+
* not provable (inspectRetirementWork).
|
|
6229
|
+
* Everything in the list is compared as its bytes; nothing of it goes
|
|
6230
|
+
* through a decoding that replaces a byte. Git's output is inspectGit's
|
|
6231
|
+
* text of bytes, the status is the status bytes as such a text, a file is
|
|
6232
|
+
* the digest of its bytes, and a copied path is its exact digest
|
|
6233
|
+
* (fingerprintTrees), which reads names and link targets as bytes. The
|
|
6234
|
+
* three `rev-parse` paths and the `core.excludesFile` value are in the state
|
|
6235
|
+
* as Git printed them (`paths`, and the first of `excludesFile`), compared
|
|
6236
|
+
* as their bytes; a `rev-parse` path that does not decode to an existing one
|
|
6237
|
+
* throws below, and the worktree is not provable. Every file and directory
|
|
6238
|
+
* the state reads is found where the copier finds it, by the copier's own
|
|
6239
|
+
* functions (copierGitDir, copierCommonDir, copierExcludesFile, headName):
|
|
6240
|
+
* the state is what the copy carries, read the way the copy reads it. A
|
|
6241
|
+
* file the copier does not reach is not there for the state either.
|
|
6242
|
+
* `status`: the repository's worktreeStatusBytes, when the caller has it.
|
|
6243
|
+
* `cloneSource`: the repository the copy is cloned from (meta.repo). */
|
|
6244
|
+
function repositoryGitState(repo, { status, cloneSource } = {}) {
|
|
6245
|
+
/** One of the copier's reads; any failure makes the worktree not provable. */
|
|
6246
|
+
const asCopier = (read) => {
|
|
6247
|
+
try { return read(repo); }
|
|
6248
|
+
catch (e) {
|
|
6249
|
+
if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
|
|
6250
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${String(e?.stderr ?? "").trim() || String(e?.message ?? "").trim()}`);
|
|
6251
|
+
}
|
|
6252
|
+
};
|
|
6253
|
+
// Three paths, one per line. A path with a line feed in it prints more
|
|
6254
|
+
// lines, and taken apart wrongly every test below would read "not there",
|
|
6255
|
+
// before the hooks and after them alike. So: exactly three, absolute, there.
|
|
6256
|
+
/** A value Git printed, as the path the kernel's text paths name with it. */
|
|
6257
|
+
const pathOf = (value) => Buffer.from(value, "latin1").toString("utf8");
|
|
6258
|
+
const printed = inspectGit(repo, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir", "--show-toplevel"]).split("\n");
|
|
6259
|
+
if (printed.at(-1) === "") printed.pop();
|
|
6260
|
+
const paths = printed.map(pathOf);
|
|
6261
|
+
if (paths.length !== 3 || !paths.every((path) => isAbsolute(path) && inspectedEntryExists(path))) {
|
|
6262
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: git rev-parse did not give its Git directory, its common directory and its top level as three existing absolute paths`);
|
|
6263
|
+
}
|
|
6264
|
+
const [gitDir, commonDir] = paths;
|
|
6265
|
+
const bytesOf = (path) => {
|
|
6266
|
+
try { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
|
|
6267
|
+
catch (e) {
|
|
6268
|
+
if (e.code === "ENOENT") return null;
|
|
6269
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
|
|
6270
|
+
}
|
|
6271
|
+
};
|
|
6272
|
+
/** The permission bits a copyFileSync gives the copy of `path`; null when it is not there. */
|
|
6273
|
+
const modeOf = (path) => {
|
|
6274
|
+
try { return statSync(path).mode & 0o7777; }
|
|
6275
|
+
catch (e) {
|
|
6276
|
+
if (e.code === "ENOENT") return null;
|
|
6277
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
|
|
6278
|
+
}
|
|
6279
|
+
};
|
|
6280
|
+
/** One value Git may not have: null when it is not set. */
|
|
6281
|
+
const valueOf = (...args) => inspectGit(repo, args, { absent: true })?.replace(/\n$/, "") ?? null;
|
|
6282
|
+
const sourceValueOf = (...args) => inspectGit(cloneSource, args, { absent: true })?.replace(/\n$/, "") ?? null;
|
|
6283
|
+
const head = asCopier(headName);
|
|
6284
|
+
const copierGit = asCopier(copierGitDir), copierCommon = asCopier(copierCommonDir);
|
|
6285
|
+
const excludesFile = valueOf("config", "--path", "--get", "core.excludesFile");
|
|
6286
|
+
const excludesRead = asCopier(copierExcludesFile);
|
|
6287
|
+
return {
|
|
6288
|
+
paths: printed,
|
|
6289
|
+
status: (status ?? worktreeStatusBytes(repo)).toString("latin1"),
|
|
6290
|
+
head: head.ref === null ? null : head.ref.toString("latin1"),
|
|
6291
|
+
commit: valueOf("rev-parse", "--verify", "--quiet", "HEAD"),
|
|
6292
|
+
// The index the copier copies (copierGitDir), read as an index: Git's
|
|
6293
|
+
// own, at a path that differs from it only in white space, is not what
|
|
6294
|
+
// the copy carries. One that is not there lists nothing.
|
|
6295
|
+
index: inspectGit(repo, ["ls-files", "-s", "-v", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
|
|
6296
|
+
resolveUndo: inspectGit(repo, ["ls-files", "--resolve-undo", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
|
|
6297
|
+
indexMode: modeOf(join(copierGit, "index")),
|
|
6298
|
+
copied: COPIED_GIT_PATHS.map((entry) => copiedGitPathDigest(entry, copiedGitPath(entry, copierGit, copierCommon))),
|
|
6299
|
+
tags: inspectGit(repo, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"]),
|
|
6300
|
+
stash: valueOf("rev-parse", "--verify", "--quiet", "refs/stash"),
|
|
6301
|
+
excludesFile: excludesFile === null && excludesRead === null ? null : [excludesFile, excludesRead, excludesRead && bytesOf(excludesRead)],
|
|
6302
|
+
exclude: bytesOf(join(copierCommon, "info", "exclude")),
|
|
6303
|
+
statusConfig: STATUS_CONFIG.map((key) => valueOf("config", "--get", key)),
|
|
6304
|
+
// The copy is cloned from the repository the instance was spawned from
|
|
6305
|
+
// (`cloneSource`, meta.repo), which can be a linked worktree of it: that
|
|
6306
|
+
// repository's HEAD is read there, as its whole ref, not the common
|
|
6307
|
+
// directory's.
|
|
6308
|
+
cloneHead: head.branch === null && gitDir !== commonDir && cloneSource
|
|
6309
|
+
? [sourceValueOf("symbolic-ref", "--quiet", "HEAD"), sourceValueOf("rev-parse", "--verify", "--quiet", "HEAD")]
|
|
6310
|
+
: null,
|
|
6311
|
+
};
|
|
6312
|
+
}
|
|
6313
|
+
/** Every repository under a worktree, at any depth → [{ path, inner }]:
|
|
6314
|
+
* nestedGitRoots, and (`inner`) the repositories inside those. A recovery
|
|
6315
|
+
* rebuilds a nested repository from a clone; one inside it comes along as
|
|
6316
|
+
* plain files, its Git directory included, which the copy's verification
|
|
6317
|
+
* leaves out and which no read-only Git command can describe whole.
|
|
6318
|
+
* A directory whose `.git` cannot be tested (inspectedEntryExists throws),
|
|
6319
|
+
* or is a dangling symbolic link (a link `stat` cannot follow, which the
|
|
6320
|
+
* copy carries as a link and the digests pass over by its name), is listed
|
|
6321
|
+
* as { path, unknown: true }: it is not taken for a directory without a
|
|
6322
|
+
* repository, and not for a repository either. So every directory with a
|
|
6323
|
+
* `.git` entry of any kind is listed: a directory, a file, a link to
|
|
6324
|
+
* anything, a dangling link, and a `.git` that cannot be tested. */
|
|
6325
|
+
function repositoriesUnder(work) {
|
|
6326
|
+
const out = [];
|
|
6327
|
+
// Asked only where `stat` found no `.git`: whether there is an entry all the same.
|
|
6328
|
+
const entryIsThere = (path) => {
|
|
6329
|
+
try { lstatSync(path); return true; }
|
|
6330
|
+
catch (e) {
|
|
6331
|
+
if (e.code === "ENOENT") return false;
|
|
6332
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
|
|
6333
|
+
}
|
|
6334
|
+
};
|
|
6335
|
+
const walk = (dir, inner) => {
|
|
6336
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
6337
|
+
if (!e.isDirectory() || e.isSymbolicLink?.() || e.name === ".git") continue;
|
|
6338
|
+
const path = join(dir, e.name);
|
|
6339
|
+
let repository = false;
|
|
6340
|
+
try {
|
|
6341
|
+
repository = inspectedEntryExists(join(path, ".git"));
|
|
6342
|
+
if (!repository && entryIsThere(join(path, ".git"))) out.push({ path, unknown: true });
|
|
6343
|
+
} catch (e) {
|
|
6344
|
+
if (e?.code !== "E_WORK_INSPECTION_FAILED") throw e;
|
|
6345
|
+
out.push({ path, unknown: true });
|
|
6346
|
+
}
|
|
6347
|
+
if (repository) out.push({ path, inner });
|
|
6348
|
+
walk(path, inner || repository);
|
|
6349
|
+
}
|
|
6350
|
+
};
|
|
6351
|
+
walk(work, false);
|
|
6352
|
+
return out;
|
|
6353
|
+
}
|
|
6354
|
+
/** The work state of a worktree instance, without the bytes of its files: the
|
|
6355
|
+
* worktree's Git state (repositoryGitState), as one text. Two inspections of
|
|
6356
|
+
* an untouched tree give the same text. The bytes are the other part, read
|
|
6357
|
+
* only when needed: observedWorkBytes. A repository under the worktree is not
|
|
6358
|
+
* part of it: such a worktree is not provable (inspectRetirementWork). */
|
|
6359
|
+
function worktreeGitState(work, status, cloneSource) { // `status`: the worktree's status bytes
|
|
6360
|
+
return JSON.stringify({ worktree: repositoryGitState(work, { status, cloneSource }) });
|
|
6361
|
+
}
|
|
6362
|
+
/** The bytes of a worktree, Git metadata left out, as their exact digest: what a work copy is verified
|
|
6363
|
+
* with, and what proves that the hooks left the files as they were. One full read. Kept nowhere. */
|
|
6364
|
+
const worktreeBytes = (work) => exactTreeDigest(work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true, children: true });
|
|
6365
|
+
/** The byte part of an observed worktree's work state, read at most once per
|
|
6366
|
+
* observation and kept on it; undefined where the observation saw no
|
|
6367
|
+
* worktree. An inspection never reads it. The retire reads it before it
|
|
6368
|
+
* writes the pre-hook recovery, the copy's verification uses it (so a pass
|
|
6369
|
+
* that copies the work costs no extra read), and so does the proof that the
|
|
6370
|
+
* retire hooks left the work as it was (movedByHooks). */
|
|
6371
|
+
function observedWorkBytes(observation) {
|
|
6372
|
+
if (observation.worktree && observation.workBytes === undefined) {
|
|
6373
|
+
// A read that fails is an inspection that failed, with its code: where the
|
|
6374
|
+
// retire reads before it writes, nothing else gives the failure one.
|
|
6375
|
+
try { observation.workBytes = worktreeBytes(observation.work); }
|
|
6376
|
+
catch (e) {
|
|
6377
|
+
if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
|
|
6378
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the worktree at ${observation.work}: ${e.message}`);
|
|
6379
|
+
}
|
|
6380
|
+
}
|
|
6381
|
+
return observation.workBytes;
|
|
6382
|
+
}
|
|
6383
|
+
/** What the retire hooks moved, between the observation before them and the
|
|
6384
|
+
* one after → { home, work }. Nothing when the later observation has nothing
|
|
6385
|
+
* to preserve. The home moved when its exact digest did: that is its bytes,
|
|
6386
|
+
* `instance.json` as it is. (The stored digest, which a baseline is compared
|
|
6387
|
+
* with, leaves the kernel's own fields of that file out and frames entries
|
|
6388
|
+
* without lengths; it reads alike some homes that differ.)
|
|
6389
|
+
* The work moved unless it is proven unchanged: a directory by its bytes
|
|
6390
|
+
* (its exact digest); a worktree by its Git state and, only when that is
|
|
6391
|
+
* equal, by its bytes. A status text or a class list alone proves nothing: a
|
|
6392
|
+
* hook can rewrite a file that was already modified. `before` must hold its
|
|
6393
|
+
* bytes already (the retire reads them before it writes the pre-hook
|
|
6394
|
+
* recovery); without them the work counts as moved. So does a worktree that
|
|
6395
|
+
* cannot be proven unchanged at all (`workProvable` false: a worktree that
|
|
6396
|
+
* holds a repository, or a read of the Git state that failed). */
|
|
6397
|
+
function movedByHooks(before, after) {
|
|
6398
|
+
if (!after.classes.length) return { home: false, work: false };
|
|
6399
|
+
const home = after.homeBytes !== before.homeBytes;
|
|
6400
|
+
let work = after.workFingerprint !== before.workFingerprint;
|
|
6401
|
+
if (!work && after.worktree) work = !before.workProvable || !after.workProvable || before.workBytes === undefined || observedWorkBytes(after) !== before.workBytes;
|
|
6402
|
+
return { home, work };
|
|
6403
|
+
}
|
|
6404
|
+
|
|
5725
6405
|
function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
|
|
5726
6406
|
if (directory) assertDirectoryRoots(home);
|
|
5727
6407
|
const classes = [];
|
|
@@ -5732,10 +6412,20 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
|
|
|
5732
6412
|
} catch (e) {
|
|
5733
6413
|
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the independent retirement baseline: ${e.message}`);
|
|
5734
6414
|
}
|
|
5735
|
-
const baselineValid = baseline
|
|
6415
|
+
const baselineValid = retirementBaselineValid(baseline, home);
|
|
6416
|
+
// This pass's one resolved exclusion set: provider-owned entries the spawn
|
|
6417
|
+
// baseline declared. Every home fingerprint here and the copy made from this
|
|
6418
|
+
// observation use it; without a valid baseline there is none.
|
|
6419
|
+
const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : []);
|
|
6420
|
+
// One walk of the home, two digests: the stored one for the comparison with
|
|
6421
|
+
// the baseline, which must not see the kernel's own fields of instance.json,
|
|
6422
|
+
// and the exact one for the comparison after the hooks, which must see
|
|
6423
|
+
// every byte.
|
|
6424
|
+
let homeDigests;
|
|
6425
|
+
const fingerprintHome = () => (homeDigests ??= fingerprintTrees(home, { excludeRoot: homeExclusions.excludeRoot, instanceHome: true, exact: true, children: true })).stored;
|
|
5736
6426
|
if (!baselineValid) {
|
|
5737
6427
|
classes.push("unknown instance-home provenance");
|
|
5738
|
-
} else if (baseline.homeFingerprint !==
|
|
6428
|
+
} else if (baseline.homeFingerprint !== fingerprintHome()) {
|
|
5739
6429
|
classes.push("changed instance-home bytes");
|
|
5740
6430
|
}
|
|
5741
6431
|
// A mutable mode must not turn owned directory bytes into an excluded shared
|
|
@@ -5745,7 +6435,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
|
|
|
5745
6435
|
}
|
|
5746
6436
|
let directoryFingerprint;
|
|
5747
6437
|
if (directory) {
|
|
5748
|
-
directoryFingerprint =
|
|
6438
|
+
directoryFingerprint = directoryBytes(work);
|
|
5749
6439
|
// Never stamp hook-created or authored execution bytes as disposable.
|
|
5750
6440
|
if (readdirSync(work).length) classes.push("directory work bytes");
|
|
5751
6441
|
}
|
|
@@ -5756,24 +6446,65 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
|
|
|
5756
6446
|
// reaches its commit. `head` is kept for the check before the removal.
|
|
5757
6447
|
const unreached = worktreeRemoval?.removes && worktreeRemoval.repo && !directory && isWorktree && existsSync(work) ? worktreeCommitUnreached(worktreeRemoval.repo, work) : undefined;
|
|
5758
6448
|
if (unreached?.unreached) classes.push("worktree commits no ref reaches");
|
|
5759
|
-
|
|
5760
|
-
|
|
6449
|
+
const worktree = isWorktree && existsSync(work);
|
|
6450
|
+
let gitState = "", workProvable = true;
|
|
6451
|
+
if (worktree) {
|
|
6452
|
+
// Read once, as bytes. The bytes go into the Git state; the rows, the
|
|
6453
|
+
// classes and the comparison with the spawn baseline read them as text.
|
|
6454
|
+
const statusBytes = worktreeStatusBytes(work);
|
|
6455
|
+
const status = statusBytes.toString("utf8");
|
|
5761
6456
|
const rows = status.split("\0").filter(Boolean);
|
|
5762
6457
|
if (rows.some((row) => !row.startsWith("?? ") && !row.startsWith("!! "))) classes.push("tracked or index worktree state");
|
|
5763
6458
|
const disposableRoots = baseline?.disposableReceipts?.map((r) => r.root) || [];
|
|
5764
6459
|
if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
6460
|
+
const found = repositoriesUnder(work);
|
|
6461
|
+
if (found.some((r) => !r.unknown)) classes.push("nested repository state");
|
|
6462
|
+
// The one place where a read of the Git state that fails is decided. It
|
|
6463
|
+
// is never taken for "not set" or for "unchanged", and it does not refuse
|
|
6464
|
+
// either: main's inspection read none of this and let such a retire go
|
|
6465
|
+
// on. The worktree is then not provable, and the copy decides, as it did
|
|
6466
|
+
// on main: nothing to preserve retires, anything else is copied before
|
|
6467
|
+
// the hooks (homeOnlyRecovery) and again after them (movedByHooks).
|
|
6468
|
+
// `git status` is not part of this: it failed the inspection on main too.
|
|
6469
|
+
try { gitState = worktreeGitState(work, statusBytes, worktreeRemoval?.repo); }
|
|
6470
|
+
catch (e) {
|
|
6471
|
+
if (!e?.code) throw e;
|
|
6472
|
+
workProvable = false;
|
|
6473
|
+
}
|
|
6474
|
+
// A worktree that holds a repository is not provable either: a directory
|
|
6475
|
+
// under it with a `.git` entry of any kind (repositoriesUnder, the
|
|
6476
|
+
// predicate the class reads): a directory, a file, a link to anything, a
|
|
6477
|
+
// dangling link, and a `.git` that cannot be tested. Only a repository
|
|
6478
|
+
// the predicate can see adds the class; the last two make the work
|
|
6479
|
+
// unprovable without it, and so never home-only (homeOnlyRecovery): a
|
|
6480
|
+
// change to the home alone can then cause a work-copy attempt before
|
|
6481
|
+
// the hooks that main did not make, and any failure of that attempt can
|
|
6482
|
+
// refuse the retirement. The retire hooks run between the two copies and
|
|
6483
|
+
// can change such a repository in ways no read of its state covers (its
|
|
6484
|
+
// configuration, its objects, what a clone of it is shown), so its work
|
|
6485
|
+
// is copied again after them, whatever they did. What stays in the repository the
|
|
6486
|
+
// worktree belongs to needs no copy: no retire removes it or deletes a
|
|
6487
|
+
// branch, and a worktree commit no ref reaches is preserved (the class
|
|
6488
|
+
// above, `unreached`).
|
|
6489
|
+
if (found.length) workProvable = false;
|
|
6490
|
+
}
|
|
6491
|
+
// Two values, so the post-hook pass can tell which part a hook moved: the
|
|
6492
|
+
// home's bytes (its exact digest), and the work state's fingerprint. A
|
|
6493
|
+
// directory's work state is its bytes (`directoryFingerprint`, an exact
|
|
6494
|
+
// digest too). A worktree's is its Git state here, and its bytes, which an
|
|
6495
|
+
// inspection does not read (observedWorkBytes). `workProvable`: whether
|
|
6496
|
+
// that state could be read and covers everything a copy of the work would
|
|
6497
|
+
// carry. None of these is kept in a baseline, a receipt or recovery.json.
|
|
6498
|
+
// When the worktree will be removed, its HEAD (the commit and the ref's
|
|
6499
|
+
// bytes) and whether a ref reaches that commit are part of the work state: a
|
|
6500
|
+
// hook that deletes the ref that reached the commit changes nothing else
|
|
6501
|
+
// the state holds, and the commit must still be preserved again after it.
|
|
6502
|
+
fingerprintHome();
|
|
6503
|
+
const workFingerprint = createHash("sha256").update(directory ? (directoryFingerprint || "missing") : gitState)
|
|
5773
6504
|
.update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
|
|
5774
6505
|
.update(unreached?.head.ref ?? "")
|
|
5775
6506
|
.digest("hex");
|
|
5776
|
-
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint,
|
|
6507
|
+
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
5777
6508
|
}
|
|
5778
6509
|
|
|
5779
6510
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -5789,6 +6520,44 @@ const RECOVERABLE_GIT_ADMIN = [
|
|
|
5789
6520
|
"BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
|
|
5790
6521
|
];
|
|
5791
6522
|
|
|
6523
|
+
/** The paths of a repository's Git directories that a recovery's work copy
|
|
6524
|
+
* takes by copying them, beside the clone, the files and the index: one
|
|
6525
|
+
* selection, read by the copier (restoreStandaloneGitState,
|
|
6526
|
+
* carryStatusConfig, detachRecoveryClone) and by the comparison
|
|
6527
|
+
* (repositoryGitState), so that each copied path is compared by what its
|
|
6528
|
+
* copy carries (copiedGitPathDigest). `dir`: the repository's own Git
|
|
6529
|
+
* directory ("git") or its common directory ("common"). `copy`: copyTreeSafe
|
|
6530
|
+
* ("tree") or copyFileSync ("file").
|
|
6531
|
+
* The index is copied whole and is not in the selection: it is compared by
|
|
6532
|
+
* its entries, its resolve-undo records and its mode, never by its bytes,
|
|
6533
|
+
* which hold a cache that a read-only Git command rewrites. That is a
|
|
6534
|
+
* deliberate semantic exception (repositoryGitState). */
|
|
6535
|
+
const COPIED_GIT_PATHS = [
|
|
6536
|
+
...RECOVERABLE_GIT_ADMIN.map((name) => ({ dir: "git", path: [name], copy: "tree" })),
|
|
6537
|
+
{ dir: "common", path: ["info", "attributes"], copy: "file" },
|
|
6538
|
+
{ dir: "common", path: ["logs", "refs", "stash"], copy: "file" },
|
|
6539
|
+
];
|
|
6540
|
+
const [GIT_ATTRIBUTES, GIT_STASH_LOG] = COPIED_GIT_PATHS.slice(-2);
|
|
6541
|
+
const copiedGitPath = ({ dir, path }, gitDir, commonDir) => join(dir === "git" ? gitDir : commonDir, ...path);
|
|
6542
|
+
/** What the copy of one selected path carries, as a digest; null when the
|
|
6543
|
+
* path is not there. copyTreeSafe gives a copy the entry's kind, bytes or
|
|
6544
|
+
* target, and the bits of the entry and of everything under it: its exact
|
|
6545
|
+
* digest. copyFileSync follows a link and gives the copy the bytes and the
|
|
6546
|
+
* mode of what it reaches: those. Any failure but "not there" throws, as a
|
|
6547
|
+
* read of the Git state does, and so does a file path that is not a file. */
|
|
6548
|
+
function copiedGitPathDigest(entry, at) {
|
|
6549
|
+
if (!inspectedEntryExists(at)) return null;
|
|
6550
|
+
try {
|
|
6551
|
+
if (entry.copy === "tree") return exactTreeDigest(at);
|
|
6552
|
+
const st = statSync(at);
|
|
6553
|
+
if (!st.isFile()) throw new Error("it is not a file");
|
|
6554
|
+
return createHash("sha256").update(`${st.mode & 0o7777}\0`).update(readFileSync(at)).digest("hex");
|
|
6555
|
+
} catch (e) {
|
|
6556
|
+
if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
|
|
6557
|
+
throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${at}: ${e.message}`);
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
|
|
5792
6561
|
/** Object ids among `oids` that `repo` does not have, from one
|
|
5793
6562
|
* `cat-file --batch-check` process. */
|
|
5794
6563
|
export function missingGitObjects(repo, oids) {
|
|
@@ -5803,7 +6572,7 @@ export function missingGitObjects(repo, oids) {
|
|
|
5803
6572
|
}
|
|
5804
6573
|
|
|
5805
6574
|
function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
5806
|
-
const sourceGit =
|
|
6575
|
+
const sourceGit = copierGitDir(sourceWork);
|
|
5807
6576
|
const recoveredGit = join(recoveredRepo, ".git");
|
|
5808
6577
|
const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
|
|
5809
6578
|
// The staged blobs the recovered index will point at. The clone already
|
|
@@ -5827,11 +6596,13 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
|
5827
6596
|
}
|
|
5828
6597
|
const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
|
|
5829
6598
|
if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
|
|
6599
|
+
// The index is the selection's one exception (COPIED_GIT_PATHS): copied
|
|
6600
|
+
// whole, compared by its entries, resolve-undo records and mode.
|
|
5830
6601
|
copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
|
|
5831
|
-
for (const
|
|
5832
|
-
const source =
|
|
6602
|
+
for (const entry of COPIED_GIT_PATHS.filter((e) => e.dir === "git")) {
|
|
6603
|
+
const source = copiedGitPath(entry, sourceGit);
|
|
5833
6604
|
if (!existsSync(source)) continue;
|
|
5834
|
-
const dest =
|
|
6605
|
+
const dest = copiedGitPath(entry, recoveredGit);
|
|
5835
6606
|
rmSync(dest, { recursive: true, force: true });
|
|
5836
6607
|
copyTreeSafe(source, dest);
|
|
5837
6608
|
}
|
|
@@ -5844,7 +6615,6 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
|
|
|
5844
6615
|
* info/exclude, which keeps Git's precedence (a later pattern wins, and info/exclude outranks
|
|
5845
6616
|
* core.excludesFile). → the sources carried, [{ kind, path }]. */
|
|
5846
6617
|
function carryExcludes(sourceWork, recoveredRepo) {
|
|
5847
|
-
const git = (...args) => execFileSync("git", ["-C", sourceWork, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5848
6618
|
const carried = [], parts = [];
|
|
5849
6619
|
// A file with no pattern line (Git's template info/exclude is comments only) changes nothing: not carried.
|
|
5850
6620
|
const carry = (kind, file) => {
|
|
@@ -5853,12 +6623,9 @@ function carryExcludes(sourceWork, recoveredRepo) {
|
|
|
5853
6623
|
if (!text.split("\n").some((line) => line.trim() && !line.startsWith("#"))) return;
|
|
5854
6624
|
parts.push(text); carried.push({ kind, path: file });
|
|
5855
6625
|
};
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
carry("core.excludesFile", resolve(git("rev-parse", "--show-toplevel"), excludesFile));
|
|
5860
|
-
}
|
|
5861
|
-
carry("info/exclude", join(git("rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"));
|
|
6626
|
+
const excludesFile = copierExcludesFile(sourceWork);
|
|
6627
|
+
if (excludesFile) carry("core.excludesFile", excludesFile);
|
|
6628
|
+
carry("info/exclude", join(copierCommonDir(sourceWork), "info", "exclude"));
|
|
5862
6629
|
if (!carried.length) return carried;
|
|
5863
6630
|
mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
|
|
5864
6631
|
writeFileSync(join(recoveredRepo, ".git", "info", "exclude"), parts.map((t) => (t.endsWith("\n") ? t : `${t}\n`)).join(""));
|
|
@@ -5885,11 +6652,12 @@ function carryStatusConfig(sourceWork, recoveredRepo) {
|
|
|
5885
6652
|
execFileSync("git", ["-C", recoveredRepo, "config", "--local", ...(value === null ? ["--unset-all", key] : [key, value])], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
5886
6653
|
carried.push({ kind: "config", key, value });
|
|
5887
6654
|
}
|
|
5888
|
-
const common =
|
|
5889
|
-
const attributes =
|
|
6655
|
+
const common = copierCommonDir(sourceWork);
|
|
6656
|
+
const attributes = copiedGitPath(GIT_ATTRIBUTES, null, common);
|
|
5890
6657
|
if (existsSync(attributes)) {
|
|
5891
|
-
|
|
5892
|
-
|
|
6658
|
+
const dest = copiedGitPath(GIT_ATTRIBUTES, null, join(recoveredRepo, ".git"));
|
|
6659
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
6660
|
+
copyFileSync(attributes, dest);
|
|
5893
6661
|
carried.push({ kind: "info/attributes", path: attributes });
|
|
5894
6662
|
}
|
|
5895
6663
|
return carried;
|
|
@@ -5901,10 +6669,10 @@ function detachRecoveryClone(source, recovered) {
|
|
|
5901
6669
|
catch { stash = undefined; }
|
|
5902
6670
|
if (stash) {
|
|
5903
6671
|
execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
|
|
5904
|
-
const sourceCommon =
|
|
5905
|
-
const stashLog =
|
|
6672
|
+
const sourceCommon = copierCommonDir(source);
|
|
6673
|
+
const stashLog = copiedGitPath(GIT_STASH_LOG, null, sourceCommon);
|
|
5906
6674
|
if (existsSync(stashLog)) {
|
|
5907
|
-
const recoveredLog = join(recovered, ".git"
|
|
6675
|
+
const recoveredLog = copiedGitPath(GIT_STASH_LOG, null, join(recovered, ".git"));
|
|
5908
6676
|
mkdirSync(dirname(recoveredLog), { recursive: true });
|
|
5909
6677
|
copyFileSync(stashLog, recoveredLog);
|
|
5910
6678
|
}
|
|
@@ -5965,7 +6733,133 @@ function preservedOutputs(work, directory) {
|
|
|
5965
6733
|
return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
|
|
5966
6734
|
}
|
|
5967
6735
|
|
|
5968
|
-
|
|
6736
|
+
/** The home part of a recovery at `dest`: everything but work/ and this pass's
|
|
6737
|
+
* excluded provider-owned entries (observation.homeExclude), verified against
|
|
6738
|
+
* the source with that same set. The copy is hashed whole, so an excluded
|
|
6739
|
+
* entry that reached it fails the verification. The stored digest is what
|
|
6740
|
+
* is compared: it leaves the kernel's own fields of instance.json out, so a
|
|
6741
|
+
* kernel write there between the copy and this check is not a disagreement.
|
|
6742
|
+
* That is an inherited limit, and this is not an exact verification of the
|
|
6743
|
+
* whole home: the stored digest also passes over the kernel's receipts and
|
|
6744
|
+
* the harness settings, reads instance.json through a parse, and frames its
|
|
6745
|
+
* entries without lengths. */
|
|
6746
|
+
function copyRecoveryHome(observation, dest) {
|
|
6747
|
+
const excludeRoot = observation.homeExclude || new Set(["work"]);
|
|
6748
|
+
copyRecoveryTree(observation.home, dest, { excludeRoot });
|
|
6749
|
+
if (fingerprintTree(observation.home, { excludeRoot, instanceHome: true }) !== fingerprintTree(dest, { instanceHome: true })) {
|
|
6750
|
+
throw new Error("home recovery verification disagreed with the source");
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
6753
|
+
|
|
6754
|
+
/** The work part of a recovery under `parent`, copied and verified: `repo/`, a
|
|
6755
|
+
* standalone clone of a worktree instance's repository carrying its
|
|
6756
|
+
* uncommitted state, or `work/`, a directory instance's bytes. A worktree's
|
|
6757
|
+
* source bytes are the observation's (observedWorkBytes), so the verified
|
|
6758
|
+
* value is also its byte state. → { copied, branchDrift?, excludes?,
|
|
6759
|
+
* statusConfig? }; `copied` is false where neither applies (another work
|
|
6760
|
+
* mode, or a worktree home with no recorded repo or branch). A HEAD that
|
|
6761
|
+
* cannot be read is an inspection failure, not a failed copy: its error is
|
|
6762
|
+
* put in `unreadableHeads`, and the callers throw it as it is. */
|
|
6763
|
+
const unreadableHeads = new WeakSet();
|
|
6764
|
+
function copyRecoveryWork(observation, meta, parent) {
|
|
6765
|
+
let branchDrift, excludes, statusConfig, copied = false;
|
|
6766
|
+
if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
6767
|
+
const recoveredRepo = join(parent, "repo");
|
|
6768
|
+
// The branch is derived from the worktree while it exists: an instance
|
|
6769
|
+
// that legitimately switched branches during its task must still be
|
|
6770
|
+
// recoverable, and the recorded spawn-time branch is only the fallback
|
|
6771
|
+
// when the worktree is gone. A HEAD that is detached, or on a ref OATS
|
|
6772
|
+
// carries no name for, recovers detached at its exact commit.
|
|
6773
|
+
let ref;
|
|
6774
|
+
try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
|
|
6775
|
+
catch (e) { unreadableHeads.add(e); throw e; }
|
|
6776
|
+
if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
|
|
6777
|
+
if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6778
|
+
else {
|
|
6779
|
+
execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6780
|
+
execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
6781
|
+
execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
6782
|
+
}
|
|
6783
|
+
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
6784
|
+
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
6785
|
+
if (existsSync(observation.work)) {
|
|
6786
|
+
restoreStandaloneGitState(observation.work, recoveredRepo);
|
|
6787
|
+
excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
|
|
6788
|
+
statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
|
|
6789
|
+
for (const e of readdirSync(observation.work, { withFileTypes: true })) {
|
|
6790
|
+
if (e.name === ".git") continue;
|
|
6791
|
+
const dest = join(recoveredRepo, e.name);
|
|
6792
|
+
rmSync(dest, { recursive: true, force: true });
|
|
6793
|
+
copyTreeSafe(join(observation.work, e.name), dest);
|
|
6794
|
+
}
|
|
6795
|
+
const nested = materializeNestedRepositories(observation.work, recoveredRepo);
|
|
6796
|
+
excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
|
|
6797
|
+
}
|
|
6798
|
+
if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
|
|
6799
|
+
if (existsSync(observation.work)) {
|
|
6800
|
+
// The source's bytes as this observation holds them: read before the
|
|
6801
|
+
// pre-hook recovery was written, or by the proof that the hooks moved
|
|
6802
|
+
// nothing, or else here. Either way they are kept as the observation's
|
|
6803
|
+
// byte state.
|
|
6804
|
+
if ((observation.worktree ? observedWorkBytes(observation) : worktreeBytes(observation.work)) !== worktreeBytes(recoveredRepo)) throw new Error("worktree recovery verification disagreed with the source");
|
|
6805
|
+
assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
|
|
6806
|
+
}
|
|
6807
|
+
// The proof of the copy: its HEAD is the commit the worktree has checked
|
|
6808
|
+
// out. A branch of the repository can be at another commit with the
|
|
6809
|
+
// same files, so the branch's tip there proves nothing about the copy.
|
|
6810
|
+
// With the worktree gone there is no such commit to read, and the copy
|
|
6811
|
+
// is the recorded branch as the repository has it.
|
|
6812
|
+
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6813
|
+
const sourceHead = ref.commit
|
|
6814
|
+
?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6815
|
+
if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
|
|
6816
|
+
copied = true;
|
|
6817
|
+
}
|
|
6818
|
+
if (observation.directory && observation.directoryFingerprint) {
|
|
6819
|
+
const recoveredWork = join(parent, "work");
|
|
6820
|
+
copyTreeSafe(observation.work, recoveredWork);
|
|
6821
|
+
if (directoryBytes(observation.work) !== observation.directoryFingerprint || directoryBytes(recoveredWork) !== observation.directoryFingerprint) {
|
|
6822
|
+
throw new Error("directory recovery verification disagreed with the inspected source");
|
|
6823
|
+
}
|
|
6824
|
+
copied = true;
|
|
6825
|
+
}
|
|
6826
|
+
return { copied, branchDrift, excludes, statusConfig };
|
|
6827
|
+
}
|
|
6828
|
+
|
|
6829
|
+
/** The top-level entries of a recovery's home snapshot with their bytes,
|
|
6830
|
+
* largest first; a directory ends in `/`. What the retire summary names as
|
|
6831
|
+
* copied from the home. */
|
|
6832
|
+
function preservedHome(recoveredHome) {
|
|
6833
|
+
const paths = readdirSync(recoveredHome, { withFileTypes: true })
|
|
6834
|
+
.map((e) => ({ path: e.isDirectory() ? `${e.name}/` : e.name, bytes: treeBytes(join(recoveredHome, e.name)) }))
|
|
6835
|
+
.sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
|
|
6836
|
+
return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
|
|
6837
|
+
}
|
|
6838
|
+
|
|
6839
|
+
/** Whether a recovery of this observation holds the home only. A home-only
|
|
6840
|
+
* change (notes, harness files, credentials) needs a home snapshot, not
|
|
6841
|
+
* another copy of an otherwise disposable clean worktree. In-progress Git
|
|
6842
|
+
* operations retain the full standalone recovery even when porcelain status
|
|
6843
|
+
* has no changed paths. An orphaned work directory (no git admin entry) stays
|
|
6844
|
+
* where it is, never moved or removed, and git cannot read it: only the home
|
|
6845
|
+
* is snapshotted. A worktree that cannot be proven unchanged (workProvable
|
|
6846
|
+
* false) is never home-only: after the hooks it could be neither proven nor
|
|
6847
|
+
* taken as copied, so its work goes into the snapshot before them. */
|
|
6848
|
+
function homeOnlyRecovery(observation, meta) {
|
|
6849
|
+
if (observation.orphanedWork === true) return true;
|
|
6850
|
+
if (observation.workProvable === false) return false;
|
|
6851
|
+
if (!(observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes" && meta.work === "worktree" && existsSync(observation.work))) return false;
|
|
6852
|
+
const gitDir = copierGitDir(observation.work);
|
|
6853
|
+
return !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6856
|
+
/** Write one verified recovery of an observed home and work: staged beside its
|
|
6857
|
+
* final name and renamed into place. `phase` goes into recovery.json:
|
|
6858
|
+
* "before-hooks" for the snapshot a retirement takes before its retire hooks
|
|
6859
|
+
* (completeRetirementRecovery concludes it), "complete" for a recovery nothing
|
|
6860
|
+
* will add to. → { receipt, manifest }: the receipt retire reports, and
|
|
6861
|
+
* recovery.json as written. */
|
|
6862
|
+
function writeRetirementRecovery(observation, meta, instance, phase) {
|
|
5969
6863
|
const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
|
|
5970
6864
|
mkdirSync(recoveryRoot, { recursive: true });
|
|
5971
6865
|
if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
|
|
@@ -5973,98 +6867,115 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5973
6867
|
}
|
|
5974
6868
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
5975
6869
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
5976
|
-
let headUnreadable;
|
|
5977
6870
|
try {
|
|
5978
|
-
|
|
5979
|
-
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
5980
|
-
// In-progress Git operations retain the full standalone recovery even
|
|
5981
|
-
// when porcelain status has no changed paths.
|
|
5982
|
-
// An orphaned work directory (no git admin entry) stays where it is, never moved or removed, and git
|
|
5983
|
-
// cannot read it: only the home is snapshotted.
|
|
5984
|
-
let homeOnly = observation.orphanedWork === true || (observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
|
|
5985
|
-
&& meta.work === "worktree" && existsSync(observation.work));
|
|
5986
|
-
if (homeOnly && !observation.orphanedWork) {
|
|
5987
|
-
const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5988
|
-
homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
|
|
5989
|
-
}
|
|
6871
|
+
const homeOnly = homeOnlyRecovery(observation, meta);
|
|
5990
6872
|
const recoveredHome = join(staging, "home");
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
throw new Error("home recovery verification disagreed with the source");
|
|
5994
|
-
}
|
|
5995
|
-
let branchDrift, excludes, statusConfig;
|
|
5996
|
-
if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
|
|
5997
|
-
const recoveredRepo = join(staging, "repo");
|
|
5998
|
-
// The branch is derived from the worktree while it exists: an instance
|
|
5999
|
-
// that legitimately switched branches during its task must still be
|
|
6000
|
-
// recoverable, and the recorded spawn-time branch is only the fallback
|
|
6001
|
-
// when the worktree is gone. A HEAD that is detached, or on a ref OATS
|
|
6002
|
-
// carries no name for, recovers detached at its exact commit. A HEAD
|
|
6003
|
-
// that cannot be read is an inspection failure, not a failed copy.
|
|
6004
|
-
let ref;
|
|
6005
|
-
try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
|
|
6006
|
-
catch (e) { headUnreadable = e; throw e; }
|
|
6007
|
-
if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
|
|
6008
|
-
if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6009
|
-
else {
|
|
6010
|
-
execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
6011
|
-
execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
6012
|
-
execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
6013
|
-
}
|
|
6014
|
-
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
6015
|
-
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
6016
|
-
if (existsSync(observation.work)) {
|
|
6017
|
-
restoreStandaloneGitState(observation.work, recoveredRepo);
|
|
6018
|
-
excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
|
|
6019
|
-
statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
|
|
6020
|
-
for (const e of readdirSync(observation.work, { withFileTypes: true })) {
|
|
6021
|
-
if (e.name === ".git") continue;
|
|
6022
|
-
const dest = join(recoveredRepo, e.name);
|
|
6023
|
-
rmSync(dest, { recursive: true, force: true });
|
|
6024
|
-
copyTreeSafe(join(observation.work, e.name), dest);
|
|
6025
|
-
}
|
|
6026
|
-
const nested = materializeNestedRepositories(observation.work, recoveredRepo);
|
|
6027
|
-
excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
|
|
6028
|
-
}
|
|
6029
|
-
if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
|
|
6030
|
-
if (existsSync(observation.work)) {
|
|
6031
|
-
if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
|
|
6032
|
-
assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
|
|
6033
|
-
}
|
|
6034
|
-
// The proof of the copy: its HEAD is the commit the worktree has checked
|
|
6035
|
-
// out. A branch of the repository can be at another commit with the
|
|
6036
|
-
// same files, so the branch's tip there proves nothing about the copy.
|
|
6037
|
-
// With the worktree gone there is no such commit to read, and the copy
|
|
6038
|
-
// is the recorded branch as the repository has it.
|
|
6039
|
-
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6040
|
-
const sourceHead = ref.commit
|
|
6041
|
-
?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6042
|
-
if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
|
|
6043
|
-
}
|
|
6044
|
-
if (observation.directory && observation.directoryFingerprint) {
|
|
6045
|
-
const recoveredWork = join(staging, "work");
|
|
6046
|
-
copyTreeSafe(observation.work, recoveredWork);
|
|
6047
|
-
if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
|
|
6048
|
-
throw new Error("directory recovery verification disagreed with the inspected source");
|
|
6049
|
-
}
|
|
6050
|
-
}
|
|
6873
|
+
copyRecoveryHome(observation, recoveredHome);
|
|
6874
|
+
const { branchDrift, excludes, statusConfig } = homeOnly ? {} : copyRecoveryWork(observation, meta, staging);
|
|
6051
6875
|
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;
|
|
6052
|
-
// What the copy cost: the untracked/ignored (or directory) outputs
|
|
6876
|
+
// What the copy cost: the home entries it carries and the untracked/ignored (or directory) outputs, named.
|
|
6877
|
+
const home = preservedHome(recoveredHome);
|
|
6053
6878
|
const workCopied = observation.directory ? !!observation.directoryFingerprint : !homeOnly && meta.work === "worktree" && existsSync(observation.work);
|
|
6054
6879
|
const outputs = workCopied ? preservedOutputs(observation.work, observation.directory) : undefined;
|
|
6055
|
-
|
|
6880
|
+
const notCopied = observation.notCopied?.length ? observation.notCopied : undefined;
|
|
6881
|
+
const manifest = { version: 1, phase, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), home, ...(outputs ? { outputs } : {}), ...(notCopied ? { notCopied } : {}) };
|
|
6882
|
+
writeFileSync(join(staging, "recovery.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
|
6056
6883
|
const bytes = treeBytes(staging);
|
|
6057
6884
|
mkdirSync(dirname(recovery), { recursive: true });
|
|
6058
6885
|
renameSync(staging, recovery);
|
|
6059
|
-
return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
|
|
6886
|
+
return { receipt: { path: recovery, classes: observation.classes, bytes, home, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}), ...(notCopied ? { notCopied } : {}) }, manifest };
|
|
6060
6887
|
} catch (e) {
|
|
6061
6888
|
rmSync(staging, { recursive: true, force: true });
|
|
6062
|
-
if (e
|
|
6889
|
+
if (unreadableHeads.has(e)) throw e;
|
|
6063
6890
|
const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
|
|
6064
6891
|
throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
|
|
6065
6892
|
}
|
|
6066
6893
|
}
|
|
6067
6894
|
|
|
6895
|
+
/** One complete recovery, for a caller with no retire hooks still to run
|
|
6896
|
+
* between the snapshot and the removal. → its receipt. */
|
|
6897
|
+
function preserveRetirementWork(observation, meta, instance) {
|
|
6898
|
+
return writeRetirementRecovery(observation, meta, instance, "complete").receipt;
|
|
6899
|
+
}
|
|
6900
|
+
|
|
6901
|
+
/** The post-hook check on the recovery a retirement wrote before its retire
|
|
6902
|
+
* hooks. Each part a hook moved (movedByHooks: the home by its bytes, the work
|
|
6903
|
+
* unless it is proven unchanged) is copied again, whole and verified, under
|
|
6904
|
+
* `after-hooks/`; nothing under the pre-hook `home/`, `repo/` or `work/` is
|
|
6905
|
+
* written, moved or removed. The home is not copied again merely because the
|
|
6906
|
+
* work is: a home whose every byte the hooks left as it was (its exact
|
|
6907
|
+
* digest) is in the pre-hook snapshot as it still is.
|
|
6908
|
+
* `after-hooks/` is staged inside the recovery under a dot-name and renamed
|
|
6909
|
+
* into place; an `after-hooks` entry that already exists is a failure, never
|
|
6910
|
+
* replaced. Then ONE atomic rewrite of recovery.json records `afterHooks` and
|
|
6911
|
+
* moves `phase` to "complete" (the same rewrite, without `afterHooks`, when
|
|
6912
|
+
* the hooks changed nothing). Until that rewrite the manifest says
|
|
6913
|
+
* "before-hooks", so an interrupted pass under-reports and can never read as
|
|
6914
|
+
* complete. A failure removes the staging, leaves the pre-hook snapshot and
|
|
6915
|
+
* its manifest as they were, and refuses: the caller keeps the home.
|
|
6916
|
+
* Directory mode: the retire hooks ran since the pre-hook pass verified that
|
|
6917
|
+
* the recovery sits in the owned storage beside the home, so that is verified
|
|
6918
|
+
* again before anything is written through its path.
|
|
6919
|
+
* → the receipt, updated: `classes` and `notCopied` are the union of both
|
|
6920
|
+
* passes, `bytes` covers the whole directory. */
|
|
6921
|
+
function completeRetirementRecovery({ receipt, manifest }, before, after, meta) {
|
|
6922
|
+
const recovery = receipt.path;
|
|
6923
|
+
const moved = movedByHooks(before, after);
|
|
6924
|
+
const wantHome = moved.home;
|
|
6925
|
+
const final = join(recovery, "after-hooks");
|
|
6926
|
+
const manifestPath = join(recovery, "recovery.json");
|
|
6927
|
+
const manifestTmp = join(recovery, `.recovery.json.${process.pid}.tmp`);
|
|
6928
|
+
const occupied = () => { try { lstatSync(final); return true; } catch { return false; } };
|
|
6929
|
+
const assertOwnedStorage = () => {
|
|
6930
|
+
if (!after.directory) return;
|
|
6931
|
+
let owned = false;
|
|
6932
|
+
try { owned = realpathSync(recovery) === join(realpathSync(dirname(after.home)), ".oats-retirement", "recovery", basename(recovery)); } catch { /* gone: not the owned storage */ }
|
|
6933
|
+
if (!owned) throw new Error("directory recovery storage was redirected");
|
|
6934
|
+
};
|
|
6935
|
+
let staging, manifestStarted = false;
|
|
6936
|
+
try {
|
|
6937
|
+
assertOwnedStorage();
|
|
6938
|
+
// Work that is not proven unchanged is copied. So is work this recovery does
|
|
6939
|
+
// not hold yet: a pre-hook snapshot that was home-only stands for no work,
|
|
6940
|
+
// so "unchanged" proves nothing about it. When the observation after the
|
|
6941
|
+
// hooks is not home-only by that pass's own rule, the work is copied, moved
|
|
6942
|
+
// or not: a class can appear from outside the work state (the baseline is
|
|
6943
|
+
// gone). That rule only ever adds a copy attempt here; it never skips one.
|
|
6944
|
+
// It asks Git, so it is decided in here: a failure refuses like any other
|
|
6945
|
+
// in this pass.
|
|
6946
|
+
const firstWorkCopy = receipt.repoCopy?.copied === false && after.classes.length > 0 && !homeOnlyRecovery(after, meta);
|
|
6947
|
+
const wantWork = !after.orphanedWork && (moved.work || firstWorkCopy);
|
|
6948
|
+
let afterHooks;
|
|
6949
|
+
if (wantHome || wantWork) {
|
|
6950
|
+
if (occupied()) throw new Error(`${final} already exists`);
|
|
6951
|
+
staging = mkdtempSync(join(recovery, ".after-hooks-"));
|
|
6952
|
+
if (wantHome) copyRecoveryHome(after, join(staging, "home"));
|
|
6953
|
+
const workCopied = wantWork && copyRecoveryWork(after, meta, staging).copied;
|
|
6954
|
+
if (wantHome || workCopied) {
|
|
6955
|
+
if (occupied()) throw new Error(`${final} already exists`);
|
|
6956
|
+
renameSync(staging, final);
|
|
6957
|
+
afterHooks = { home: wantHome, work: workCopied };
|
|
6958
|
+
} else rmSync(staging, { recursive: true, force: true });
|
|
6959
|
+
staging = undefined;
|
|
6960
|
+
}
|
|
6961
|
+
const classes = [...new Set([...receipt.classes, ...after.classes])];
|
|
6962
|
+
const notCopied = unionNotCopied(receipt.notCopied, after.notCopied);
|
|
6963
|
+
const { notCopied: _before, ...rest } = manifest;
|
|
6964
|
+
assertOwnedStorage();
|
|
6965
|
+
manifestStarted = true;
|
|
6966
|
+
writeFileSync(manifestTmp, JSON.stringify({ ...rest, phase: "complete", classes, ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) }, null, 2) + "\n", { mode: 0o600 });
|
|
6967
|
+
renameSync(manifestTmp, manifestPath);
|
|
6968
|
+
const { notCopied: _was, ...kept } = receipt;
|
|
6969
|
+
return { ...kept, classes, bytes: treeBytes(recovery), ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) };
|
|
6970
|
+
} catch (e) {
|
|
6971
|
+
if (staging) rmSync(staging, { recursive: true, force: true });
|
|
6972
|
+
if (manifestStarted) rmSync(manifestTmp, { force: true });
|
|
6973
|
+
if (unreadableHeads.has(e)) throw e;
|
|
6974
|
+
const details = e.statusDisagreement ? { home: after.home, statusDisagreement: e.statusDisagreement } : undefined;
|
|
6975
|
+
throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${after.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
|
|
6976
|
+
}
|
|
6977
|
+
}
|
|
6978
|
+
|
|
6068
6979
|
/** Marker a self-retiring instance leaves BESIDE its home: the retirement is
|
|
6069
6980
|
* requested and owed, and a detached completion is on its way. It is not
|
|
6070
6981
|
* written into the home, so the caller changes no instance bytes and the
|
|
@@ -6361,6 +7272,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6361
7272
|
// `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
|
|
6362
7273
|
// A no-launch instance is already quiesced. A launched one must have exact
|
|
6363
7274
|
// window absence established before recovery copying begins.
|
|
7275
|
+
let sessionStopped = false;
|
|
6364
7276
|
if (!self && runtimeAuthority?.launched) {
|
|
6365
7277
|
const runtimeSession = runtimeAuthority.tmux.session;
|
|
6366
7278
|
const runtimeWindow = runtimeAuthority.tmux.window;
|
|
@@ -6384,11 +7296,41 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6384
7296
|
if (scan.processes.length) throw unestablished(`${detail}; a process still works in this home (${scan.processes.slice(0, 5).map((p) => `pid ${p.pid} ${p.command}`).join(", ")}${scan.processes.length > 5 ? `, and ${scan.processes.length - 5} more` : ""})`);
|
|
6385
7297
|
}
|
|
6386
7298
|
}
|
|
7299
|
+
sessionStopped = true;
|
|
6387
7300
|
}
|
|
6388
7301
|
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
7302
|
+
// The bytes of the worktree as they are before the hooks: after the hooks,
|
|
7303
|
+
// "the work did not move" is then proven, never taken from a status text.
|
|
7304
|
+
// Read before the recovery is written, so that a worktree that cannot be
|
|
7305
|
+
// read (an entry that is not a file, a directory or a symbolic link)
|
|
7306
|
+
// refuses here and leaves no recovery behind, however often it is retried.
|
|
7307
|
+
// The refusal says what this retire has and has not done by now: a launched
|
|
7308
|
+
// instance's session was stopped above. A pre-hook copy of the work
|
|
7309
|
+
// verifies itself against this same read. A worktree that cannot be proven
|
|
7310
|
+
// unchanged is copied again in any case, and with nothing to preserve
|
|
7311
|
+
// before the hooks there is nothing to prove: any class after them is
|
|
7312
|
+
// preserved.
|
|
7313
|
+
// What a refusal before the hooks tells the operator about the retire so far.
|
|
7314
|
+
const soFar = (kept) => `${name} is not retired and ${kept}; ${sessionStopped ? "its session has been stopped" : "this retire stopped no session"}`;
|
|
7315
|
+
if (stableObservation.classes.length && stableObservation.workProvable) {
|
|
7316
|
+
try { observedWorkBytes(stableObservation); }
|
|
7317
|
+
catch (e) {
|
|
7318
|
+
throw oatsError(e.code, `${e.message}. No recovery was written and nothing was deleted: ${soFar("its home is kept")}`);
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
// One recovery per retirement. The snapshot taken here, before the retire
|
|
7322
|
+
// hooks, is the gate: it is verified before any hook runs and is never
|
|
7323
|
+
// rewritten. What the hooks change is added to it afterwards. A copy that
|
|
7324
|
+
// cannot be made or verified refuses here, with the same disclosure.
|
|
7325
|
+
let preHookRecovery;
|
|
7326
|
+
if (stableObservation.classes.length) {
|
|
7327
|
+
try { preHookRecovery = writeRetirementRecovery(stableObservation, meta, name, "before-hooks"); }
|
|
7328
|
+
catch (e) {
|
|
7329
|
+
if (e?.code !== "E_WORK_PRESERVATION_FAILED") throw e;
|
|
7330
|
+
throw Object.assign(oatsError(e.code, `${e.message}. No retire hook has run, no recovery was written and nothing was deleted: ${soFar("its home and work are kept")}`, e.provenance), e.details ? { details: e.details } : {});
|
|
7331
|
+
}
|
|
7332
|
+
}
|
|
7333
|
+
let workRecovery = preHookRecovery?.receipt;
|
|
6392
7334
|
|
|
6393
7335
|
// Capability lifecycle hooks (retire) — run BEFORE the dir (and any package state in it,
|
|
6394
7336
|
// e.g. aweb signing keys) is removed. The knowledge integration harvests notes/ here;
|
|
@@ -6463,11 +7405,19 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6463
7405
|
}
|
|
6464
7406
|
|
|
6465
7407
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
6466
|
-
// them
|
|
7408
|
+
// them. A recovery written before the hooks gains a separately verified
|
|
7409
|
+
// snapshot of each part they moved, under its after-hooks/, and its manifest
|
|
7410
|
+
// is concluded; a home that only now has something to preserve gets its one
|
|
7411
|
+
// recovery here. Nothing was preserved before the hooks only when there was
|
|
7412
|
+
// no class then, so a class now is something new to preserve, whether it
|
|
7413
|
+
// came from the home, from the work or from outside both (another ref, the
|
|
7414
|
+
// baseline): it does not have to be a move of the observed state. Either
|
|
7415
|
+
// way this finishes, or refuses and keeps the home, before the worktree
|
|
7416
|
+
// step and before the home is removed.
|
|
6467
7417
|
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
6468
|
-
if (
|
|
6469
|
-
|
|
6470
|
-
workRecovery =
|
|
7418
|
+
if (preHookRecovery) workRecovery = completeRetirementRecovery(preHookRecovery, stableObservation, finalObservation, meta);
|
|
7419
|
+
else if (finalObservation.classes.length) {
|
|
7420
|
+
workRecovery = preserveRetirementWork({ ...finalObservation, notCopied: unionNotCopied(stableObservation.notCopied, finalObservation.notCopied) }, meta, name);
|
|
6471
7421
|
}
|
|
6472
7422
|
|
|
6473
7423
|
// Lineage repair: any instance pointing at the retiree (parentInstance from a
|
|
@@ -6673,7 +7623,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6673
7623
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6674
7624
|
}
|
|
6675
7625
|
|
|
6676
|
-
const result = { retired: name, agent: found.agent.name, workRecovery,
|
|
7626
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
|
|
6677
7627
|
const w = [...(hookResults?.warnings || [])];
|
|
6678
7628
|
if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
6679
7629
|
if (isCapturedHome(meta) && !quarantine) {
|