Skip to content

Commit fab7dc5

Browse files
committed
feat(workflows): add specification requirement check and pre-flight validation
- Add `specification` flag to workflow templates to mark workflows requiring specs - Implement pre-flight checks for specification validation and onboarding needs - Move specification validation logic to dedicated preflight module - Update CLI and TUI to use new pre-flight validation flow
1 parent 36ff395 commit fab7dc5

9 files changed

Lines changed: 195 additions & 68 deletions

File tree

src/cli/tui/app-shell.tsx

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ import { MonitoringCleanup } from "../../agents/monitoring/index.js"
2424
import path from "path"
2525
import { createRequire } from "node:module"
2626
import { resolvePackageJson } from "../../shared/runtime/root.js"
27-
import { getSelectedTrack, setSelectedTrack, hasSelectedConditions, setSelectedConditions, getProjectName, setProjectName, getControllerAgents, loadControllerConfig } from "../../shared/workflows/index.js"
28-
import { loadTemplate } from "../../workflows/templates/loader.js"
29-
import { getTemplatePathFromTracking } from "../../shared/workflows/template.js"
27+
import { setSelectedTrack, setSelectedConditions, setProjectName } from "../../shared/workflows/index.js"
28+
import { checkOnboardingRequired, needsOnboarding } from "../../workflows/preflight.js"
3029
import type { TracksConfig, ConditionGroup } from "../../workflows/templates/types"
3130
import type { AgentDefinition } from "../../shared/agents/config/types"
3231
import type { InitialToast } from "./app"
@@ -168,39 +167,23 @@ export function App(props: { initialToast?: InitialToast }) {
168167
setDebugLogFile(debugLogPath)
169168
}
170169

