utils/lifecycle.ts
Rama main · réplica de lectura del repositorio original.
import type { ShellPermission } from "../external.ts";
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
import { capOutput, runSandboxed } from "../mcp/shell.ts";
import { log } from "./cli.ts";
import { resolveEnv } from "./secrets.ts";
import { $ } from "./shell.ts";
export interface ExecuteLifecycleHookParams {
event: string;
script: string | null;
/**
* the repo's shell permission, which decides the hook's environment on the
* same terms as the `shell` tool: `enabled` inherits the full process env,
* anything else gets the default-deny allowlist (safe set + the repo's
* `envAllowlist`).
*
* hooks are maintainer-authored, but the code they invoke is not — after
* `checkout_pr` on a fork PR, a `prepushScript` of `pnpm test` runs the
* contributor's script and test files. so the hook is treated as untrusted
* execution and gets the same sandbox + env scrub as `shell`.
*/
shell: ShellPermission;
/**
* when true, after the hook runs (success or failure), discard tracked-file
* mods so the agent doesn't see hook-generated drift (e.g. `pnpm install`
* rewriting a lockfile). untracked files are preserved — hooks that
* intentionally materialize files (e.g. a `.env` from a template) stay
* visible to the agent. skipped (with a warning) if the tree had
* pre-existing tracked changes before the hook ran, so we never clobber
* pre-existing work; pre-existing untracked files are ignored for this
* gate because `git restore --staged --worktree .` doesn't touch them
* anyway. no-op when no script was configured.
*/
normalizeWorkingTreeAfter?: boolean;
}
/** structured failure info — `output` on the `exit` variant is trimmed
* stderr, falling back to stdout when stderr is empty. */
export type LifecycleHookFailure =
| { kind: "exit"; exitCode: number; output: string }
| { kind: "timeout" }
| { kind: "spawn"; spawnError: string };
/** one-line, agent-facing description of a hook failure. empty string when
* there was no failure, so callers can pass the result straight through to a
* prompt section that omits itself on empty. */
export function describeSetupFailure(failure: LifecycleHookFailure | undefined): string {
if (!failure) return "";
switch (failure.kind) {
case "exit":
return `It exited with code ${failure.exitCode}. Output:\n\n${failure.output || "(empty)"}`;
case "timeout":
return "It timed out and was killed before completing.";
case "spawn":
return `It failed to start: ${failure.spawnError}`;
default: {
const _exhaustive: never = failure;
return _exhaustive satisfies never;
}
}
}
export interface LifecycleHookResult {
/**
* human-readable warning when the hook failed. includes retry guidance:
* transient spawn/exit errors are worth retrying, timeouts and
* persistent failures are not. absent when the hook succeeded or was
* skipped. setup/post-checkout callers surface this verbatim; prepush
* builds its own message from `failure` instead.
*/
warning?: string;
/**
* structured failure info — undefined when the hook succeeded or was
* skipped. lets callers compose their own messaging without parsing the
* `warning` string.
*/
failure?: LifecycleHookFailure;
}
/**
* execute a lifecycle hook script if one is configured.
*
* soft-fails: instead of throwing on hook errors, returns a warning string
* (and structured failure info) so callers can choose how to surface it
* (mcp tools relay it to the agent; setup logs it and adds a prompt banner).
* timeouts are flagged as non-retryable in the warning text.
*/
export async function executeLifecycleHook(
params: ExecuteLifecycleHookParams
): Promise<LifecycleHookResult> {
if (!params.script) return {};
log.info(`» executing ${params.event} lifecycle hook...`);
// snapshot tracked-file mods BEFORE the hook runs so we can distinguish
// hook-generated drift from pre-existing work. both hook windows should
// start clean in normal operation (setup runs before any working-tree
// writes; checkout_pr refuses to run with a dirty tree), but if that
// invariant breaks we'd rather warn than discard whatever was there.
// pre-existing untracked files don't matter here — `git restore --staged
// --worktree .` never touches untracked files, so they're never at risk.
const preHookTrackedCount = params.normalizeWorkingTreeAfter
? (await runGitLines(["diff", "--name-only", "HEAD"])).length
: 0;
// single try/finally so normalization fires on success AND failure paths.
// a hook that fails partway through (e.g. `pnpm install` updates the
// lockfile then explodes on a peer-dep conflict) leaves the same kind of
// drift a successful run does, and the agent will see it next regardless
// of which path we took. failure-mode messaging is unchanged; the only
// delta is that we don't return tracked drift to the agent.
let result: LifecycleHookResult;
try {
// `spawnShell` throws outright when CI offers no PID-namespace isolation,
// so the soft-fail contract has to cover the spawn itself, not just a
// non-zero exit — otherwise an unsandboxable self-hosted runner turns a
// best-effort hook into a dead run.
let sandboxed: Awaited<ReturnType<typeof runSandboxed>>;
try {
sandboxed = await runSandboxed({
command: params.script,
env: resolveEnv(params.shell === "enabled" ? "inherit" : "restricted"),
cwd: process.cwd(),
seal: params.shell !== "enabled",
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
failure: { kind: "spawn", spawnError: msg },
warning: `lifecycle hook '${params.event}' could not be sandboxed, so it did not run: ${msg}`,
};
}
if (sandboxed.timedOut) {
const minutes = Math.round(LIFECYCLE_HOOK_TIMEOUT_MS / 60000);
result = {
failure: { kind: "timeout" },
warning:
`lifecycle hook '${params.event}' timed out after ${minutes}min. ` +
`do NOT retry — the script is likely hung or doing too much work. ` +
`ask the repo owner to simplify the hook (e.g. move long-running work out of the hook, add caching, or split it).`,
};
} else if (sandboxed.exitCode === -1) {
// runSandboxed folds a spawn failure into the output and reports -1;
// no exit code ever reaches that value from a real process.
result = {
failure: { kind: "spawn", spawnError: sandboxed.output },
warning:
`lifecycle hook '${params.event}' failed to spawn: ${sandboxed.output}. ` +
`this is likely a transient failure — retry the operation.`,
};
} else if (sandboxed.exitCode !== 0) {
// the buffer retains up to 8MB; `describeSetupFailure` inlines this into
// the agent prompt, so cap it the same way the shell tool does.
const output = capOutput(sandboxed.output);
result = {
failure: { kind: "exit", output, exitCode: sandboxed.exitCode },
warning:
`lifecycle hook '${params.event}' failed with exit code ${sandboxed.exitCode}. ` +
`output: ${output || "(empty)"}. ` +
`retry the operation if the failure looks flaky (network blips, transient rate limits). ` +
`do NOT retry if the script is broken (missing commands, syntax errors) or the error is persistent. ` +
`if it failed on a missing environment variable, the repo's env allowlist has to name it — hooks run with the same filtered env as the shell tool.`,
};
} else {
log.info(`» ${params.event} lifecycle hook completed successfully`);
result = {};
}
} finally {
if (params.normalizeWorkingTreeAfter) {
await normalizeWorkingTreeAfterHook({ event: params.event, preHookTrackedCount });
}
}
return result;
}
/**
* discard tracked-file mods left by a lifecycle hook so the agent's next
* `git status` matches the pre-hook state. untracked files (e.g. a `.env`
* the hook materialized from a template) are left alone — the agent decides
* what to do with them. skipped (with a warning) when the tree had
* pre-existing tracked changes before the hook ran, so pre-existing work
* is never clobbered. idempotent: a second call on a clean tree is a no-op
* and stays quiet.
*/
async function normalizeWorkingTreeAfterHook(params: {
event: string;
preHookTrackedCount: number;
}): Promise<void> {
if (params.preHookTrackedCount > 0) {
log.warning(
`» working tree had ${params.preHookTrackedCount} pre-existing tracked changes before ${params.event} hook; ` +
`skipping post-hook normalization to avoid clobbering pre-existing work`
);
return;
}
const trackedCount = (await runGitLines(["diff", "--name-only", "HEAD"])).length;
if (trackedCount === 0) return;
await runGit(["restore", "--staged", "--worktree", "."]);
log.info(`» discarded ${trackedCount} tracked changes from ${params.event} hook`);
}
async function runGit(args: string[]): Promise<string> {
return $("git", args, { log: false });
}
async function runGitLines(args: string[]): Promise<string[]> {
return (await runGit(args)).split("\n").filter(Boolean);
}