feat(FN-4219): complete Step 3 — add git policy and ops
Fusion-Task-Id: FN-4219 Fusion-Task-Lineage: 8d9a9ba6-6729-4376-b935-549e28a7fa35
This commit is contained in:
66
packages/engine/src/experiment/git-ops.ts
Normal file
66
packages/engine/src/experiment/git-ops.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const GIT_TIMEOUT_MS = 30_000;
|
||||
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export interface GitOps {
|
||||
head(): Promise<string>;
|
||||
add(paths: string[]): Promise<void>;
|
||||
commit(message: string): Promise<string>;
|
||||
resetHard(ref: string): Promise<void>;
|
||||
stashPush(message: string): Promise<string | null>;
|
||||
stashPop(ref: string): Promise<void>;
|
||||
statusPorcelain(): Promise<string>;
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]): Promise<string> {
|
||||
const command = `git ${args.join(" ")}`;
|
||||
try {
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
const err = error as Error & { stderr?: string; stdout?: string };
|
||||
const stderr = err.stderr?.trim();
|
||||
const stdout = err.stdout?.trim();
|
||||
const detail = stderr || stdout || err.message;
|
||||
throw new Error(`Git command failed (${command}): ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultGitOps(cwd: string): GitOps {
|
||||
return {
|
||||
async head() {
|
||||
return await runGit(cwd, ["rev-parse", "HEAD"]);
|
||||
},
|
||||
async add(paths: string[]) {
|
||||
await runGit(cwd, ["add", ...paths]);
|
||||
},
|
||||
async commit(message: string) {
|
||||
await runGit(cwd, ["commit", "-m", JSON.stringify(message)]);
|
||||
return await runGit(cwd, ["rev-parse", "HEAD"]);
|
||||
},
|
||||
async resetHard(ref: string) {
|
||||
await runGit(cwd, ["reset", "--hard", ref]);
|
||||
},
|
||||
async stashPush(message: string) {
|
||||
const output = await runGit(cwd, ["stash", "push", "-m", JSON.stringify(message)]);
|
||||
if (output.includes("No local changes to save")) {
|
||||
return null;
|
||||
}
|
||||
const match = output.match(/(stash@\{\d+\})/);
|
||||
return match?.[1] ?? "stash@{0}";
|
||||
},
|
||||
async stashPop(ref: string) {
|
||||
await runGit(cwd, ["stash", "pop", ref]);
|
||||
},
|
||||
async statusPorcelain() {
|
||||
return await runGit(cwd, ["status", "--porcelain"]);
|
||||
},
|
||||
};
|
||||
}
|
||||
83
packages/engine/src/experiment/git-policy.ts
Normal file
83
packages/engine/src/experiment/git-policy.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
ExperimentRunRecordPayload,
|
||||
ExperimentSession,
|
||||
ExperimentSessionRecord,
|
||||
} from "@fusion/core";
|
||||
|
||||
import type { GitOps } from "./git-ops.js";
|
||||
|
||||
export const AUTORESEARCH_PRESERVED_PATHS = [
|
||||
"autoresearch.jsonl",
|
||||
"autoresearch.md",
|
||||
"autoresearch.ideas.md",
|
||||
"autoresearch.checks.sh",
|
||||
"autoresearch.config.json",
|
||||
"autoresearch.hooks/",
|
||||
] as const;
|
||||
|
||||
export function isPreservedAutoresearchPath(pathname: string): boolean {
|
||||
return AUTORESEARCH_PRESERVED_PATHS.some((preserved) =>
|
||||
preserved.endsWith("/")
|
||||
? pathname === preserved.slice(0, -1) || pathname.startsWith(preserved)
|
||||
: pathname === preserved,
|
||||
);
|
||||
}
|
||||
|
||||
export class ExperimentRevertConflictError extends Error {
|
||||
constructor(message: string, public readonly causeError?: unknown) {
|
||||
super(message);
|
||||
this.name = "ExperimentRevertConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitKept(opts: {
|
||||
session: ExperimentSession;
|
||||
runRecord: ExperimentSessionRecord;
|
||||
runPayload: ExperimentRunRecordPayload;
|
||||
git: GitOps;
|
||||
commitMessage?: string;
|
||||
}): Promise<{ commit: string }> {
|
||||
const metricName = opts.session.metric.name;
|
||||
const metricValue = opts.runPayload.primaryMetric ?? "n/a";
|
||||
const message =
|
||||
opts.commitMessage ??
|
||||
`experiment(${opts.session.id}): keep ${opts.runRecord.id} — ${metricName}=${metricValue}`;
|
||||
|
||||
await opts.git.add(["-A"]);
|
||||
const commit = await opts.git.commit(message);
|
||||
return { commit };
|
||||
}
|
||||
|
||||
export async function revertDiscarded(opts: {
|
||||
session: ExperimentSession;
|
||||
git: GitOps;
|
||||
baselineCommit: string;
|
||||
}): Promise<{ revertedTo: string; preservedPaths: string[] }> {
|
||||
const status = await opts.git.statusPorcelain();
|
||||
const preservedPaths = status
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^..\s+(.+)$/)?.[1]?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.filter((pathname) => isPreservedAutoresearchPath(pathname));
|
||||
|
||||
let stashRef: string | null = null;
|
||||
if (preservedPaths.length > 0) {
|
||||
await opts.git.add(preservedPaths);
|
||||
stashRef = await opts.git.stashPush(`experiment-preserve-${opts.session.id}`);
|
||||
}
|
||||
|
||||
await opts.git.resetHard(opts.baselineCommit);
|
||||
|
||||
if (stashRef) {
|
||||
try {
|
||||
await opts.git.stashPop(stashRef);
|
||||
} catch (error) {
|
||||
throw new ExperimentRevertConflictError(
|
||||
`Failed to restore preserved autoresearch artifacts for ${opts.session.id}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { revertedTo: opts.baselineCommit, preservedPaths };
|
||||
}
|
||||
Reference in New Issue
Block a user