171-
// Check if tracks/conditions exist and no selection yet
170+
// Run pre-flight checks
172171
try {
173-
const templatePath = await getTemplatePathFromTracking(cmRoot)
174-
const template = await loadTemplate(cwd, templatePath)
175-
const selectedTrack = await getSelectedTrack(cmRoot)
176-
const conditionsSelected = await hasSelectedConditions(cmRoot)
177-
const existingProjectName = await getProjectName(cmRoot)
178-
179-
const hasTracks = template.tracks && Object.keys(template.tracks.options).length > 0
180-
const hasConditionGroups = template.conditionGroups && template.conditionGroups.length > 0
181-
const needsTrackSelection = hasTracks && !selectedTrack
182-
const needsConditionsSelection = hasConditionGroups && !conditionsSelected
183-
const needsProjectName = !existingProjectName
184-
185-
// Check if workflow requires controller selection
186-
// Skip if controller session already exists
187-
let controllers: AgentDefinition[] = []
188-
const existingControllerConfig = await loadControllerConfig(cmRoot)
189-
const hasExistingControllerSession = existingControllerConfig?.controllerConfig?.sessionId
190-
if (template.controller === true && !hasExistingControllerSession) {
191-
controllers = await getControllerAgents(cwd)
192-
}
193-
const needsControllerSelection = controllers.length > 0
172+
const onboardingNeeds = await checkOnboardingRequired({ cwd })
173+
const { template, controllerAgents } = onboardingNeeds
194174

195-
// If project name, tracks, conditions, or controller need selection, show onboard view
196-
if (needsProjectName || needsTrackSelection || needsConditionsSelection || needsControllerSelection) {
175+
// If any onboarding is needed, show onboard view
176+
if (needsOnboarding(onboardingNeeds)) {
197177
debug('[AppShell] Starting onboarding flow')
198178

199-
// Store config for Onboard component (backward compatibility)
179+
const hasTracks = template.tracks && Object.keys(template.tracks.options).length > 0
180+
const hasConditionGroups = template.conditionGroups && template.conditionGroups.length > 0
181+
182+
// Store config for Onboard component
200183
if (hasTracks) setTemplateTracks(template.tracks!)
201184
if (hasConditionGroups) setTemplateConditionGroups(template.conditionGroups!)
202-
if (needsControllerSelection) setControllerAgents(controllers)
203-
setInitialProjectName(existingProjectName)
185+
if (onboardingNeeds.needsControllerSelection) setControllerAgents(controllerAgents)
186+
setInitialProjectName(null)
204187

205188
// Create event bus and service for onboarding
206189
const eventBus = new WorkflowEventBus()
@@ -209,8 +192,8 @@ export function App(props: { initialToast?: InitialToast }) {
209192
const service = new OnboardingService(eventBus, {
210193
tracks: hasTracks ? template.tracks : undefined,
211194
conditionGroups: hasConditionGroups ? template.conditionGroups : undefined,
212-
controllerAgents: needsControllerSelection ? controllers : undefined,
213-
initialProjectName: existingProjectName,
195+
controllerAgents: onboardingNeeds.needsControllerSelection ? controllerAgents : undefined,
196+
initialProjectName: onboardingNeeds.needsProjectName ? undefined : undefined,
214197
cwd,
215198
cmRoot,
216199
})
@@ -233,12 +216,12 @@ export function App(props: { initialToast?: InitialToast }) {
233216
return
234217
}
235218
} catch (error) {
236-
// If template loading fails, proceed to workflow anyway
237-
appDebug('[AppShell] Failed to check tracks/conditions: %s', error)
238-
console.error("Failed to check tracks/conditions:", error)
219+
// If pre-flight check fails, proceed to workflow anyway
220+
appDebug('[AppShell] Failed pre-flight check: %s', error)
221+
console.error("Failed pre-flight check:", error)
239222
}
240223

241-
// No tracks/conditions or already selected - start workflow directly
224+
// No onboarding needed - start workflow directly
242225
appDebug('[AppShell] Starting workflow execution directly')
243226
startWorkflowExecution()
244227
}
@@ -257,7 +240,7 @@ export function App(props: { initialToast?: InitialToast }) {
257240
pendingWorkflowStart = () => {
258241
appDebug('[AppShell] Importing and running workflow')
259242
import("../../workflows/run.js").then(({ runWorkflow }) => {
260-
runWorkflow({ cwd, specificationPath: specPath }).catch((error) => {
243+
runWorkflow({ cwd }).catch((error) => {
261244
// Emit error event to show toast with actual error message
262245
const errorMsg = error instanceof Error ? error.message : String(error)
263246
appDebug('[AppShell] Workflow error: %s', errorMsg)

src/cli/tui/routes/home/hooks/use-home-commands.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useDialog } from "@tui/shared/context/dialog"
1111
import { useSession } from "@tui/shared/context/session"
1212
import { SelectMenu } from "@tui/shared/components/select-menu"
1313
import * as path from "node:path"
14-
import { getAbsoluteSpecPath, HOME_COMMANDS } from "../config/commands"
14+
import { HOME_COMMANDS } from "../config/commands"
1515

1616
export interface UseHomeCommandsOptions {
1717
onStartWorkflow?: () => void
@@ -60,11 +60,10 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
6060
}
6161

6262
const handleStartCommand = async () => {
63-
const specPath = getAbsoluteSpecPath()
64-
6563
try {
66-
const { validateSpecification } = await import("../../../../../workflows/run.js")
67-
await validateSpecification(specPath)
64+
// Pre-flight check - validates specification if required by template
65+
const { checkSpecificationRequired } = await import("../../../../../workflows/preflight.js")
66+
await checkSpecificationRequired()
6867
} catch (error) {
6968
if (error instanceof Error) {
7069
toast.show({
@@ -78,7 +77,6 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
7877

7978
if (options.onStartWorkflow) {
8079
options.onStartWorkflow()
81-
return
8280
}
8381
}
8482

src/runtime/cli-setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export async function runCodemachineCli(argv: string[] = process.argv): Promise<
120120
// Set CWD immediately (lightweight, no I/O)
121121
const cwd = options.dir || process.cwd();
122122
process.env.CODEMACHINE_CWD = cwd;
123+
if (options.spec && options.spec !== DEFAULT_SPEC_PATH) {
124+
process.env.CODEMACHINE_SPEC_PATH = path.resolve(cwd, options.spec);
125+
}
123126
appDebug('[CLI] CWD set to %s', cwd);
124127

125128
// Start background initialization (non-blocking, fire-and-forget)

src/runtime/services/validation.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from 'node:path';
2-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
2+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
33

44
const DEFAULT_SPEC_TEMPLATE = `# Project Specifications
55
@@ -28,24 +28,37 @@ export class ValidationError extends Error {
2828

2929
export async function validateSpecification(specificationPath: string): Promise<void> {
3030
const absolute = path.resolve(specificationPath);
31-
let specificationContents: string;
3231

32+
// Check if path exists and what type it is
3333
try {
34-
specificationContents = await readFile(absolute, { encoding: 'utf8' });
35-
} catch (_error) {
36-
// File doesn't exist - create it with default template
37-
await mkdir(path.dirname(absolute), { recursive: true });
38-
await writeFile(absolute, DEFAULT_SPEC_TEMPLATE, { encoding: 'utf8' });
39-
40-
const message = `Spec file created. Please write your specs at: ${absolute}`;
41-
throw new ValidationError(message, absolute);
34+
const stats = await stat(absolute);
35+
if (stats.isDirectory()) {
36+
throw new ValidationError(`Spec path should be a file, not a directory: ${absolute}`, absolute);
37+
}
38+
} catch (error) {
39+
// Re-throw ValidationError
40+
if (error instanceof ValidationError) {
41+
throw error;
42+
}
43+
44+
const nodeError = error as NodeJS.ErrnoException;
45+
if (nodeError.code === 'ENOENT') {
46+
// File doesn't exist - create it with default template
47+
await mkdir(path.dirname(absolute), { recursive: true });
48+
await writeFile(absolute, DEFAULT_SPEC_TEMPLATE, { encoding: 'utf8' });
49+
throw new ValidationError(`Spec file created. Please write your specs at: ${absolute}`, absolute);
50+
}
51+
52+
// Unexpected error - wrap it
53+
throw new ValidationError(`Failed to access spec file: ${nodeError.message}`, absolute);
4254
}
4355

56+
// File exists and is not a directory - read it
57+
const specificationContents = await readFile(absolute, { encoding: 'utf8' });
4458
const trimmed = specificationContents.trim();
4559

4660
// Check if empty or still has default template content
4761
if (trimmed.length === 0 || trimmed === DEFAULT_SPEC_TEMPLATE.trim()) {
48-
const message = `Spec file is empty. Please write your specs at: ${absolute}`;
49-
throw new ValidationError(message, absolute);
62+
throw new ValidationError(`Spec file is empty. Please write your specs at: ${absolute}`, absolute);
5063
}
5164
}

src/workflows/preflight.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Workflow Pre-flight Checks
3+
*
4+
* Consolidates all checks that must pass before a workflow can start.
5+
* Single source of truth for workflow startup validation.
6+
*/
7+
8+
import * as path from 'node:path';
9+
import type { WorkflowTemplate } from './templates/types.js';
10+
import { loadTemplateWithPath } from './templates/loader.js';
11+
import { getTemplatePathFromTracking, getSelectedTrack, hasSelectedConditions, getProjectName, loadControllerConfig, getControllerAgents } from '../shared/workflows/index.js';
12+
import { validateSpecification } from '../runtime/services/index.js';
13+
import { ensureWorkspaceStructure } from '../runtime/services/workspace/index.js';
14+
import type { AgentDefinition } from '../shared/agents/config/types.js';
15+
16+
export { ValidationError } from '../runtime/services/index.js';
17+
18+
/**
19+
* Onboarding requirements - what the user needs to configure before workflow can start
20+
*/
21+
export interface OnboardingNeeds {
22+
needsProjectName: boolean;
23+
needsTrackSelection: boolean;
24+
needsConditionsSelection: boolean;
25+
needsControllerSelection: boolean;
26+
/** Controller agents available for selection (only populated if needsControllerSelection is true) */
27+
controllerAgents: AgentDefinition[];
28+
/** The loaded template for reference */
29+
template: WorkflowTemplate;
30+
}
31+
32+
/**
33+
* Check what onboarding steps are needed before workflow can start
34+
* Does NOT throw - returns the requirements for the UI to handle
35+
*/
36+
export async function checkOnboardingRequired(options: { cwd?: string } = {}): Promise<OnboardingNeeds> {
37+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
38+
const cmRoot = path.join(cwd, '.codemachine');
39+
40+
// Ensure workspace structure exists
41+
await ensureWorkspaceStructure({ cwd });
42+
43+
// Load template
44+
const templatePath = await getTemplatePathFromTracking(cmRoot);
45+
const { template } = await loadTemplateWithPath(cwd, templatePath);
46+
47+
// Check existing selections
48+
const selectedTrack = await getSelectedTrack(cmRoot);
49+
const conditionsSelected = await hasSelectedConditions(cmRoot);
50+
const existingProjectName = await getProjectName(cmRoot);
51+
52+
// Determine what's needed
53+
const hasTracks = !!(template.tracks && Object.keys(template.tracks.options).length > 0);
54+
const hasConditionGroups = !!(template.conditionGroups && template.conditionGroups.length > 0);
55+
const needsTrackSelection = hasTracks && !selectedTrack;
56+
const needsConditionsSelection = hasConditionGroups && !conditionsSelected;
57+
const needsProjectName = !existingProjectName;
58+
59+
// Check controller requirement
60+
let controllerAgents: AgentDefinition[] = [];
61+
const existingControllerConfig = await loadControllerConfig(cmRoot);
62+
const hasExistingControllerSession = existingControllerConfig?.controllerConfig?.sessionId;
63+
if (template.controller === true && !hasExistingControllerSession) {
64+
controllerAgents = await getControllerAgents(cwd);
65+
}
66+
const needsControllerSelection = controllerAgents.length > 0;
67+
68+
return {
69+
needsProjectName,
70+
needsTrackSelection,
71+
needsConditionsSelection,
72+
needsControllerSelection,
73+
controllerAgents,
74+
template,
75+
};
76+
}
77+
78+
/**
79+
* Check if specification file is required and valid
80+
* Throws ValidationError if template requires specification but it's missing/empty
81+
*
82+
* Path can be overridden via:
83+
* - CLI: --spec <path>
84+
* - Env: CODEMACHINE_SPEC_PATH
85+
*/
86+
export async function checkSpecificationRequired(options: { cwd?: string } = {}): Promise<void> {
87+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
88+
const cmRoot = path.join(cwd, '.codemachine');
89+
const specificationPath = process.env.CODEMACHINE_SPEC_PATH
90+
|| path.resolve(cwd, '.codemachine', 'inputs', 'specifications.md');
91+
92+
// Ensure workspace structure exists
93+
await ensureWorkspaceStructure({ cwd });
94+
95+
// Load template to check specification requirement
96+
const templatePath = await getTemplatePathFromTracking(cmRoot);
97+
const { template } = await loadTemplateWithPath(cwd, templatePath);
98+
99+
// Validate specification only if template requires it
100+
if (template.specification === true) {
101+
await validateSpecification(specificationPath);
102+
}
103+
}
104+
105+
/**
106+
* Main pre-flight check - verifies workflow can start
107+
* Throws ValidationError if workflow cannot start due to missing specification
108+
* Returns onboarding needs if user configuration is required
109+
*/
110+
export async function checkWorkflowCanStart(options: { cwd?: string } = {}): Promise<OnboardingNeeds> {
111+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
112+
113+
// First check specification requirement (throws if invalid)
114+
await checkSpecificationRequired({ cwd });
115+
116+
// Then check onboarding requirements (returns needs, doesn't throw)
117+
return checkOnboardingRequired({ cwd });
118+
}
119+
120+
/**
121+
* Quick check if any onboarding is needed
122+
* Useful for UI to decide whether to show onboarding flow
123+
*/
124+
export function needsOnboarding(needs: OnboardingNeeds): boolean {
125+
return (
126+
needs.needsProjectName ||
127+
needs.needsTrackSelection ||
128+
needs.needsConditionsSelection ||
129+
needs.needsControllerSelection
130+
);
131+
}

src/workflows/run.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,29 @@ import { StepIndexManager } from './indexing/index.js';
2121
import { registry } from '../infra/engines/index.js';
2222
import { MonitoringCleanup, AgentMonitorService, StatusService } from '../agents/monitoring/index.js';
2323
import { WorkflowEventBus, WorkflowEventEmitter } from './events/index.js';
24-
import { validateSpecification } from '../runtime/services/index.js';
2524
import { ensureWorkspaceStructure, mirrorSubAgents } from '../runtime/services/workspace/index.js';
2625
import { WorkflowRunner } from './runner/index.js';
2726
import { getUniqueAgentId } from './context/index.js';
2827
import { setupWorkflowMCP, cleanupWorkflowMCP } from './mcp.js';
2928

30-
export { validateSpecification, ValidationError } from '../runtime/services/index.js';
29+
// Re-export from preflight for backward compatibility
30+
export { ValidationError, checkWorkflowCanStart, checkSpecificationRequired, checkOnboardingRequired, needsOnboarding } from './preflight.js';
3131
export type { WorkflowStep, WorkflowTemplate };
3232

3333
/**
34-
* Run a workflow with validation
34+
* Run a workflow
35+
* Note: Pre-flight checks (specification validation) should be done via preflight.ts before calling this
3536
*/
3637
export async function runWorkflow(options: RunWorkflowOptions = {}): Promise<void> {
3738
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
38-
const specificationPath = options.specificationPath || path.resolve(cwd, '.codemachine', 'inputs', 'specifications.md');
3939

4040
// Ensure workspace structure exists (creates .codemachine folder tree)
4141
await ensureWorkspaceStructure({ cwd });
4242

43-
// Validate specification
44-
await validateSpecification(specificationPath);
43+
// Load template
44+
const cmRoot = path.join(cwd, '.codemachine');
45+
const templatePath = options.templatePath || (await getTemplatePathFromTracking(cmRoot));
46+
const { template } = await loadTemplateWithPath(cwd, templatePath);
4547

4648
// Clear screen for TUI
4749
if (process.stdout.isTTY) {
@@ -58,8 +60,7 @@ export async function runWorkflow(options: RunWorkflowOptions = {}): Promise<voi
5860
// Set up cleanup handlers
5961
MonitoringCleanup.setup();
6062

61-
// Load template (needed before we can set up the before-cleanup handler)
62-
const cmRoot = path.join(cwd, '.codemachine');
63+
// Initialize index manager for step tracking
6364
const indexManager = new StepIndexManager(cmRoot);
6465

6566
// Register callback to save session state before cleanup on Ctrl+C
@@ -84,10 +85,6 @@ export async function runWorkflow(options: RunWorkflowOptions = {}): Promise<voi
8485
},
8586
});
8687

87-
// Load template
88-
const templatePath = options.templatePath || (await getTemplatePathFromTracking(cmRoot));
89-
const { template } = await loadTemplateWithPath(cwd, templatePath);
90-
9188
debug('[Workflow] Using template: %s', template.name);
9289

9390
// Mirror sub-agents if template has subAgentIds

0 commit comments

Comments
 (0)