feat: add snapshot-based none VCS mode

This commit is contained in:
liujing
2026-07-17 11:50:53 +08:00
parent 758c41a953
commit b756a10c35
11 changed files with 1336 additions and 121 deletions
+135 -89
View File
@@ -41,6 +41,7 @@ import {
releaseLock,
runDir as makeRunDir,
writeState,
workflowsRoot,
} from "./workflow-state.js";
import {
createVcsProvider,
@@ -245,18 +246,54 @@ export interface StartWorkflowOpts {
maxCycles?: number;
cwd?: string;
}
/** Resolve VCS selection from candidates and configs - exported for testing */
export async function resolveVcsSelection(
cwd: string,
explicitVcs?: VcsKind
): Promise<{ vcsKind: VcsKind; repoRoot: string }> {
): Promise<{ vcsKind: VcsKind; repoRoot: string; configSourceRoot?: string }> {
// Discover candidates
const { findVcsCandidates } = await import("./vcs-provider.js");
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
// Load config from candidate roots plus cwd to find defaultVcs
const configs = new Map<string, { vcs?: VcsKind | "auto"; root: string }>();
const rootsToTry = new Set<string>();
if (candidates.git) rootsToTry.add(candidates.git);
if (candidates.svn) rootsToTry.add(candidates.svn);
rootsToTry.add(cwd); // always try cwd as well
for (const root of rootsToTry) {
try {
const config = resolveWorkflowConfig(root, {});
configs.set(root, { vcs: config.vcs, root });
} catch (err) {
if (err instanceof WorkflowConfigError) {
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${err.message}`);
}
const errCode = (err as any)?.code;
if (errCode === "ENOENT") {
configs.set(root, { root });
} else {
throw err;
}
}
}
// If explicit VCS provided, use it directly
if (explicitVcs) {
const provider = createVcsProvider(explicitVcs);
try {
const repoRoot = provider.repoRoot(cwd);
return { vcsKind: explicitVcs, repoRoot };
let configSourceRoot = repoRoot;
if (explicitVcs === "none") {
for (const root of rootsToTry) {
if (fs.existsSync(path.join(root, "agent-workflow.json"))) {
configSourceRoot = root;
break;
}
}
}
return { vcsKind: explicitVcs, repoRoot, configSourceRoot };
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
@@ -265,47 +302,7 @@ export async function resolveVcsSelection(
}
}
// Discover candidates
const { findVcsCandidates } = await import("./vcs-provider.js");
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
if (!candidates.git && !candidates.svn) {
throw new WorkflowEngineError(
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
);
}
// Load config from every distinct candidate root
const configs = new Map<string, { vcs?: VcsKind; root: string }>();
for (const [, root] of Object.entries(candidates) as Array<[string, string | undefined]>) {
if (!root) continue;
// Skip if we already loaded config from this root
if (configs.has(root)) continue;
try {
const config = resolveWorkflowConfig(root, {});
configs.set(root, { vcs: config.vcs, root });
} catch (err) {
// Re-throw validation/parse errors (WorkflowConfigError) — these are real problems
if (err instanceof WorkflowConfigError) {
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`);
}
// Re-throw file access errors unless the file simply doesn't exist (ENOENT).
// Permission denied, I/O errors, generic Error/TypeError, etc. must surface rather than be silently ignored.
const errCode = (err as any)?.code;
if (errCode === "ENOENT") {
// Config file doesn't exist — that's ok, treat as no project config
configs.set(root, { root });
} else {
throw err;
}
}
}
// Extract project defaultVcs settings
// Extract project defaultVcs settings from configs
const projectDefaults = Array.from(configs.values())
.map(c => c.vcs)
.filter((v): v is VcsKind => v !== undefined);
@@ -318,15 +315,25 @@ export async function resolveVcsSelection(
);
}
// Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error
const projectDefault = projectDefaults[0];
if (projectDefault === "none") {
let configSourceRoot = cwd;
for (const c of configs.values()) {
if (c.vcs === "none") {
configSourceRoot = c.root;
break;
}
}
return { vcsKind: "none", repoRoot: cwd, configSourceRoot };
}
if (candidates.git && candidates.svn) {
// Ambiguous: need explicit selection
if (projectDefault === "git") {
return { vcsKind: "git", repoRoot: candidates.git };
return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git };
} else if (projectDefault === "svn") {
return { vcsKind: "svn", repoRoot: candidates.svn };
return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn };
} else {
throw new WorkflowEngineError(
"Ambiguous VCS: both Git and SVN detected. " +
@@ -334,9 +341,23 @@ export async function resolveVcsSelection(
);
}
} else if (candidates.git) {
return { vcsKind: "git", repoRoot: candidates.git };
if (projectDefault === "svn") {
throw new WorkflowEngineError(`Configured VCS is "svn" but no SVN working copy was detected.`);
}
return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git };
} else if (candidates.svn) {
if (projectDefault === "git") {
throw new WorkflowEngineError(`Configured VCS is "git" but no Git repository was detected.`);
}
return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn };
} else {
return { vcsKind: "svn", repoRoot: candidates.svn! };
// Neither detected
if (projectDefault === "git" || projectDefault === "svn") {
throw new WorkflowEngineError(
`Configured VCS is "${projectDefault}" but no such VCS repository was detected.`
);
}
return { vcsKind: "none", repoRoot: cwd, configSourceRoot: cwd };
}
}
@@ -364,10 +385,25 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
const typedPlan = plan as WorkflowPlanV1;
// 2. Resolve VCS selection using deterministic helper
const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
const { vcsKind, repoRoot, configSourceRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
// 3. Resolve full config from final repoRoot
const config = resolveWorkflowConfig(repoRoot, {
// Reject AGENT_WORKFLOW_DIR/workflow state root that is equal to or nested under target repoRoot in none mode
if (vcsKind === "none") {
const absRepoRoot = path.resolve(repoRoot);
const absStateRoot = path.resolve(workflowsRoot());
const relative = path.relative(absRepoRoot, absStateRoot);
const isNestedOrEqual = !relative.startsWith("..") && !path.isAbsolute(relative);
if (isNestedOrEqual) {
throw new WorkflowEngineError(
`Workflow state directory (${absStateRoot}) cannot be equal to or nested under the repository root (${absRepoRoot}) in none VCS mode. ` +
`Please set the AGENT_WORKFLOW_DIR environment variable to an external location.`,
4
);
}
}
// 3. Resolve full config from configSourceRoot or final repoRoot
const config = resolveWorkflowConfig(configSourceRoot ?? repoRoot, {
...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}),
...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}),
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
@@ -378,35 +414,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
// 4. Probe executor
await probeExecutor(config.executor, config.executorConfig);
// 5. Require clean working tree
let isClean: boolean;
try {
isClean = vcsProvider.isClean(repoRoot);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
// 5. Require clean working tree (skipped in none mode)
if (vcsKind !== "none") {
let isClean: boolean;
try {
isClean = vcsProvider.isClean(repoRoot);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
}
throw new WorkflowEngineError((err as Error).message);
}
if (!isClean) {
throw new WorkflowEngineError(
"Working tree has uncommitted changes. Commit or stash them before starting a workflow.",
4,
);
}
throw new WorkflowEngineError((err as Error).message);
}
if (!isClean) {
throw new WorkflowEngineError(
"Working tree has uncommitted changes. Commit or stash them before starting a workflow.",
4,
);
}
// 6. Capture baseline
let vcsBaseline: VcsBaseline;
try {
vcsBaseline = vcsProvider.captureBaseline(repoRoot);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
}
throw new WorkflowEngineError((err as Error).message);
}
// 7. Acquire lock (one active workflow per repo)
// 6. Acquire lock (one active workflow per repo)
const runId = generateRunId();
try {
acquireLock(repoRoot, runId);
@@ -420,6 +447,20 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
const dir = makeRunDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
// 7. Capture baseline
let vcsBaseline: VcsBaseline;
try {
vcsBaseline = vcsProvider.captureBaseline(repoRoot, dir);
} catch (err) {
try {
releaseLock(repoRoot, runId);
} catch {}
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
}
throw new WorkflowEngineError((err as Error).message);
}
const state = initWorkflowState({
runId,
repoRoot,
@@ -433,6 +474,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
state.executorConfig = config.executorConfig;
state.timeoutSeconds = config.timeoutSeconds;
state.enginePid = process.pid;
state.status = "executing";
writeState(dir, state);
appendEvent(dir, {
kind: "workflow_started",
@@ -449,6 +491,8 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
} else if (vcsBaseline.kind === "svn") {
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`);
} else if (vcsBaseline.kind === "none") {
process.stdout.write(` baseline: snapshot\n`);
}
process.stdout.write(` run dir: ${dir}\n\n`);
@@ -484,6 +528,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
config: config.executorConfig,
timeoutSeconds: state.timeoutSeconds,
signal: ac.signal,
vcsKind,
});
} catch (err) {
cleanup();
@@ -568,7 +613,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
// 1. Generate diff from baseline
let diff: string;
try {
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline);
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline, dir);
} catch (err) {
if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`);
@@ -582,7 +627,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
// 2. Check for baseline drift
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) {
if (err instanceof VcsError) {
transitionToTerminal(
@@ -599,7 +644,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
// 3. Check out-of-scope files
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) {
if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
@@ -922,6 +967,7 @@ async function resumeExecutorCycle(
config: state.executorConfig ?? {},
timeoutSeconds: state.timeoutSeconds,
signal: ac.signal,
vcsKind: state.vcsKind || "git",
});
} catch (err) {
cleanup();
@@ -996,7 +1042,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) {
if (err instanceof VcsError) {
return err.message;
@@ -1007,7 +1053,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
// 3. All files must be in scope
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) {
if (err instanceof VcsError) {
return `Scope check failed: ${err.message}`;
@@ -1129,7 +1175,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
@@ -1139,7 +1185,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
@@ -1237,7 +1283,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
@@ -1247,7 +1293,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
@@ -1394,7 +1440,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
@@ -1403,7 +1449,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
let outOfScope: string[] = [];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: scope check failed: ${message}`);