feat: add snapshot-based none VCS mode
This commit is contained in:
+135
-89
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user