@davidorex/pi-workflows 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +169 -0
- package/agents/audit-fixer.agent.yaml +18 -0
- package/agents/code-explorer.agent.yaml +11 -0
- package/agents/decomposer.agent.yaml +51 -0
- package/agents/investigator.agent.yaml +49 -0
- package/agents/pattern-analyzer.agent.yaml +21 -0
- package/agents/phase-author.agent.yaml +16 -0
- package/agents/plan-decomposer.agent.yaml +12 -0
- package/agents/quality-analyzer.agent.yaml +21 -0
- package/agents/researcher.agent.yaml +38 -0
- package/agents/spec-implementer.agent.yaml +12 -0
- package/agents/structure-analyzer.agent.yaml +21 -0
- package/agents/synthesizer.agent.yaml +23 -0
- package/agents/verifier.agent.yaml +12 -0
- package/package.json +40 -0
- package/schemas/decomposition-specs.schema.json +50 -0
- package/schemas/execution-results.schema.json +50 -0
- package/schemas/investigation-findings.schema.json +45 -0
- package/schemas/pattern-analysis.schema.json +43 -0
- package/schemas/plan-breakdown.schema.json +22 -0
- package/schemas/quality-analysis.schema.json +35 -0
- package/schemas/research-findings.schema.json +44 -0
- package/schemas/structure-analysis.schema.json +42 -0
- package/schemas/synthesis.schema.json +33 -0
- package/schemas/verifier-output.schema.json +96 -0
- package/src/agent-spec.test.ts +289 -0
- package/src/agent-spec.ts +122 -0
- package/src/block-validation.test.ts +350 -0
- package/src/checkpoint.test.ts +237 -0
- package/src/checkpoint.ts +139 -0
- package/src/completion.test.ts +143 -0
- package/src/completion.ts +68 -0
- package/src/dag.test.ts +350 -0
- package/src/dag.ts +219 -0
- package/src/dispatch.test.ts +473 -0
- package/src/dispatch.ts +353 -0
- package/src/expression.test.ts +353 -0
- package/src/expression.ts +332 -0
- package/src/format.test.ts +80 -0
- package/src/format.ts +41 -0
- package/src/graduated-failure.test.ts +556 -0
- package/src/index.test.ts +114 -0
- package/src/index.ts +488 -0
- package/src/loop.test.ts +549 -0
- package/src/output.test.ts +213 -0
- package/src/output.ts +70 -0
- package/src/parallel-integration.test.ts +175 -0
- package/src/resume.test.ts +192 -0
- package/src/state.test.ts +192 -0
- package/src/state.ts +253 -0
- package/src/step-agent.test.ts +327 -0
- package/src/step-agent.ts +178 -0
- package/src/step-command.test.ts +330 -0
- package/src/step-command.ts +132 -0
- package/src/step-foreach.test.ts +647 -0
- package/src/step-foreach.ts +148 -0
- package/src/step-gate.test.ts +185 -0
- package/src/step-gate.ts +116 -0
- package/src/step-loop.test.ts +626 -0
- package/src/step-loop.ts +323 -0
- package/src/step-parallel.test.ts +475 -0
- package/src/step-parallel.ts +168 -0
- package/src/step-pause.test.ts +30 -0
- package/src/step-pause.ts +27 -0
- package/src/step-shared.test.ts +355 -0
- package/src/step-shared.ts +157 -0
- package/src/step-transform.test.ts +155 -0
- package/src/step-transform.ts +47 -0
- package/src/template-integration.test.ts +72 -0
- package/src/template.test.ts +162 -0
- package/src/template.ts +92 -0
- package/src/test-helpers.ts +51 -0
- package/src/tui.test.ts +355 -0
- package/src/tui.ts +182 -0
- package/src/types.ts +195 -0
- package/src/verifier-schema.test.ts +226 -0
- package/src/workflow-discovery.test.ts +105 -0
- package/src/workflow-discovery.ts +113 -0
- package/src/workflow-executor.test.ts +1530 -0
- package/src/workflow-executor.ts +810 -0
- package/src/workflow-sdk.test.ts +238 -0
- package/src/workflow-sdk.ts +262 -0
- package/src/workflow-spec.test.ts +576 -0
- package/src/workflow-spec.ts +471 -0
- package/templates/analyzers/base-analyzer.md +9 -0
- package/templates/analyzers/macros.md +1 -0
- package/templates/analyzers/patterns-task.md +11 -0
- package/templates/analyzers/patterns.md +15 -0
- package/templates/analyzers/quality-task.md +11 -0
- package/templates/analyzers/quality.md +15 -0
- package/templates/analyzers/structure-task.md +17 -0
- package/templates/analyzers/structure.md +15 -0
- package/templates/audit-fixer/task.md +67 -0
- package/templates/decomposer/task.md +56 -0
- package/templates/explorer/system.md +3 -0
- package/templates/explorer/task.md +9 -0
- package/templates/investigator/task.md +30 -0
- package/templates/phase-author/task.md +74 -0
- package/templates/plan-decomposer/task.md +46 -0
- package/templates/researcher/task.md +26 -0
- package/templates/spec-implementer/task.md +53 -0
- package/templates/synthesizer/system.md +3 -0
- package/templates/synthesizer/task.md +38 -0
- package/templates/verifier/task.md +57 -0
- package/workflows/create-phase.workflow.yaml +67 -0
- package/workflows/do-gap.workflow.yaml +153 -0
- package/workflows/fix-audit.workflow.yaml +217 -0
- package/workflows/gap-to-phase.workflow.yaml +98 -0
- package/workflows/parallel-analysis.workflow.yaml +62 -0
- package/workflows/parallel-explicit.workflow.yaml +42 -0
- package/workflows/pausable-analysis.workflow.yaml +44 -0
- package/workflows/resumable-analysis.workflow.yaml +42 -0
- package/workflows/self-implement.workflow.yaml +63 -0
- package/workflows/typed-analysis.workflow.yaml +63 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent spec loading — YAML specs are the source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Agent specs are declarative YAML files that define typed functions:
|
|
5
|
+
* InputSchema → OutputSchema, with template references for prompt
|
|
6
|
+
* composition. The .md that pi consumes is compiled at dispatch time
|
|
7
|
+
* from spec + templates + typed input. It exists in memory only.
|
|
8
|
+
*
|
|
9
|
+
* Search order (first match wins):
|
|
10
|
+
* 1. .pi/agents/<name>.agent.yaml (project)
|
|
11
|
+
* 2. ~/.pi/agent/agents/<name>.agent.yaml (user)
|
|
12
|
+
* 3. <package>/agents/<name>.agent.yaml (builtin)
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/** Check if a prompt.system value looks like a template file path vs inline text. */
|
|
18
|
+
function isTemplatePath(value: string | undefined): boolean {
|
|
19
|
+
if (!value) return false;
|
|
20
|
+
return value.endsWith(".md") || value.endsWith(".txt") || (value.includes("/") && !value.includes("\n"));
|
|
21
|
+
}
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import { parse as parseYaml } from "yaml";
|
|
24
|
+
import type { AgentSpec } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown when an agent spec file is not found in any search path.
|
|
28
|
+
*/
|
|
29
|
+
export class AgentNotFoundError extends Error {
|
|
30
|
+
public readonly agentName: string;
|
|
31
|
+
public readonly searchPaths: string[];
|
|
32
|
+
|
|
33
|
+
constructor(agentName: string, searchPaths: string[]) {
|
|
34
|
+
const pathList = searchPaths.map(p => ` - ${p}`).join("\n");
|
|
35
|
+
super(`Agent '${agentName}' not found. Searched:\n${pathList}`);
|
|
36
|
+
this.name = "AgentNotFoundError";
|
|
37
|
+
this.agentName = agentName;
|
|
38
|
+
this.searchPaths = searchPaths;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Thrown when an agent spec file exists but cannot be read or parsed.
|
|
44
|
+
*/
|
|
45
|
+
export class AgentParseError extends Error {
|
|
46
|
+
public readonly agentName: string;
|
|
47
|
+
public readonly filePath: string;
|
|
48
|
+
public readonly cause: Error;
|
|
49
|
+
|
|
50
|
+
constructor(agentName: string, filePath: string, cause: Error) {
|
|
51
|
+
super(`Agent '${agentName}' at ${filePath}: ${cause.message}`);
|
|
52
|
+
this.name = "AgentParseError";
|
|
53
|
+
this.agentName = agentName;
|
|
54
|
+
this.filePath = filePath;
|
|
55
|
+
this.cause = cause;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a YAML agent spec file into an AgentSpec.
|
|
61
|
+
*/
|
|
62
|
+
export function parseAgentYaml(filePath: string): AgentSpec {
|
|
63
|
+
const name = path.basename(filePath, ".agent.yaml");
|
|
64
|
+
|
|
65
|
+
let content: string;
|
|
66
|
+
try {
|
|
67
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new AgentParseError(name, filePath, err instanceof Error ? err : new Error(String(err)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let spec: any;
|
|
73
|
+
try {
|
|
74
|
+
spec = parseYaml(content);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new AgentParseError(name, filePath, err instanceof Error ? err : new Error(String(err)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle null/undefined from parsing empty file or non-mapping YAML
|
|
80
|
+
if (!spec || typeof spec !== "object") {
|
|
81
|
+
throw new AgentParseError(name, filePath, new Error("File is empty or does not contain a YAML mapping"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
name: spec.name || name,
|
|
86
|
+
description: spec.description,
|
|
87
|
+
role: spec.role,
|
|
88
|
+
model: spec.model,
|
|
89
|
+
thinking: spec.thinking,
|
|
90
|
+
tools: spec.tools,
|
|
91
|
+
extensions: spec.extensions,
|
|
92
|
+
skills: spec.skills,
|
|
93
|
+
output: spec.output?.file,
|
|
94
|
+
promptTemplate: isTemplatePath(spec.prompt?.system) ? spec.prompt?.system : undefined,
|
|
95
|
+
systemPrompt: isTemplatePath(spec.prompt?.system) ? undefined : spec.prompt?.system,
|
|
96
|
+
taskTemplate: spec.prompt?.task,
|
|
97
|
+
inputSchema: spec.input,
|
|
98
|
+
outputFormat: spec.output?.format,
|
|
99
|
+
outputSchema: spec.output?.schema,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create an agent loader that finds .agent.yaml specs.
|
|
105
|
+
*/
|
|
106
|
+
export function createAgentLoader(cwd: string, builtinDir?: string): (name: string) => AgentSpec {
|
|
107
|
+
const defaultBuiltinDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "agents");
|
|
108
|
+
|
|
109
|
+
return (name: string): AgentSpec => {
|
|
110
|
+
const searchPaths = [
|
|
111
|
+
path.join(cwd, ".pi", "agents", `${name}.agent.yaml`),
|
|
112
|
+
path.join(os.homedir(), ".pi", "agent", "agents", `${name}.agent.yaml`),
|
|
113
|
+
path.join(defaultBuiltinDir, `${name}.agent.yaml`),
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
for (const p of searchPaths) {
|
|
117
|
+
if (fs.existsSync(p)) return parseAgentYaml(p);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new AgentNotFoundError(name, searchPaths);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for post-step block validation with filesystem diff.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { snapshotBlockFiles, validateChangedBlocks } from "@davidorex/pi-project/src/block-validation.ts";
|
|
10
|
+
import { executeWorkflow } from "./workflow-executor.ts";
|
|
11
|
+
import type { WorkflowSpec } from "./types.ts";
|
|
12
|
+
import { mockCtx, mockPi } from "./test-helpers.ts";
|
|
13
|
+
|
|
14
|
+
// ── Unit tests for snapshotBlockFiles / validateChangedBlocks ──
|
|
15
|
+
|
|
16
|
+
describe("snapshotBlockFiles", () => {
|
|
17
|
+
it("returns empty map when .project/ does not exist", () => {
|
|
18
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-snap-"));
|
|
19
|
+
const result = snapshotBlockFiles(tmpDir);
|
|
20
|
+
assert.strictEqual(result.size, 0);
|
|
21
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("snapshots .json files in .project/", () => {
|
|
25
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-snap-"));
|
|
26
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
27
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(wfDir, "gaps.json"), "{}");
|
|
29
|
+
fs.writeFileSync(path.join(wfDir, "decisions.json"), "[]");
|
|
30
|
+
fs.writeFileSync(path.join(wfDir, "readme.txt"), "not json"); // should be ignored
|
|
31
|
+
|
|
32
|
+
const result = snapshotBlockFiles(tmpDir);
|
|
33
|
+
assert.strictEqual(result.size, 2);
|
|
34
|
+
assert.ok(result.has(path.join(wfDir, "gaps.json")));
|
|
35
|
+
assert.ok(result.has(path.join(wfDir, "decisions.json")));
|
|
36
|
+
assert.ok(!result.has(path.join(wfDir, "readme.txt")));
|
|
37
|
+
|
|
38
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("validateChangedBlocks", () => {
|
|
43
|
+
it("does nothing when no files changed", () => {
|
|
44
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
|
|
45
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
46
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(wfDir, "gaps.json"), "{}");
|
|
48
|
+
|
|
49
|
+
const before = snapshotBlockFiles(tmpDir);
|
|
50
|
+
// No changes — should not throw
|
|
51
|
+
assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
|
|
52
|
+
|
|
53
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("skips changed files with no matching schema", () => {
|
|
57
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
|
|
58
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
59
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const before = snapshotBlockFiles(tmpDir);
|
|
62
|
+
|
|
63
|
+
// Create a new file with no schema
|
|
64
|
+
fs.writeFileSync(path.join(wfDir, "no-schema.json"), '{"data": true}');
|
|
65
|
+
|
|
66
|
+
// Should not throw — no schema to validate against
|
|
67
|
+
assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
|
|
68
|
+
|
|
69
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("validates changed file against its schema — passes", () => {
|
|
73
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
|
|
74
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
75
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
76
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
77
|
+
|
|
78
|
+
// Create schema
|
|
79
|
+
fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["name"],
|
|
82
|
+
properties: { name: { type: "string" } },
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const before = snapshotBlockFiles(tmpDir);
|
|
86
|
+
|
|
87
|
+
// Create a valid block file
|
|
88
|
+
fs.writeFileSync(path.join(wfDir, "test-block.json"), JSON.stringify({ name: "valid" }));
|
|
89
|
+
|
|
90
|
+
assert.doesNotThrow(() => validateChangedBlocks(tmpDir, before));
|
|
91
|
+
|
|
92
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("throws on validation failure", () => {
|
|
96
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
|
|
97
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
98
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
99
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
// Create schema requiring name as string
|
|
102
|
+
fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
|
|
103
|
+
type: "object",
|
|
104
|
+
required: ["name"],
|
|
105
|
+
properties: { name: { type: "string" } },
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const before = snapshotBlockFiles(tmpDir);
|
|
109
|
+
|
|
110
|
+
// Create an invalid block file (name should be string, not number)
|
|
111
|
+
fs.writeFileSync(path.join(wfDir, "test-block.json"), JSON.stringify({ name: 123 }));
|
|
112
|
+
|
|
113
|
+
assert.throws(
|
|
114
|
+
() => validateChangedBlocks(tmpDir, before),
|
|
115
|
+
(err: unknown) => err instanceof Error && err.message.includes("Block validation failed"),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("detects modified files (not just new files)", () => {
|
|
122
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-val-"));
|
|
123
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
124
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
125
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
// Create schema
|
|
128
|
+
fs.writeFileSync(path.join(schemasDir, "data.schema.json"), JSON.stringify({
|
|
129
|
+
type: "object",
|
|
130
|
+
required: ["value"],
|
|
131
|
+
properties: { value: { type: "number" } },
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
// Create valid initial file
|
|
135
|
+
const blockPath = path.join(wfDir, "data.json");
|
|
136
|
+
fs.writeFileSync(blockPath, JSON.stringify({ value: 42 }));
|
|
137
|
+
|
|
138
|
+
const before = snapshotBlockFiles(tmpDir);
|
|
139
|
+
|
|
140
|
+
// Modify the file to be invalid (need to ensure mtime changes)
|
|
141
|
+
// Use a tiny delay or touch the file
|
|
142
|
+
const newContent = JSON.stringify({ value: "not a number" });
|
|
143
|
+
// Force mtime change by writing with a slight delay
|
|
144
|
+
const origMtime = fs.statSync(blockPath).mtimeMs;
|
|
145
|
+
fs.writeFileSync(blockPath, newContent);
|
|
146
|
+
// If mtime didn't change (same millisecond), force it
|
|
147
|
+
const newMtime = fs.statSync(blockPath).mtimeMs;
|
|
148
|
+
if (newMtime === origMtime) {
|
|
149
|
+
fs.utimesSync(blockPath, new Date(), new Date(Date.now() + 1000));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
assert.throws(
|
|
153
|
+
() => validateChangedBlocks(tmpDir, before),
|
|
154
|
+
(err: unknown) => err instanceof Error && err.message.includes("Block validation failed"),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Integration tests: block validation in workflow executor ──
|
|
162
|
+
|
|
163
|
+
describe("post-step block validation in executor", () => {
|
|
164
|
+
it("step that does not modify .project/ passes normally", async () => {
|
|
165
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
|
|
166
|
+
const spec: WorkflowSpec = {
|
|
167
|
+
name: "bv-no-change",
|
|
168
|
+
description: "step without block changes",
|
|
169
|
+
steps: {
|
|
170
|
+
compute: {
|
|
171
|
+
transform: { mapping: { result: 42 } },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
source: "project",
|
|
175
|
+
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const result = await executeWorkflow(spec, {}, {
|
|
179
|
+
ctx: mockCtx(tmpDir),
|
|
180
|
+
pi: mockPi(),
|
|
181
|
+
loadAgent: () => ({ name: "default" }),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
assert.strictEqual(result.status, "completed");
|
|
185
|
+
assert.strictEqual(result.steps.compute.status, "completed");
|
|
186
|
+
|
|
187
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("step that writes valid data to a .project/ block passes", async () => {
|
|
191
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
|
|
192
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
193
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
194
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
195
|
+
|
|
196
|
+
// Create schema
|
|
197
|
+
fs.writeFileSync(path.join(schemasDir, "result.schema.json"), JSON.stringify({
|
|
198
|
+
type: "object",
|
|
199
|
+
required: ["status"],
|
|
200
|
+
properties: { status: { type: "string" } },
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
// Use a command step that writes a valid block file
|
|
204
|
+
const spec: WorkflowSpec = {
|
|
205
|
+
name: "bv-valid-write",
|
|
206
|
+
description: "step writes valid block",
|
|
207
|
+
steps: {
|
|
208
|
+
writeBlock: {
|
|
209
|
+
command: `echo '{"status": "ok"}' > ${path.join(wfDir, "result.json")}`,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
source: "project",
|
|
213
|
+
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = await executeWorkflow(spec, {}, {
|
|
217
|
+
ctx: mockCtx(tmpDir),
|
|
218
|
+
pi: mockPi(),
|
|
219
|
+
loadAgent: () => ({ name: "default" }),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
assert.strictEqual(result.status, "completed");
|
|
223
|
+
assert.strictEqual(result.steps.writeBlock.status, "completed");
|
|
224
|
+
|
|
225
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("step that writes invalid data to a .project/ block fails", async () => {
|
|
229
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
|
|
230
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
231
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
232
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
233
|
+
|
|
234
|
+
// Create schema requiring status as string
|
|
235
|
+
fs.writeFileSync(path.join(schemasDir, "result.schema.json"), JSON.stringify({
|
|
236
|
+
type: "object",
|
|
237
|
+
required: ["status"],
|
|
238
|
+
properties: { status: { type: "string" } },
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
// Command step writes invalid data (status is number, not string)
|
|
242
|
+
const spec: WorkflowSpec = {
|
|
243
|
+
name: "bv-invalid-write",
|
|
244
|
+
description: "step writes invalid block",
|
|
245
|
+
steps: {
|
|
246
|
+
writeBlock: {
|
|
247
|
+
command: `echo '{"status": 123}' > ${path.join(wfDir, "result.json")}`,
|
|
248
|
+
},
|
|
249
|
+
shouldNotRun: {
|
|
250
|
+
transform: { mapping: { ran: true } },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
source: "project",
|
|
254
|
+
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = await executeWorkflow(spec, {}, {
|
|
258
|
+
ctx: mockCtx(tmpDir),
|
|
259
|
+
pi: mockPi(),
|
|
260
|
+
loadAgent: () => ({ name: "default" }),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
assert.strictEqual(result.status, "failed");
|
|
264
|
+
assert.strictEqual(result.steps.writeBlock.status, "failed");
|
|
265
|
+
assert.ok(result.steps.writeBlock.error?.includes("Block validation failed"));
|
|
266
|
+
// Fail-fast: next step should not run
|
|
267
|
+
assert.ok(!result.steps.shouldNotRun);
|
|
268
|
+
|
|
269
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("changed .project/ file with no matching schema is skipped", async () => {
|
|
273
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
|
|
274
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
275
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
276
|
+
// No schemas directory at all
|
|
277
|
+
|
|
278
|
+
// Command writes a JSON file with no schema
|
|
279
|
+
const spec: WorkflowSpec = {
|
|
280
|
+
name: "bv-no-schema",
|
|
281
|
+
description: "step writes block with no schema",
|
|
282
|
+
steps: {
|
|
283
|
+
writeBlock: {
|
|
284
|
+
command: `echo '{"anything": "goes"}' > ${path.join(wfDir, "custom.json")}`,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
source: "project",
|
|
288
|
+
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const result = await executeWorkflow(spec, {}, {
|
|
292
|
+
ctx: mockCtx(tmpDir),
|
|
293
|
+
pi: mockPi(),
|
|
294
|
+
loadAgent: () => ({ name: "default" }),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.strictEqual(result.status, "completed");
|
|
298
|
+
assert.strictEqual(result.steps.writeBlock.status, "completed");
|
|
299
|
+
|
|
300
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("validates multiple changed block files in one step", async () => {
|
|
304
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bv-exec-"));
|
|
305
|
+
const wfDir = path.join(tmpDir, ".project");
|
|
306
|
+
const schemasDir = path.join(wfDir, "schemas");
|
|
307
|
+
fs.mkdirSync(schemasDir, { recursive: true });
|
|
308
|
+
|
|
309
|
+
// Schema for file-a: requires name as string
|
|
310
|
+
fs.writeFileSync(path.join(schemasDir, "file-a.schema.json"), JSON.stringify({
|
|
311
|
+
type: "object",
|
|
312
|
+
required: ["name"],
|
|
313
|
+
properties: { name: { type: "string" } },
|
|
314
|
+
}));
|
|
315
|
+
// Schema for file-b: requires count as number
|
|
316
|
+
fs.writeFileSync(path.join(schemasDir, "file-b.schema.json"), JSON.stringify({
|
|
317
|
+
type: "object",
|
|
318
|
+
required: ["count"],
|
|
319
|
+
properties: { count: { type: "number" } },
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
// Write valid file-a, invalid file-b
|
|
323
|
+
const cmd = [
|
|
324
|
+
`echo '{"name": "ok"}' > ${path.join(wfDir, "file-a.json")}`,
|
|
325
|
+
`echo '{"count": "not-a-number"}' > ${path.join(wfDir, "file-b.json")}`,
|
|
326
|
+
].join(" && ");
|
|
327
|
+
|
|
328
|
+
const spec: WorkflowSpec = {
|
|
329
|
+
name: "bv-multi",
|
|
330
|
+
description: "step writes multiple blocks",
|
|
331
|
+
steps: {
|
|
332
|
+
writeBlocks: { command: cmd },
|
|
333
|
+
},
|
|
334
|
+
source: "project",
|
|
335
|
+
filePath: path.join(tmpDir, "test.project.yaml"),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const result = await executeWorkflow(spec, {}, {
|
|
339
|
+
ctx: mockCtx(tmpDir),
|
|
340
|
+
pi: mockPi(),
|
|
341
|
+
loadAgent: () => ({ name: "default" }),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
assert.strictEqual(result.status, "failed");
|
|
345
|
+
assert.strictEqual(result.steps.writeBlocks.status, "failed");
|
|
346
|
+
assert.ok(result.steps.writeBlocks.error?.includes("file-b.json"));
|
|
347
|
+
|
|
348
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
349
|
+
});
|
|
350
|
+
});
|