feat: add gateway config/update restart flow
This commit is contained in:
68
src/infra/restart-sentinel.test.ts
Normal file
68
src/infra/restart-sentinel.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
consumeRestartSentinel,
|
||||
readRestartSentinel,
|
||||
resolveRestartSentinelPath,
|
||||
trimLogTail,
|
||||
writeRestartSentinel,
|
||||
} from "./restart-sentinel.js";
|
||||
|
||||
describe("restart sentinel", () => {
|
||||
let prevStateDir: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sentinel-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevStateDir) process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
else delete process.env.CLAWDBOT_STATE_DIR;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes and consumes a sentinel", async () => {
|
||||
const payload = {
|
||||
kind: "update" as const,
|
||||
status: "ok" as const,
|
||||
ts: Date.now(),
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
stats: { mode: "git" },
|
||||
};
|
||||
const filePath = await writeRestartSentinel(payload);
|
||||
expect(filePath).toBe(resolveRestartSentinelPath());
|
||||
|
||||
const read = await readRestartSentinel();
|
||||
expect(read?.payload.kind).toBe("update");
|
||||
|
||||
const consumed = await consumeRestartSentinel();
|
||||
expect(consumed?.payload.sessionKey).toBe(payload.sessionKey);
|
||||
|
||||
const empty = await readRestartSentinel();
|
||||
expect(empty).toBeNull();
|
||||
});
|
||||
|
||||
it("drops invalid sentinel payloads", async () => {
|
||||
const filePath = resolveRestartSentinelPath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, "not-json", "utf-8");
|
||||
|
||||
const read = await readRestartSentinel();
|
||||
expect(read).toBeNull();
|
||||
|
||||
await expect(fs.stat(filePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("trims log tails", () => {
|
||||
const text = "a".repeat(9000);
|
||||
const trimmed = trimLogTail(text, 8000);
|
||||
expect(trimmed?.length).toBeLessThanOrEqual(8001);
|
||||
expect(trimmed?.startsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
116
src/infra/restart-sentinel.ts
Normal file
116
src/infra/restart-sentinel.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
|
||||
export type RestartSentinelLog = {
|
||||
stdoutTail?: string | null;
|
||||
stderrTail?: string | null;
|
||||
exitCode?: number | null;
|
||||
};
|
||||
|
||||
export type RestartSentinelStep = {
|
||||
name: string;
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
durationMs?: number | null;
|
||||
log?: RestartSentinelLog | null;
|
||||
};
|
||||
|
||||
export type RestartSentinelStats = {
|
||||
mode?: string;
|
||||
root?: string;
|
||||
before?: Record<string, unknown> | null;
|
||||
after?: Record<string, unknown> | null;
|
||||
steps?: RestartSentinelStep[];
|
||||
reason?: string | null;
|
||||
durationMs?: number | null;
|
||||
};
|
||||
|
||||
export type RestartSentinelPayload = {
|
||||
kind: "config-apply" | "update";
|
||||
status: "ok" | "error" | "skipped";
|
||||
ts: number;
|
||||
sessionKey?: string;
|
||||
message?: string | null;
|
||||
stats?: RestartSentinelStats | null;
|
||||
};
|
||||
|
||||
export type RestartSentinel = {
|
||||
version: 1;
|
||||
payload: RestartSentinelPayload;
|
||||
};
|
||||
|
||||
const SENTINEL_FILENAME = "restart-sentinel.json";
|
||||
|
||||
export function resolveRestartSentinelPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return path.join(resolveStateDir(env), SENTINEL_FILENAME);
|
||||
}
|
||||
|
||||
export async function writeRestartSentinel(
|
||||
payload: RestartSentinelPayload,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const filePath = resolveRestartSentinelPath(env);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const data: RestartSentinel = { version: 1, payload };
|
||||
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export async function readRestartSentinel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<RestartSentinel | null> {
|
||||
const filePath = resolveRestartSentinelPath(env);
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
let parsed: RestartSentinel | undefined;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as RestartSentinel | undefined;
|
||||
} catch {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
if (!parsed || parsed.version !== 1 || !parsed.payload) {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeRestartSentinel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<RestartSentinel | null> {
|
||||
const filePath = resolveRestartSentinelPath(env);
|
||||
const parsed = await readRestartSentinel(env);
|
||||
if (!parsed) return null;
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function formatRestartSentinelMessage(
|
||||
payload: RestartSentinelPayload,
|
||||
): string {
|
||||
return `GatewayRestart:\n${JSON.stringify(payload, null, 2)}`;
|
||||
}
|
||||
|
||||
export function summarizeRestartSentinel(
|
||||
payload: RestartSentinelPayload,
|
||||
): string {
|
||||
const kind = payload.kind;
|
||||
const status = payload.status;
|
||||
const mode = payload.stats?.mode ? ` (${payload.stats.mode})` : "";
|
||||
return `Gateway restart ${kind} ${status}${mode}`.trim();
|
||||
}
|
||||
|
||||
export function trimLogTail(input?: string | null, maxChars = 8000) {
|
||||
if (!input) return null;
|
||||
const text = input.trimEnd();
|
||||
if (text.length <= maxChars) return text;
|
||||
return `…${text.slice(text.length - maxChars)}`;
|
||||
}
|
||||
@@ -34,3 +34,48 @@ export function triggerClawdbotRestart():
|
||||
spawnSync("launchctl", ["kickstart", "-k", target], { stdio: "ignore" });
|
||||
return "launchctl";
|
||||
}
|
||||
|
||||
export type ScheduledRestart = {
|
||||
ok: boolean;
|
||||
pid: number;
|
||||
signal: "SIGUSR1";
|
||||
delayMs: number;
|
||||
reason?: string;
|
||||
mode: "emit" | "signal";
|
||||
};
|
||||
|
||||
export function scheduleGatewaySigusr1Restart(opts?: {
|
||||
delayMs?: number;
|
||||
reason?: string;
|
||||
}): ScheduledRestart {
|
||||
const delayMsRaw =
|
||||
typeof opts?.delayMs === "number" && Number.isFinite(opts.delayMs)
|
||||
? Math.floor(opts.delayMs)
|
||||
: 2000;
|
||||
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
|
||||
const reason =
|
||||
typeof opts?.reason === "string" && opts.reason.trim()
|
||||
? opts.reason.trim().slice(0, 200)
|
||||
: undefined;
|
||||
const pid = process.pid;
|
||||
const hasListener = process.listenerCount("SIGUSR1") > 0;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (hasListener) {
|
||||
process.emit("SIGUSR1");
|
||||
} else {
|
||||
process.kill(pid, "SIGUSR1");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, delayMs);
|
||||
return {
|
||||
ok: true,
|
||||
pid,
|
||||
signal: "SIGUSR1",
|
||||
delayMs,
|
||||
reason,
|
||||
mode: hasListener ? "emit" : "signal",
|
||||
};
|
||||
}
|
||||
|
||||
111
src/infra/update-runner.test.ts
Normal file
111
src/infra/update-runner.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { runGatewayUpdate } from "./update-runner.js";
|
||||
|
||||
type CommandResult = { stdout?: string; stderr?: string; code?: number };
|
||||
|
||||
function createRunner(responses: Record<string, CommandResult>) {
|
||||
const calls: string[] = [];
|
||||
const runner = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
const res = responses[key] ?? {};
|
||||
return {
|
||||
stdout: res.stdout ?? "",
|
||||
stderr: res.stderr ?? "",
|
||||
code: res.code ?? 0,
|
||||
};
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
describe("runGatewayUpdate", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("skips git update when worktree is dirty", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: tempDir,
|
||||
runCommand: async (argv, _options) => runner(argv),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("skipped");
|
||||
expect(result.reason).toBe("dirty");
|
||||
expect(calls.some((call) => call.includes("rebase"))).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts rebase on failure", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
|
||||
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
|
||||
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]:
|
||||
{ stdout: "origin/main" },
|
||||
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" },
|
||||
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
|
||||
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: tempDir,
|
||||
runCommand: async (argv, _options) => runner(argv),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.reason).toBe("rebase-failed");
|
||||
expect(calls.some((call) => call.includes("rebase --abort"))).toBe(true);
|
||||
});
|
||||
|
||||
it("runs package manager update when no git root", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", packageManager: "pnpm@8.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8");
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 },
|
||||
"pnpm update": { stdout: "ok" },
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: tempDir,
|
||||
runCommand: async (argv, _options) => runner(argv),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.mode).toBe("pnpm");
|
||||
expect(calls.some((call) => call.includes("pnpm update"))).toBe(true);
|
||||
});
|
||||
});
|
||||
390
src/infra/update-runner.ts
Normal file
390
src/infra/update-runner.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||
import { trimLogTail } from "./restart-sentinel.js";
|
||||
|
||||
export type UpdateStepResult = {
|
||||
name: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
durationMs: number;
|
||||
exitCode: number | null;
|
||||
stdoutTail?: string | null;
|
||||
stderrTail?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateRunResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
mode: "git" | "pnpm" | "bun" | "npm" | "unknown";
|
||||
root?: string;
|
||||
reason?: string;
|
||||
before?: { sha?: string | null; version?: string | null };
|
||||
after?: { sha?: string | null; version?: string | null };
|
||||
steps: UpdateStepResult[];
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
type CommandRunner = (
|
||||
argv: string[],
|
||||
options: CommandOptions,
|
||||
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
|
||||
type UpdateRunnerOptions = {
|
||||
cwd?: string;
|
||||
argv1?: string;
|
||||
timeoutMs?: number;
|
||||
runCommand?: CommandRunner;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
||||
const MAX_LOG_CHARS = 8000;
|
||||
|
||||
const START_DIRS = ["cwd", "argv1", "process"];
|
||||
|
||||
function normalizeDir(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function buildStartDirs(opts: UpdateRunnerOptions): string[] {
|
||||
const dirs: string[] = [];
|
||||
const cwd = normalizeDir(opts.cwd);
|
||||
if (cwd) dirs.push(cwd);
|
||||
const argv1 = normalizeDir(opts.argv1);
|
||||
if (argv1) dirs.push(path.dirname(argv1));
|
||||
const proc = normalizeDir(process.cwd());
|
||||
if (proc) dirs.push(proc);
|
||||
return Array.from(new Set(dirs));
|
||||
}
|
||||
|
||||
async function readPackageVersion(root: string) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed?.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGitRoot(
|
||||
runCommand: CommandRunner,
|
||||
candidates: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
for (const dir of candidates) {
|
||||
const res = await runCommand(
|
||||
["git", "-C", dir, "rev-parse", "--show-toplevel"],
|
||||
{
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
if (res.code === 0) {
|
||||
const root = res.stdout.trim();
|
||||
if (root) return root;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPackageRoot(candidates: string[]) {
|
||||
for (const dir of candidates) {
|
||||
let current = dir;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const pkgPath = path.join(current, "package.json");
|
||||
try {
|
||||
const raw = await fs.readFile(pkgPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
if (parsed?.name === "clawdbot") return current;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectPackageManager(root: string) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { packageManager?: string };
|
||||
const pm = parsed?.packageManager?.split("@")[0]?.trim();
|
||||
if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const files = await fs.readdir(root).catch((): string[] => []);
|
||||
if (files.includes("pnpm-lock.yaml")) return "pnpm";
|
||||
if (files.includes("bun.lockb")) return "bun";
|
||||
if (files.includes("package-lock.json")) return "npm";
|
||||
return "npm";
|
||||
}
|
||||
|
||||
async function runStep(
|
||||
runCommand: CommandRunner,
|
||||
name: string,
|
||||
argv: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
): Promise<UpdateStepResult> {
|
||||
const started = Date.now();
|
||||
const result = await runCommand(argv, { cwd, timeoutMs });
|
||||
const durationMs = Date.now() - started;
|
||||
return {
|
||||
name,
|
||||
command: argv.join(" "),
|
||||
cwd,
|
||||
durationMs,
|
||||
exitCode: result.code,
|
||||
stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
|
||||
stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
|
||||
};
|
||||
}
|
||||
|
||||
function managerScriptArgs(
|
||||
manager: "pnpm" | "bun" | "npm",
|
||||
script: string,
|
||||
args: string[] = [],
|
||||
) {
|
||||
if (manager === "pnpm") return ["pnpm", script, ...args];
|
||||
if (manager === "bun") return ["bun", "run", script, ...args];
|
||||
if (args.length > 0) return ["npm", "run", script, "--", ...args];
|
||||
return ["npm", "run", script];
|
||||
}
|
||||
|
||||
function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
||||
if (manager === "pnpm") return ["pnpm", "install"];
|
||||
if (manager === "bun") return ["bun", "install"];
|
||||
return ["npm", "install"];
|
||||
}
|
||||
|
||||
function managerUpdateArgs(manager: "pnpm" | "bun" | "npm") {
|
||||
if (manager === "pnpm") return ["pnpm", "update"];
|
||||
if (manager === "bun") return ["bun", "update"];
|
||||
return ["npm", "update"];
|
||||
}
|
||||
|
||||
export async function runGatewayUpdate(
|
||||
opts: UpdateRunnerOptions = {},
|
||||
): Promise<UpdateRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const runCommand =
|
||||
opts.runCommand ??
|
||||
(async (argv, options) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
});
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const steps: UpdateStepResult[] = [];
|
||||
const candidates = buildStartDirs(opts);
|
||||
|
||||
const gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs);
|
||||
if (gitRoot) {
|
||||
const beforeSha = (
|
||||
await runStep(
|
||||
runCommand,
|
||||
"git rev-parse HEAD",
|
||||
["git", "-C", gitRoot, "rev-parse", "HEAD"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
)
|
||||
).stdoutTail?.trim();
|
||||
const beforeVersion = await readPackageVersion(gitRoot);
|
||||
|
||||
const statusStep = await runStep(
|
||||
runCommand,
|
||||
"git status",
|
||||
["git", "-C", gitRoot, "status", "--porcelain"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
);
|
||||
steps.push(statusStep);
|
||||
if ((statusStep.stdoutTail ?? "").trim()) {
|
||||
return {
|
||||
status: "skipped",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "dirty",
|
||||
before: { sha: beforeSha ?? null, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const upstreamStep = await runStep(
|
||||
runCommand,
|
||||
"git upstream",
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
gitRoot,
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{upstream}",
|
||||
],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
);
|
||||
steps.push(upstreamStep);
|
||||
if (upstreamStep.exitCode !== 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "no-upstream",
|
||||
before: { sha: beforeSha ?? null, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"git fetch",
|
||||
["git", "-C", gitRoot, "fetch", "--all", "--prune"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
|
||||
const rebaseStep = await runStep(
|
||||
runCommand,
|
||||
"git rebase",
|
||||
["git", "-C", gitRoot, "rebase", "@{upstream}"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
);
|
||||
steps.push(rebaseStep);
|
||||
if (rebaseStep.exitCode !== 0) {
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"git rebase --abort",
|
||||
["git", "-C", gitRoot, "rebase", "--abort"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: "rebase-failed",
|
||||
before: { sha: beforeSha ?? null, version: beforeVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = await detectPackageManager(gitRoot);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"deps install",
|
||||
managerInstallArgs(manager),
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"build",
|
||||
managerScriptArgs(manager, "build"),
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"ui:install",
|
||||
managerScriptArgs(manager, "ui:install"),
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"ui:build",
|
||||
managerScriptArgs(manager, "ui:build"),
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"clawdbot doctor",
|
||||
managerScriptArgs(manager, "clawdbot", ["doctor"]),
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
|
||||
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||
const afterShaStep = await runStep(
|
||||
runCommand,
|
||||
"git rev-parse HEAD (after)",
|
||||
["git", "-C", gitRoot, "rev-parse", "HEAD"],
|
||||
gitRoot,
|
||||
timeoutMs,
|
||||
);
|
||||
steps.push(afterShaStep);
|
||||
const afterVersion = await readPackageVersion(gitRoot);
|
||||
|
||||
return {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: "git",
|
||||
root: gitRoot,
|
||||
reason: failedStep ? failedStep.name : undefined,
|
||||
before: { sha: beforeSha ?? null, version: beforeVersion },
|
||||
after: {
|
||||
sha: afterShaStep.stdoutTail?.trim() ?? null,
|
||||
version: afterVersion,
|
||||
},
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const pkgRoot = await findPackageRoot(candidates);
|
||||
if (!pkgRoot) {
|
||||
return {
|
||||
status: "error",
|
||||
mode: "unknown",
|
||||
reason: `no root (${START_DIRS.join(",")})`,
|
||||
steps: [],
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
const manager = await detectPackageManager(pkgRoot);
|
||||
steps.push(
|
||||
await runStep(
|
||||
runCommand,
|
||||
"deps update",
|
||||
managerUpdateArgs(manager),
|
||||
pkgRoot,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
const failed = steps.find((step) => step.exitCode !== 0);
|
||||
return {
|
||||
status: failed ? "error" : "ok",
|
||||
mode: manager,
|
||||
root: pkgRoot,
|
||||
reason: failed ? failed.name : undefined,
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user