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:
140
packages/engine/src/__tests__/experiment-git-policy.test.ts
Normal file
140
packages/engine/src/__tests__/experiment-git-policy.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ExperimentRunRecordPayload,
|
||||
ExperimentSession,
|
||||
ExperimentSessionRecord,
|
||||
} from "@fusion/core";
|
||||
|
||||
import type { GitOps } from "../experiment/git-ops.js";
|
||||
import {
|
||||
commitKept,
|
||||
ExperimentRevertConflictError,
|
||||
revertDiscarded,
|
||||
} from "../experiment/git-policy.js";
|
||||
|
||||
const baseSession: ExperimentSession = {
|
||||
id: "EXP-001",
|
||||
projectId: "proj",
|
||||
name: "session",
|
||||
metric: { name: "accuracy", direction: "maximize" },
|
||||
status: "active",
|
||||
currentSegment: 1,
|
||||
maxIterations: 10,
|
||||
tags: [],
|
||||
bestRunId: null,
|
||||
baselineCommit: null,
|
||||
keptRunIds: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const baseRunRecord: ExperimentSessionRecord = {
|
||||
id: "EXPR-001",
|
||||
sessionId: "EXP-001",
|
||||
segment: 1,
|
||||
type: "run",
|
||||
payload: { status: "keep", secondaryMetrics: [] },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const baseRunPayload: ExperimentRunRecordPayload = {
|
||||
status: "keep",
|
||||
primaryMetric: 0.91,
|
||||
secondaryMetrics: [],
|
||||
};
|
||||
|
||||
function createGitMock(): GitOps {
|
||||
return {
|
||||
head: vi.fn(),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
resetHard: vi.fn(),
|
||||
stashPush: vi.fn(),
|
||||
stashPop: vi.fn(),
|
||||
statusPorcelain: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("git policy", () => {
|
||||
it("commitKept stages and commits with default message", async () => {
|
||||
const git = createGitMock();
|
||||
vi.mocked(git.commit).mockResolvedValue("abc123");
|
||||
|
||||
const result = await commitKept({
|
||||
session: baseSession,
|
||||
runRecord: baseRunRecord,
|
||||
runPayload: baseRunPayload,
|
||||
git,
|
||||
});
|
||||
|
||||
expect(git.add).toHaveBeenCalledWith(["-A"]);
|
||||
expect(git.commit).toHaveBeenCalledWith(
|
||||
"experiment(EXP-001): keep EXPR-001 — accuracy=0.91",
|
||||
);
|
||||
expect(result).toEqual({ commit: "abc123" });
|
||||
});
|
||||
|
||||
it("revertDiscarded without preserved paths only resets", async () => {
|
||||
const git = createGitMock();
|
||||
vi.mocked(git.statusPorcelain).mockResolvedValue(" M src/file.ts");
|
||||
|
||||
const result = await revertDiscarded({
|
||||
session: baseSession,
|
||||
git,
|
||||
baselineCommit: "base-sha",
|
||||
});
|
||||
|
||||
expect(git.stashPush).not.toHaveBeenCalled();
|
||||
expect(git.resetHard).toHaveBeenCalledWith("base-sha");
|
||||
expect(result).toEqual({ revertedTo: "base-sha", preservedPaths: [] });
|
||||
});
|
||||
|
||||
it("revertDiscarded with preserved path stashes then pops", async () => {
|
||||
const git = createGitMock();
|
||||
vi.mocked(git.statusPorcelain).mockResolvedValue(
|
||||
" M autoresearch.jsonl\n M src/file.ts",
|
||||
);
|
||||
vi.mocked(git.stashPush).mockResolvedValue("stash@{0}");
|
||||
|
||||
await revertDiscarded({
|
||||
session: baseSession,
|
||||
git,
|
||||
baselineCommit: "base-sha",
|
||||
});
|
||||
|
||||
expect(git.add).toHaveBeenCalledWith(["autoresearch.jsonl"]);
|
||||
expect(git.stashPush).toHaveBeenCalledOnce();
|
||||
expect(git.resetHard).toHaveBeenCalledWith("base-sha");
|
||||
expect(git.stashPop).toHaveBeenCalledWith("stash@{0}");
|
||||
});
|
||||
|
||||
it("rethrow stash pop conflicts as ExperimentRevertConflictError", async () => {
|
||||
const git = createGitMock();
|
||||
vi.mocked(git.statusPorcelain).mockResolvedValue(" M autoresearch.md");
|
||||
vi.mocked(git.stashPush).mockResolvedValue("stash@{1}");
|
||||
vi.mocked(git.stashPop).mockRejectedValue(new Error("conflict"));
|
||||
|
||||
await expect(
|
||||
revertDiscarded({
|
||||
session: baseSession,
|
||||
git,
|
||||
baselineCommit: "base-sha",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ExperimentRevertConflictError);
|
||||
});
|
||||
|
||||
it("does not preserve similarly-named non-matching files", async () => {
|
||||
const git = createGitMock();
|
||||
vi.mocked(git.statusPorcelain).mockResolvedValue(" M autoresearch.jsonl.bak");
|
||||
|
||||
const result = await revertDiscarded({
|
||||
session: baseSession,
|
||||
git,
|
||||
baselineCommit: "base-sha",
|
||||
});
|
||||
|
||||
expect(git.add).not.toHaveBeenCalled();
|
||||
expect(result.preservedPaths).toEqual([]);
|
||||
});
|
||||
});
|
||||
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