feat(FN-835): add script-mode workflow step execution with validation hardening

- Implement script-mode workflow steps that execute named commands from project settings with 2-minute timeout
- Harden PATCH /api/workflow-steps/:id validation to reject empty names, check resulting state validity
- Add comprehensive executor tests (420 lines) covering script mode execution, timeout, missing scripts, and prompt mode
- Add dashboard route tests for PATCH validation edge cases
- Update AGENTS.md and README.md with script-mode engine behavior documentation
This commit is contained in:
gsxdsm
2026-04-04 11:21:13 -07:00
parent 01ffffee81
commit 453e533eee
6 changed files with 580 additions and 18 deletions

View File

@@ -6503,6 +6503,55 @@ describe("PATCH /workflow-steps/:id", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId");
});
it("returns 400 when updating scriptName to nonexistent on existing script-mode step", async () => {
// Simulate an existing script-mode step
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001",
name: "Run Tests",
description: "Test runner",
mode: "script",
scriptName: "test",
prompt: "",
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { test: "pnpm test", lint: "pnpm lint" },
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
scriptName: "nonexistent",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not found in project settings");
// Should NOT have called updateWorkflowStep since validation failed
expect(store.updateWorkflowStep).not.toHaveBeenCalled();
});
it("returns 400 when updating script-mode step without scriptName (resulting state)", async () => {
// Simulate an existing script-mode step with scriptName cleared
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001",
name: "Run Tests",
description: "Test runner",
mode: "script",
scriptName: "",
prompt: "",
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
name: "Updated Name",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("scriptName is required when mode is 'script'");
});
});
describe("DELETE /workflow-steps/:id", () => {

View File

@@ -5968,17 +5968,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
updates.enabled = enabled;
}
// Validate script name references an actual script when switching to script mode
if (updates.mode === "script") {
const scriptNameToValidate = (updates.scriptName as string | undefined);
if (!scriptNameToValidate?.trim()) {
// Validate script-mode requirements against the resulting state (existing + updates)
// This catches cases where an existing script-mode step has its scriptName updated
// without the mode field being explicitly sent.
const existingStep = await scopedStore.getWorkflowStep(req.params.id);
const resultingMode: string | undefined = updates.mode !== undefined ? (updates.mode as string) : existingStep?.mode;
const resultingScriptName: string | undefined = updates.scriptName !== undefined ? (updates.scriptName as string) : existingStep?.scriptName;
if (resultingMode === "script") {
if (!resultingScriptName?.trim()) {
res.status(400).json({ error: "scriptName is required when mode is 'script'" });
return;
}
const settings = await scopedStore.getSettings();
const scripts = settings.scripts || {};
if (!(scriptNameToValidate.trim() in scripts)) {
res.status(400).json({ error: `Script '${scriptNameToValidate.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}` });
if (!(resultingScriptName.trim() in scripts)) {
res.status(400).json({ error: `Script '${resultingScriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}` });
return;
}
}