patchwork-os 0.2.0-alpha.2 → 0.2.0-alpha.22
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/README.bridge.md +6 -0
- package/README.md +40 -15
- package/deploy/bootstrap-vps.sh +184 -0
- package/dist/approvalHttp.d.ts +11 -2
- package/dist/approvalHttp.js +98 -10
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +12 -1
- package/dist/approvalQueue.js +25 -3
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +20 -0
- package/dist/automation.js +35 -0
- package/dist/automation.js.map +1 -1
- package/dist/bridge.js +145 -23
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeToken.js +57 -19
- package/dist/bridgeToken.js.map +1 -1
- package/dist/claudeDriver.d.ts +3 -1
- package/dist/claudeDriver.js +48 -0
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeOrchestrator.d.ts +1 -1
- package/dist/claudeOrchestrator.js +14 -8
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/launchd.d.ts +2 -0
- package/dist/commands/launchd.js +94 -0
- package/dist/commands/launchd.js.map +1 -0
- package/dist/commands/recipe.d.ts +256 -0
- package/dist/commands/recipe.js +1313 -0
- package/dist/commands/recipe.js.map +1 -0
- package/dist/config.d.ts +15 -2
- package/dist/config.js +94 -8
- package/dist/config.js.map +1 -1
- package/dist/connectors/baseConnector.d.ts +117 -0
- package/dist/connectors/baseConnector.js +213 -0
- package/dist/connectors/baseConnector.js.map +1 -0
- package/dist/connectors/confluence.d.ts +111 -0
- package/dist/connectors/confluence.js +406 -0
- package/dist/connectors/confluence.js.map +1 -0
- package/dist/connectors/fixtureLibrary.d.ts +21 -0
- package/dist/connectors/fixtureLibrary.js +70 -0
- package/dist/connectors/fixtureLibrary.js.map +1 -0
- package/dist/connectors/fixtureRecorder.d.ts +1 -0
- package/dist/connectors/fixtureRecorder.js +35 -0
- package/dist/connectors/fixtureRecorder.js.map +1 -0
- package/dist/connectors/github.d.ts +58 -8
- package/dist/connectors/github.js +312 -84
- package/dist/connectors/github.js.map +1 -1
- package/dist/connectors/gmail.d.ts +4 -1
- package/dist/connectors/gmail.js +93 -16
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleCalendar.d.ts +60 -0
- package/dist/connectors/googleCalendar.js +345 -0
- package/dist/connectors/googleCalendar.js.map +1 -0
- package/dist/connectors/jira.d.ts +98 -0
- package/dist/connectors/jira.js +379 -0
- package/dist/connectors/jira.js.map +1 -0
- package/dist/connectors/linear.d.ts +117 -0
- package/dist/connectors/linear.js +239 -0
- package/dist/connectors/linear.js.map +1 -0
- package/dist/connectors/mcpClient.d.ts +56 -0
- package/dist/connectors/mcpClient.js +189 -0
- package/dist/connectors/mcpClient.js.map +1 -0
- package/dist/connectors/mcpOAuth.d.ts +84 -0
- package/dist/connectors/mcpOAuth.js +389 -0
- package/dist/connectors/mcpOAuth.js.map +1 -0
- package/dist/connectors/mockConnector.d.ts +28 -0
- package/dist/connectors/mockConnector.js +81 -0
- package/dist/connectors/mockConnector.js.map +1 -0
- package/dist/connectors/notion.d.ts +143 -0
- package/dist/connectors/notion.js +424 -0
- package/dist/connectors/notion.js.map +1 -0
- package/dist/connectors/sentry.d.ts +43 -0
- package/dist/connectors/sentry.js +188 -0
- package/dist/connectors/sentry.js.map +1 -0
- package/dist/connectors/slack.d.ts +50 -0
- package/dist/connectors/slack.js +324 -0
- package/dist/connectors/slack.js.map +1 -0
- package/dist/connectors/tokenStorage.d.ts +35 -0
- package/dist/connectors/tokenStorage.js +394 -0
- package/dist/connectors/tokenStorage.js.map +1 -0
- package/dist/connectors/zendesk.d.ts +104 -0
- package/dist/connectors/zendesk.js +424 -0
- package/dist/connectors/zendesk.js.map +1 -0
- package/dist/drivers/claude/api.d.ts +11 -0
- package/dist/drivers/claude/api.js +54 -0
- package/dist/drivers/claude/api.js.map +1 -0
- package/dist/drivers/claude/envSanitizer.d.ts +7 -0
- package/dist/drivers/claude/envSanitizer.js +18 -0
- package/dist/drivers/claude/envSanitizer.js.map +1 -0
- package/dist/drivers/claude/streamParser.d.ts +38 -0
- package/dist/drivers/claude/streamParser.js +34 -0
- package/dist/drivers/claude/streamParser.js.map +1 -0
- package/dist/drivers/claude/subprocess.d.ts +19 -0
- package/dist/drivers/claude/subprocess.js +216 -0
- package/dist/drivers/claude/subprocess.js.map +1 -0
- package/dist/drivers/claude/subprocessSettings.d.ts +9 -0
- package/dist/drivers/claude/subprocessSettings.js +55 -0
- package/dist/drivers/claude/subprocessSettings.js.map +1 -0
- package/dist/drivers/gemini/index.d.ts +18 -0
- package/dist/drivers/gemini/index.js +210 -0
- package/dist/drivers/gemini/index.js.map +1 -0
- package/dist/drivers/grok/index.d.ts +11 -0
- package/dist/drivers/grok/index.js +22 -0
- package/dist/drivers/grok/index.js.map +1 -0
- package/dist/drivers/index.d.ts +23 -0
- package/dist/drivers/index.js +31 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/drivers/openai/index.d.ts +24 -0
- package/dist/drivers/openai/index.js +110 -0
- package/dist/drivers/openai/index.js.map +1 -0
- package/dist/drivers/types.d.ts +72 -0
- package/dist/drivers/types.js +30 -0
- package/dist/drivers/types.js.map +1 -0
- package/dist/featureFlags.d.ts +73 -0
- package/dist/featureFlags.js +203 -0
- package/dist/featureFlags.js.map +1 -0
- package/dist/fp/automationInterpreter.js +1 -0
- package/dist/fp/automationInterpreter.js.map +1 -1
- package/dist/fp/automationProgram.d.ts +1 -1
- package/dist/fp/automationProgram.js.map +1 -1
- package/dist/fp/policyParser.js +17 -0
- package/dist/fp/policyParser.js.map +1 -1
- package/dist/index.js +543 -37
- package/dist/index.js.map +1 -1
- package/dist/installGuard.d.ts +25 -0
- package/dist/installGuard.js +48 -0
- package/dist/installGuard.js.map +1 -0
- package/dist/oauth.d.ts +4 -1
- package/dist/oauth.js +50 -14
- package/dist/oauth.js.map +1 -1
- package/dist/patchworkConfig.d.ts +9 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +104 -0
- package/dist/recipes/chainedRunner.js +359 -0
- package/dist/recipes/chainedRunner.js.map +1 -0
- package/dist/recipes/dependencyGraph.d.ts +39 -0
- package/dist/recipes/dependencyGraph.js +199 -0
- package/dist/recipes/dependencyGraph.js.map +1 -0
- package/dist/recipes/legacyRecipeCompat.d.ts +1 -0
- package/dist/recipes/legacyRecipeCompat.js +97 -0
- package/dist/recipes/legacyRecipeCompat.js.map +1 -0
- package/dist/recipes/nestedRecipeStep.d.ts +58 -0
- package/dist/recipes/nestedRecipeStep.js +95 -0
- package/dist/recipes/nestedRecipeStep.js.map +1 -0
- package/dist/recipes/outputRegistry.d.ts +28 -0
- package/dist/recipes/outputRegistry.js +52 -0
- package/dist/recipes/outputRegistry.js.map +1 -0
- package/dist/recipes/scheduler.d.ts +23 -7
- package/dist/recipes/scheduler.js +135 -41
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schemaGenerator.d.ts +28 -0
- package/dist/recipes/schemaGenerator.js +484 -0
- package/dist/recipes/schemaGenerator.js.map +1 -0
- package/dist/recipes/templateEngine.d.ts +62 -0
- package/dist/recipes/templateEngine.js +182 -0
- package/dist/recipes/templateEngine.js.map +1 -0
- package/dist/recipes/toolRegistry.d.ts +181 -0
- package/dist/recipes/toolRegistry.js +300 -0
- package/dist/recipes/toolRegistry.js.map +1 -0
- package/dist/recipes/tools/calendar.d.ts +6 -0
- package/dist/recipes/tools/calendar.js +61 -0
- package/dist/recipes/tools/calendar.js.map +1 -0
- package/dist/recipes/tools/confluence.d.ts +6 -0
- package/dist/recipes/tools/confluence.js +254 -0
- package/dist/recipes/tools/confluence.js.map +1 -0
- package/dist/recipes/tools/diagnostics.d.ts +6 -0
- package/dist/recipes/tools/diagnostics.js +36 -0
- package/dist/recipes/tools/diagnostics.js.map +1 -0
- package/dist/recipes/tools/file.d.ts +6 -0
- package/dist/recipes/tools/file.js +170 -0
- package/dist/recipes/tools/file.js.map +1 -0
- package/dist/recipes/tools/git.d.ts +6 -0
- package/dist/recipes/tools/git.js +63 -0
- package/dist/recipes/tools/git.js.map +1 -0
- package/dist/recipes/tools/github.d.ts +6 -0
- package/dist/recipes/tools/github.js +91 -0
- package/dist/recipes/tools/github.js.map +1 -0
- package/dist/recipes/tools/gmail.d.ts +6 -0
- package/dist/recipes/tools/gmail.js +210 -0
- package/dist/recipes/tools/gmail.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +18 -0
- package/dist/recipes/tools/index.js +21 -0
- package/dist/recipes/tools/index.js.map +1 -0
- package/dist/recipes/tools/linear.d.ts +6 -0
- package/dist/recipes/tools/linear.js +83 -0
- package/dist/recipes/tools/linear.js.map +1 -0
- package/dist/recipes/tools/notion.d.ts +6 -0
- package/dist/recipes/tools/notion.js +278 -0
- package/dist/recipes/tools/notion.js.map +1 -0
- package/dist/recipes/tools/slack.d.ts +6 -0
- package/dist/recipes/tools/slack.js +72 -0
- package/dist/recipes/tools/slack.js.map +1 -0
- package/dist/recipes/tools/zendesk.d.ts +6 -0
- package/dist/recipes/tools/zendesk.js +245 -0
- package/dist/recipes/tools/zendesk.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +79 -0
- package/dist/recipes/yamlRunner.js +612 -346
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +14 -1
- package/dist/recipesHttp.js +21 -4
- package/dist/recipesHttp.js.map +1 -1
- package/dist/riskTier.js +1 -0
- package/dist/riskTier.js.map +1 -1
- package/dist/runLog.d.ts +23 -0
- package/dist/runLog.js +56 -1
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +19 -1
- package/dist/server.js +682 -31
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +2 -0
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/addLinearComment.d.ts +55 -0
- package/dist/tools/addLinearComment.js +72 -0
- package/dist/tools/addLinearComment.js.map +1 -0
- package/dist/tools/bridgeDoctor.js +2 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/createLinearIssue.d.ts +84 -0
- package/dist/tools/createLinearIssue.js +146 -0
- package/dist/tools/createLinearIssue.js.map +1 -0
- package/dist/tools/ctxGetTaskContext.d.ts +4 -1
- package/dist/tools/ctxGetTaskContext.js +45 -2
- package/dist/tools/ctxGetTaskContext.js.map +1 -1
- package/dist/tools/fetchCalendarEvents.d.ts +94 -0
- package/dist/tools/fetchCalendarEvents.js +97 -0
- package/dist/tools/fetchCalendarEvents.js.map +1 -0
- package/dist/tools/fetchGithubIssue.d.ts +80 -0
- package/dist/tools/fetchGithubIssue.js +84 -0
- package/dist/tools/fetchGithubIssue.js.map +1 -0
- package/dist/tools/fetchGithubPR.d.ts +89 -0
- package/dist/tools/fetchGithubPR.js +96 -0
- package/dist/tools/fetchGithubPR.js.map +1 -0
- package/dist/tools/fetchLinearIssue.d.ts +112 -0
- package/dist/tools/fetchLinearIssue.js +129 -0
- package/dist/tools/fetchLinearIssue.js.map +1 -0
- package/dist/tools/fetchSentryIssue.d.ts +143 -0
- package/dist/tools/fetchSentryIssue.js +150 -0
- package/dist/tools/fetchSentryIssue.js.map +1 -0
- package/dist/tools/fetchSlackProfile.d.ts +43 -0
- package/dist/tools/fetchSlackProfile.js +46 -0
- package/dist/tools/fetchSlackProfile.js.map +1 -0
- package/dist/tools/getConnectorStatus.d.ts +58 -0
- package/dist/tools/getConnectorStatus.js +56 -0
- package/dist/tools/getConnectorStatus.js.map +1 -0
- package/dist/tools/github/actions.js +4 -2
- package/dist/tools/github/actions.js.map +1 -1
- package/dist/tools/github/composite.d.ts +339 -0
- package/dist/tools/github/composite.js +343 -0
- package/dist/tools/github/composite.js.map +1 -0
- package/dist/tools/github/index.d.ts +2 -1
- package/dist/tools/github/index.js +2 -1
- package/dist/tools/github/index.js.map +1 -1
- package/dist/tools/github/issues.js +8 -4
- package/dist/tools/github/issues.js.map +1 -1
- package/dist/tools/github/pr.d.ts +122 -0
- package/dist/tools/github/pr.js +195 -5
- package/dist/tools/github/pr.js.map +1 -1
- package/dist/tools/index.js +36 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/searchTools.js +1 -1
- package/dist/tools/searchTools.js.map +1 -1
- package/dist/tools/slackListChannels.d.ts +65 -0
- package/dist/tools/slackListChannels.js +70 -0
- package/dist/tools/slackListChannels.js.map +1 -0
- package/dist/tools/slackPostMessage.d.ts +57 -0
- package/dist/tools/slackPostMessage.js +77 -0
- package/dist/tools/slackPostMessage.js.map +1 -0
- package/dist/tools/updateLinearIssue.d.ts +89 -0
- package/dist/tools/updateLinearIssue.js +117 -0
- package/dist/tools/updateLinearIssue.js.map +1 -0
- package/dist/transport.d.ts +7 -1
- package/dist/transport.js +85 -11
- package/dist/transport.js.map +1 -1
- package/package.json +4 -2
- package/scripts/start-all.sh +56 -19
- package/templates/automation-policies/recipe-authoring.json +25 -0
- package/templates/automation-policy.example.json +6 -0
- package/templates/co.patchwork-os.bridge.plist +34 -0
- package/templates/recipes/ctx-loop-test.yaml +75 -0
- package/templates/recipes/lint-on-save.yaml +1 -2
- package/templates/recipes/morning-brief-slack.yaml +57 -0
- package/templates/recipes/morning-brief.yaml +21 -5
- package/templates/recipes/sentry-to-linear.yaml +77 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe CLI commands — new, lint, test, watch, record, fmt
|
|
3
|
+
*
|
|
4
|
+
* Implements the A2 CLI UX milestone for recipe authoring.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync, } from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { Ajv } from "ajv";
|
|
11
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
12
|
+
import "../recipes/tools/index.js";
|
|
13
|
+
import { loadFixtureLibrary } from "../connectors/fixtureLibrary.js";
|
|
14
|
+
import { MockConnector } from "../connectors/mockConnector.js";
|
|
15
|
+
import { FLAG_SCHEMA_LINT, isEnabled } from "../featureFlags.js";
|
|
16
|
+
import { normalizeRecipeForRuntime } from "../recipes/legacyRecipeCompat.js";
|
|
17
|
+
import { generateSchemaSet, writeSchemas } from "../recipes/schemaGenerator.js";
|
|
18
|
+
import { getTool, isConnectorNamespace, listToolOutputContextKeys, seedToolOutputPreviewContext, } from "../recipes/toolRegistry.js";
|
|
19
|
+
import { buildChainedDeps, dispatchRecipe, loadYamlRecipe, render, runYamlRecipe, } from "../recipes/yamlRunner.js";
|
|
20
|
+
const RECIPES_DIR = join(os.homedir(), ".patchwork", "recipes");
|
|
21
|
+
const FIXTURES_DIR = join(os.homedir(), ".patchwork", "fixtures");
|
|
22
|
+
const RECIPE_SCHEMA_HEADER = "# yaml-language-server: $schema=https://patchwork.sh/schema/recipe.v1.json";
|
|
23
|
+
const RECIPE_API_VERSION = "patchwork.sh/v1";
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// patchwork recipe new
|
|
26
|
+
// ============================================================================
|
|
27
|
+
const TEMPLATES = {
|
|
28
|
+
minimal: `apiVersion: ${RECIPE_API_VERSION}
|
|
29
|
+
name: {{name}}
|
|
30
|
+
description: {{description}}
|
|
31
|
+
trigger:
|
|
32
|
+
type: manual
|
|
33
|
+
steps:
|
|
34
|
+
- tool: file.write
|
|
35
|
+
path: ~/.patchwork/inbox/{{name}}.md
|
|
36
|
+
content: "Hello from {{name}}\\n"
|
|
37
|
+
`,
|
|
38
|
+
daily: `apiVersion: ${RECIPE_API_VERSION}
|
|
39
|
+
name: {{name}}
|
|
40
|
+
description: {{description}}
|
|
41
|
+
trigger:
|
|
42
|
+
type: cron
|
|
43
|
+
at: "0 9 * * 1-5"
|
|
44
|
+
steps:
|
|
45
|
+
- tool: git.log_since
|
|
46
|
+
since: "24h"
|
|
47
|
+
into: commits
|
|
48
|
+
- agent:
|
|
49
|
+
prompt: |
|
|
50
|
+
Summarize these commits for a daily standup:
|
|
51
|
+
{{commits}}
|
|
52
|
+
into: summary
|
|
53
|
+
- tool: file.write
|
|
54
|
+
path: ~/.patchwork/inbox/{{name}}-{{date}}.md
|
|
55
|
+
content: "# {{name}}\\n\\n{{summary}}\\n"
|
|
56
|
+
`,
|
|
57
|
+
inbox: `apiVersion: ${RECIPE_API_VERSION}
|
|
58
|
+
name: {{name}}
|
|
59
|
+
description: {{description}}
|
|
60
|
+
trigger:
|
|
61
|
+
type: manual
|
|
62
|
+
steps:
|
|
63
|
+
- tool: gmail.fetch_unread
|
|
64
|
+
since: "24h"
|
|
65
|
+
max: 20
|
|
66
|
+
into: unread
|
|
67
|
+
- tool: github.list_issues
|
|
68
|
+
assignee: "@me"
|
|
69
|
+
max: 10
|
|
70
|
+
into: issues
|
|
71
|
+
- agent:
|
|
72
|
+
prompt: |
|
|
73
|
+
Summarize my inbox. Unread emails: {{unread}}.
|
|
74
|
+
Assigned issues: {{issues}}.
|
|
75
|
+
into: summary
|
|
76
|
+
- tool: file.write
|
|
77
|
+
path: ~/.patchwork/inbox/{{name}}-{{date}}.md
|
|
78
|
+
content: "# {{name}}\\n\\n{{summary}}\\n"
|
|
79
|
+
`,
|
|
80
|
+
};
|
|
81
|
+
export function runNew(options) {
|
|
82
|
+
if (!options.name) {
|
|
83
|
+
throw new Error("Recipe name is required");
|
|
84
|
+
}
|
|
85
|
+
if (!options.description) {
|
|
86
|
+
throw new Error("Recipe description is required");
|
|
87
|
+
}
|
|
88
|
+
const templateKey = options.template ?? "minimal";
|
|
89
|
+
const template = TEMPLATES[templateKey];
|
|
90
|
+
if (!template) {
|
|
91
|
+
throw new Error(`Unknown template: "${templateKey}". ` +
|
|
92
|
+
`Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
const today = new Date().toISOString().split("T")[0] ?? "";
|
|
95
|
+
const body = template
|
|
96
|
+
.replace(/\{\{name\}\}/g, options.name)
|
|
97
|
+
.replace(/\{\{description\}\}/g, options.description)
|
|
98
|
+
.replace(/\{\{date\}\}/g, today);
|
|
99
|
+
const content = `${RECIPE_SCHEMA_HEADER}\n${body}`;
|
|
100
|
+
const outputDir = options.outputDir ?? RECIPES_DIR;
|
|
101
|
+
if (!existsSync(outputDir)) {
|
|
102
|
+
mkdirSync(outputDir, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
const outputPath = join(outputDir, `${options.name}.yaml`);
|
|
105
|
+
if (existsSync(outputPath)) {
|
|
106
|
+
throw new Error(`Recipe already exists: ${outputPath}`);
|
|
107
|
+
}
|
|
108
|
+
writeFileSync(outputPath, content);
|
|
109
|
+
return { path: outputPath, content };
|
|
110
|
+
}
|
|
111
|
+
export function listTemplates() {
|
|
112
|
+
return Object.keys(TEMPLATES);
|
|
113
|
+
}
|
|
114
|
+
export async function runSchema(outputDir) {
|
|
115
|
+
const resolvedOutputDir = resolve(outputDir);
|
|
116
|
+
const schemas = generateSchemaSet();
|
|
117
|
+
const filesWritten = [];
|
|
118
|
+
await writeSchemas(resolvedOutputDir, schemas, (filePath, content) => {
|
|
119
|
+
const dir = dirname(filePath);
|
|
120
|
+
if (!existsSync(dir)) {
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(filePath, content);
|
|
124
|
+
filesWritten.push(filePath);
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
outputDir: resolvedOutputDir,
|
|
128
|
+
filesWritten,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Lint a recipe file against the schema.
|
|
133
|
+
* Falls back to basic YAML parsing if schema linting is disabled.
|
|
134
|
+
*/
|
|
135
|
+
export function runLint(recipePath) {
|
|
136
|
+
const issues = [];
|
|
137
|
+
// Check file exists
|
|
138
|
+
if (!existsSync(recipePath)) {
|
|
139
|
+
return {
|
|
140
|
+
valid: false,
|
|
141
|
+
issues: [{ level: "error", message: `File not found: ${recipePath}` }],
|
|
142
|
+
warnings: 0,
|
|
143
|
+
errors: 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Read and parse
|
|
147
|
+
let content;
|
|
148
|
+
let recipe;
|
|
149
|
+
try {
|
|
150
|
+
content = readFileSync(recipePath, "utf-8");
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return {
|
|
154
|
+
valid: false,
|
|
155
|
+
issues: [
|
|
156
|
+
{
|
|
157
|
+
level: "error",
|
|
158
|
+
message: `Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
warnings: 0,
|
|
162
|
+
errors: 1,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
recipe = parseYaml(content);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
valid: false,
|
|
171
|
+
issues: [
|
|
172
|
+
{
|
|
173
|
+
level: "error",
|
|
174
|
+
message: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
warnings: 0,
|
|
178
|
+
errors: 1,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const normalizedRecipe = normalizeRecipeForValidation(recipe);
|
|
182
|
+
// Basic structural validation
|
|
183
|
+
if (!normalizedRecipe || typeof normalizedRecipe !== "object") {
|
|
184
|
+
issues.push({ level: "error", message: "Recipe must be a YAML object" });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const r = normalizedRecipe;
|
|
188
|
+
// Required fields
|
|
189
|
+
if (!r.name || typeof r.name !== "string") {
|
|
190
|
+
issues.push({
|
|
191
|
+
level: "error",
|
|
192
|
+
message: "Missing or invalid 'name' field",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
else if (!/^[a-z0-9-]+$/.test(r.name)) {
|
|
196
|
+
issues.push({
|
|
197
|
+
level: "warning",
|
|
198
|
+
message: "Recipe name should use kebab-case (lowercase letters, numbers, hyphens)",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (!r.description || typeof r.description !== "string") {
|
|
202
|
+
issues.push({ level: "warning", message: "Missing 'description' field" });
|
|
203
|
+
}
|
|
204
|
+
if (!r.trigger || typeof r.trigger !== "object") {
|
|
205
|
+
issues.push({
|
|
206
|
+
level: "error",
|
|
207
|
+
message: "Missing or invalid 'trigger' field",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const trigger = r.trigger;
|
|
212
|
+
const validTypes = [
|
|
213
|
+
"manual",
|
|
214
|
+
"cron",
|
|
215
|
+
"webhook",
|
|
216
|
+
"file_watch",
|
|
217
|
+
"git_hook",
|
|
218
|
+
"on_file_save",
|
|
219
|
+
"on_test_run",
|
|
220
|
+
"chained",
|
|
221
|
+
];
|
|
222
|
+
if (!trigger.type || !validTypes.includes(trigger.type)) {
|
|
223
|
+
issues.push({
|
|
224
|
+
level: "error",
|
|
225
|
+
message: `Invalid trigger.type. Must be one of: ${validTypes.join(", ")}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (trigger.type === "cron" && !trigger.at) {
|
|
229
|
+
issues.push({
|
|
230
|
+
level: "warning",
|
|
231
|
+
message: "cron trigger should have 'at' (cron expression)",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(r.steps) || r.steps.length === 0) {
|
|
236
|
+
issues.push({
|
|
237
|
+
level: "error",
|
|
238
|
+
message: "Recipe must have at least one step",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const triggerType = r.trigger && typeof r.trigger === "object"
|
|
243
|
+
? r.trigger.type
|
|
244
|
+
: undefined;
|
|
245
|
+
const allowNestedRecipeSteps = triggerType === "chained";
|
|
246
|
+
// Validate each step
|
|
247
|
+
for (let i = 0; i < r.steps.length; i++) {
|
|
248
|
+
const step = r.steps[i];
|
|
249
|
+
const hasTool = typeof step.tool === "string";
|
|
250
|
+
const hasAgent = !!step.agent;
|
|
251
|
+
const hasNestedRecipe = allowNestedRecipeSteps && typeof step.recipe === "string";
|
|
252
|
+
if (!hasTool && !hasAgent && !hasNestedRecipe) {
|
|
253
|
+
issues.push({
|
|
254
|
+
level: "error",
|
|
255
|
+
message: `Step ${i + 1}: Must have 'tool' or 'agent' field${allowNestedRecipeSteps ? " (or 'recipe' for chained recipes)" : ""}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (step.agent && typeof step.agent === "object") {
|
|
259
|
+
const agent = step.agent;
|
|
260
|
+
if (!agent.prompt || typeof agent.prompt !== "string") {
|
|
261
|
+
issues.push({
|
|
262
|
+
level: "error",
|
|
263
|
+
message: `Step ${i + 1}: Agent step missing 'prompt'`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
validateTemplateReferences(r, issues);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Schema-based validation (if enabled)
|
|
272
|
+
if (isEnabled(FLAG_SCHEMA_LINT)) {
|
|
273
|
+
issues.push(...validateRecipeSchema(normalizedRecipe));
|
|
274
|
+
}
|
|
275
|
+
const errors = issues.filter((i) => i.level === "error").length;
|
|
276
|
+
const warnings = issues.filter((i) => i.level === "warning").length;
|
|
277
|
+
return {
|
|
278
|
+
valid: errors === 0,
|
|
279
|
+
issues,
|
|
280
|
+
warnings,
|
|
281
|
+
errors,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function normalizeRecipeForValidation(recipe) {
|
|
285
|
+
const normalized = normalizeRecipeForRuntime(recipe);
|
|
286
|
+
if (!normalized ||
|
|
287
|
+
typeof normalized !== "object" ||
|
|
288
|
+
Array.isArray(normalized)) {
|
|
289
|
+
return normalized;
|
|
290
|
+
}
|
|
291
|
+
const validationReady = {
|
|
292
|
+
...normalized,
|
|
293
|
+
};
|
|
294
|
+
if (validationReady.trigger &&
|
|
295
|
+
typeof validationReady.trigger === "object" &&
|
|
296
|
+
!Array.isArray(validationReady.trigger)) {
|
|
297
|
+
validationReady.trigger = normalizeValidationTrigger(validationReady.trigger);
|
|
298
|
+
}
|
|
299
|
+
if (Array.isArray(validationReady.steps)) {
|
|
300
|
+
validationReady.steps = flattenValidationSteps(validationReady.steps);
|
|
301
|
+
}
|
|
302
|
+
return validationReady;
|
|
303
|
+
}
|
|
304
|
+
function normalizeValidationTrigger(trigger) {
|
|
305
|
+
const normalized = { ...trigger };
|
|
306
|
+
if (normalized.type === "event") {
|
|
307
|
+
normalized.type = "webhook";
|
|
308
|
+
normalized.legacyType = "event";
|
|
309
|
+
if (typeof normalized.on === "string") {
|
|
310
|
+
normalized.eventSource = normalized.on;
|
|
311
|
+
}
|
|
312
|
+
delete normalized.on;
|
|
313
|
+
if (normalized.filter !== undefined &&
|
|
314
|
+
typeof normalized.filter !== "string") {
|
|
315
|
+
normalized.eventFilter = normalized.filter;
|
|
316
|
+
delete normalized.filter;
|
|
317
|
+
}
|
|
318
|
+
if (normalized.lead_time_hours !== undefined) {
|
|
319
|
+
normalized.eventLeadTimeHours = normalized.lead_time_hours;
|
|
320
|
+
delete normalized.lead_time_hours;
|
|
321
|
+
}
|
|
322
|
+
if (normalized.lead_time_minutes !== undefined) {
|
|
323
|
+
normalized.eventLeadTimeMinutes = normalized.lead_time_minutes;
|
|
324
|
+
delete normalized.lead_time_minutes;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return normalized;
|
|
328
|
+
}
|
|
329
|
+
function flattenValidationSteps(steps) {
|
|
330
|
+
const normalizedSteps = [];
|
|
331
|
+
for (const step of steps) {
|
|
332
|
+
normalizedSteps.push(...flattenValidationStep(step));
|
|
333
|
+
}
|
|
334
|
+
return normalizedSteps;
|
|
335
|
+
}
|
|
336
|
+
function flattenValidationStep(step) {
|
|
337
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
338
|
+
return [step];
|
|
339
|
+
}
|
|
340
|
+
const record = step;
|
|
341
|
+
if (Array.isArray(record.parallel)) {
|
|
342
|
+
const parallelSteps = [];
|
|
343
|
+
for (const nestedStep of record.parallel) {
|
|
344
|
+
parallelSteps.push(...flattenValidationStep(nestedStep));
|
|
345
|
+
}
|
|
346
|
+
return parallelSteps;
|
|
347
|
+
}
|
|
348
|
+
if (Array.isArray(record.branch)) {
|
|
349
|
+
const branchSteps = [];
|
|
350
|
+
for (const branchStep of record.branch) {
|
|
351
|
+
if (!branchStep ||
|
|
352
|
+
typeof branchStep !== "object" ||
|
|
353
|
+
Array.isArray(branchStep)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const branchRecord = branchStep;
|
|
357
|
+
const otherwiseStep = branchRecord.otherwise;
|
|
358
|
+
if (otherwiseStep &&
|
|
359
|
+
typeof otherwiseStep === "object" &&
|
|
360
|
+
!Array.isArray(otherwiseStep)) {
|
|
361
|
+
branchSteps.push(...flattenValidationStep(otherwiseStep));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
branchSteps.push(...flattenValidationStep(branchRecord));
|
|
365
|
+
}
|
|
366
|
+
return branchSteps.length > 0 ? branchSteps : [record];
|
|
367
|
+
}
|
|
368
|
+
return [record];
|
|
369
|
+
}
|
|
370
|
+
function validateRecipeSchema(recipe) {
|
|
371
|
+
try {
|
|
372
|
+
const schemas = generateSchemaSet();
|
|
373
|
+
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
374
|
+
for (const schema of Object.values(schemas.namespaces)) {
|
|
375
|
+
ajv.addSchema(schema);
|
|
376
|
+
}
|
|
377
|
+
const validate = ajv.compile(schemas.recipe);
|
|
378
|
+
const valid = validate(recipe);
|
|
379
|
+
if (valid) {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
return (validate.errors ?? []).map(toSchemaLintIssue);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
level: "error",
|
|
388
|
+
message: `Schema validation failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function registerRecipeContextKeys(recipe, availableKeys) {
|
|
394
|
+
const trigger = recipe.trigger && typeof recipe.trigger === "object"
|
|
395
|
+
? recipe.trigger
|
|
396
|
+
: undefined;
|
|
397
|
+
if (trigger?.type === "git_hook") {
|
|
398
|
+
availableKeys.add("hash");
|
|
399
|
+
availableKeys.add("message");
|
|
400
|
+
availableKeys.add("branch");
|
|
401
|
+
}
|
|
402
|
+
if (trigger?.type === "on_file_save") {
|
|
403
|
+
availableKeys.add("file");
|
|
404
|
+
}
|
|
405
|
+
if (trigger?.type === "on_test_run") {
|
|
406
|
+
availableKeys.add("runner");
|
|
407
|
+
availableKeys.add("failed");
|
|
408
|
+
availableKeys.add("passed");
|
|
409
|
+
availableKeys.add("total");
|
|
410
|
+
availableKeys.add("failures");
|
|
411
|
+
}
|
|
412
|
+
if (trigger?.legacyType === "event") {
|
|
413
|
+
availableKeys.add("event");
|
|
414
|
+
}
|
|
415
|
+
if (Array.isArray(trigger?.vars)) {
|
|
416
|
+
for (const item of trigger.vars) {
|
|
417
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
418
|
+
availableKeys.add(item.name);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(trigger?.inputs)) {
|
|
423
|
+
for (const item of trigger.inputs) {
|
|
424
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
425
|
+
availableKeys.add(item.name);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (Array.isArray(recipe.context)) {
|
|
430
|
+
for (const block of recipe.context) {
|
|
431
|
+
if (!block || typeof block !== "object") {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const typedBlock = block;
|
|
435
|
+
if (typedBlock.type === "env" && Array.isArray(typedBlock.keys)) {
|
|
436
|
+
for (const key of typedBlock.keys) {
|
|
437
|
+
if (typeof key === "string") {
|
|
438
|
+
availableKeys.add(key);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function toSchemaLintIssue(error) {
|
|
446
|
+
const path = error.instancePath
|
|
447
|
+
? error.instancePath.slice(1).replace(/\//g, ".")
|
|
448
|
+
: "recipe";
|
|
449
|
+
return {
|
|
450
|
+
level: "error",
|
|
451
|
+
message: `Schema validation: ${path} ${error.message ?? "is invalid"}`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function validateTemplateReferences(recipe, issues) {
|
|
455
|
+
const BUILTIN_KEYS = new Set([
|
|
456
|
+
"date",
|
|
457
|
+
"time",
|
|
458
|
+
"YYYY-MM",
|
|
459
|
+
"YYYY-MM-DD",
|
|
460
|
+
"ISO_NOW",
|
|
461
|
+
]);
|
|
462
|
+
const availableKeys = new Set(BUILTIN_KEYS);
|
|
463
|
+
registerRecipeContextKeys(recipe, availableKeys);
|
|
464
|
+
const triggerType = recipe.trigger && typeof recipe.trigger === "object"
|
|
465
|
+
? recipe.trigger.type
|
|
466
|
+
: undefined;
|
|
467
|
+
const isChainedRecipe = triggerType === "chained";
|
|
468
|
+
const steps = Array.isArray(recipe.steps)
|
|
469
|
+
? recipe.steps
|
|
470
|
+
: [];
|
|
471
|
+
// Track all `into` keys produced so far for duplicate detection
|
|
472
|
+
const seenIntoKeys = new Map(); // key → first step (1-indexed)
|
|
473
|
+
for (let index = 0; index < steps.length; index++) {
|
|
474
|
+
const step = steps[index] ?? {};
|
|
475
|
+
const templates = collectRenderedTemplates(step, isChainedRecipe);
|
|
476
|
+
for (const template of templates) {
|
|
477
|
+
for (const expression of extractTemplateExpressions(template.value)) {
|
|
478
|
+
for (const identifier of extractTemplateIdentifiers(expression)) {
|
|
479
|
+
if (!availableKeys.has(identifier)) {
|
|
480
|
+
issues.push({
|
|
481
|
+
level: "error",
|
|
482
|
+
message: `Step ${index + 1}: Unknown template reference '{{${expression}}}' in ${template.label}`,
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Validate the `into` key produced by this step
|
|
490
|
+
const intoKey = resolveStepIntoKey(step, isChainedRecipe);
|
|
491
|
+
if (intoKey) {
|
|
492
|
+
if (BUILTIN_KEYS.has(intoKey)) {
|
|
493
|
+
issues.push({
|
|
494
|
+
level: "error",
|
|
495
|
+
message: `Step ${index + 1}: 'into: ${intoKey}' shadows a built-in context key`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const firstSeen = seenIntoKeys.get(intoKey);
|
|
500
|
+
if (firstSeen !== undefined) {
|
|
501
|
+
issues.push({
|
|
502
|
+
level: "warning",
|
|
503
|
+
message: `Step ${index + 1}: 'into: ${intoKey}' overwrites value already written by step ${firstSeen}`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
seenIntoKeys.set(intoKey, index + 1);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
registerStepContextKeys(step, availableKeys, isChainedRecipe);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Return the context key a step writes to (its `into` value), or null. */
|
|
515
|
+
function resolveStepIntoKey(step, isChainedRecipe) {
|
|
516
|
+
if (step.agent && typeof step.agent === "object") {
|
|
517
|
+
const agent = step.agent;
|
|
518
|
+
return typeof agent.into === "string" ? agent.into : "agent_output";
|
|
519
|
+
}
|
|
520
|
+
if (typeof step.into === "string")
|
|
521
|
+
return step.into;
|
|
522
|
+
if (isChainedRecipe && typeof step.id === "string")
|
|
523
|
+
return step.id;
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
function collectRenderedTemplates(step, isChainedRecipe) {
|
|
527
|
+
const templates = [];
|
|
528
|
+
for (const [key, value] of Object.entries(step)) {
|
|
529
|
+
if (key === "tool" || key === "into" || key === "agent") {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (typeof value === "string") {
|
|
533
|
+
templates.push({ label: key, value });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (step.agent && typeof step.agent === "object") {
|
|
537
|
+
const agent = step.agent;
|
|
538
|
+
if (typeof agent.prompt === "string") {
|
|
539
|
+
templates.push({ label: "agent.prompt", value: agent.prompt });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (isChainedRecipe && step.vars && typeof step.vars === "object") {
|
|
543
|
+
for (const [key, value] of Object.entries(step.vars)) {
|
|
544
|
+
if (typeof value === "string") {
|
|
545
|
+
templates.push({ label: `vars.${key}`, value });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return templates;
|
|
550
|
+
}
|
|
551
|
+
function extractTemplateExpressions(template) {
|
|
552
|
+
const matches = template.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g);
|
|
553
|
+
const expressions = [];
|
|
554
|
+
for (const match of matches) {
|
|
555
|
+
const expression = match[1]?.trim();
|
|
556
|
+
if (expression) {
|
|
557
|
+
expressions.push(expression);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return expressions;
|
|
561
|
+
}
|
|
562
|
+
function extractTemplateIdentifiers(expression) {
|
|
563
|
+
const reserved = new Set(["true", "false", "null"]);
|
|
564
|
+
const identifiers = new Set();
|
|
565
|
+
for (const match of expression.matchAll(/[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*/g)) {
|
|
566
|
+
const rawIdentifier = match[0];
|
|
567
|
+
if (!rawIdentifier) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const rootIdentifier = rawIdentifier.split(".")[0] ?? rawIdentifier;
|
|
571
|
+
if (!reserved.has(rootIdentifier)) {
|
|
572
|
+
identifiers.add(rootIdentifier);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return Array.from(identifiers);
|
|
576
|
+
}
|
|
577
|
+
function registerStepContextKeys(step, availableKeys, isChainedRecipe = false) {
|
|
578
|
+
if (isChainedRecipe) {
|
|
579
|
+
const stepId = typeof step.id === "string" ? step.id : undefined;
|
|
580
|
+
if (stepId) {
|
|
581
|
+
availableKeys.add(stepId);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (step.agent && typeof step.agent === "object") {
|
|
585
|
+
const agent = step.agent;
|
|
586
|
+
const intoKey = typeof agent.into === "string" ? agent.into : "agent_output";
|
|
587
|
+
availableKeys.add(intoKey);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const intoKey = typeof step.into === "string" ? step.into : undefined;
|
|
591
|
+
if (!intoKey) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
availableKeys.add(intoKey);
|
|
595
|
+
const toolId = typeof step.tool === "string" ? step.tool : undefined;
|
|
596
|
+
if (!toolId) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
for (const key of listToolOutputContextKeys(toolId, intoKey)) {
|
|
600
|
+
availableKeys.add(key);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Format/normalize a recipe file.
|
|
605
|
+
* - Normalizes YAML formatting
|
|
606
|
+
* - Sorts keys in consistent order
|
|
607
|
+
* - Validates and re-serializes
|
|
608
|
+
*/
|
|
609
|
+
export function runFmt(recipePath, options = {}) {
|
|
610
|
+
const content = readFileSync(recipePath, "utf-8");
|
|
611
|
+
const { header: schemaHeader } = extractSchemaHeader(content);
|
|
612
|
+
const recipe = normalizeRecipeForRuntime(parseYaml(content));
|
|
613
|
+
// Normalize key order
|
|
614
|
+
const normalized = {};
|
|
615
|
+
const keyOrder = [
|
|
616
|
+
"apiVersion",
|
|
617
|
+
"version",
|
|
618
|
+
"name",
|
|
619
|
+
"description",
|
|
620
|
+
"trigger",
|
|
621
|
+
"context",
|
|
622
|
+
"steps",
|
|
623
|
+
"expect",
|
|
624
|
+
"output",
|
|
625
|
+
"on_error",
|
|
626
|
+
];
|
|
627
|
+
for (const key of keyOrder) {
|
|
628
|
+
if (key in recipe) {
|
|
629
|
+
normalized[key] = recipe[key];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Add any extra keys at the end
|
|
633
|
+
for (const key of Object.keys(recipe)) {
|
|
634
|
+
if (!keyOrder.includes(key)) {
|
|
635
|
+
normalized[key] = recipe[key];
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Re-serialize with consistent formatting
|
|
639
|
+
const formattedBody = stringifyYaml(normalized, {
|
|
640
|
+
indent: 2,
|
|
641
|
+
lineWidth: 100,
|
|
642
|
+
});
|
|
643
|
+
const formatted = schemaHeader
|
|
644
|
+
? `${schemaHeader}\n${formattedBody}`
|
|
645
|
+
: formattedBody;
|
|
646
|
+
const changed = formatted.trim() !== content.trim();
|
|
647
|
+
if (!options.check) {
|
|
648
|
+
writeFileSync(recipePath, formatted);
|
|
649
|
+
}
|
|
650
|
+
return { formatted, changed };
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Watch a recipe file and re-run `runFmt` on every save (debounced).
|
|
654
|
+
* Mirrors runPreflightWatch / runTestWatch — composes runWatch + runFmt.
|
|
655
|
+
* Returns a stop function.
|
|
656
|
+
*/
|
|
657
|
+
export function runFmtWatch(options) {
|
|
658
|
+
const { recipePath, check, onResult, onError, debounceMs, watchFactory } = options;
|
|
659
|
+
return runWatch({
|
|
660
|
+
recipePath,
|
|
661
|
+
onChange: async () => {
|
|
662
|
+
const result = runFmt(recipePath, { check });
|
|
663
|
+
await onResult(result);
|
|
664
|
+
},
|
|
665
|
+
...(onError ? { onError } : {}),
|
|
666
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
667
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
function extractSchemaHeader(content) {
|
|
671
|
+
if (content.startsWith(`${RECIPE_SCHEMA_HEADER}\n`)) {
|
|
672
|
+
return { header: RECIPE_SCHEMA_HEADER };
|
|
673
|
+
}
|
|
674
|
+
return {};
|
|
675
|
+
}
|
|
676
|
+
export async function runRecipe(recipeRef, options = {}) {
|
|
677
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
678
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
679
|
+
const triggerType = recipe.trigger?.type;
|
|
680
|
+
if (options.step && triggerType === "chained") {
|
|
681
|
+
throw new Error(`Single-step execution is not supported for chained recipes: ${recipe.name}`);
|
|
682
|
+
}
|
|
683
|
+
const selection = options.step
|
|
684
|
+
? selectRecipeStep(recipe, options.step)
|
|
685
|
+
: undefined;
|
|
686
|
+
const recipeToRun = selection
|
|
687
|
+
? { ...recipe, steps: [selection.step] }
|
|
688
|
+
: recipe;
|
|
689
|
+
const runnerDeps = {
|
|
690
|
+
...options.deps,
|
|
691
|
+
workdir: options.workdir ?? options.deps?.workdir ?? process.cwd(),
|
|
692
|
+
};
|
|
693
|
+
if (options.dryRun) {
|
|
694
|
+
throw new Error("runRecipeDryPlan must be used for dry-run execution");
|
|
695
|
+
}
|
|
696
|
+
const result = await dispatchRecipe(recipeToRun, {
|
|
697
|
+
...runnerDeps,
|
|
698
|
+
chainedDeps: buildChainedDeps(runnerDeps),
|
|
699
|
+
}, options.vars ?? {});
|
|
700
|
+
return {
|
|
701
|
+
recipe,
|
|
702
|
+
recipePath,
|
|
703
|
+
result,
|
|
704
|
+
...(selection
|
|
705
|
+
? {
|
|
706
|
+
stepSelection: {
|
|
707
|
+
query: selection.query,
|
|
708
|
+
matchedBy: selection.matchedBy,
|
|
709
|
+
matchedValue: selection.matchedValue,
|
|
710
|
+
},
|
|
711
|
+
}
|
|
712
|
+
: {}),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
export function summarizeRecipeExecution(result) {
|
|
716
|
+
if ("stepsRun" in result) {
|
|
717
|
+
return {
|
|
718
|
+
ok: !result.errorMessage,
|
|
719
|
+
steps: result.stepsRun,
|
|
720
|
+
outputs: result.outputs,
|
|
721
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
ok: result.success,
|
|
726
|
+
steps: result.summary.total,
|
|
727
|
+
outputs: [],
|
|
728
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
729
|
+
failed: result.summary.failed,
|
|
730
|
+
skipped: result.summary.skipped,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
export async function runWatchedRecipe(recipePath, options = {}) {
|
|
734
|
+
const lint = runLint(recipePath);
|
|
735
|
+
if (!lint.valid) {
|
|
736
|
+
return { lint };
|
|
737
|
+
}
|
|
738
|
+
const run = await runRecipe(recipePath, options);
|
|
739
|
+
return {
|
|
740
|
+
lint,
|
|
741
|
+
run,
|
|
742
|
+
summary: summarizeRecipeExecution(run.result),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Stable JSON schema version for machine-readable dry-run plans.
|
|
747
|
+
* Bump on breaking shape changes; consumers (dashboard run timeline, external tools)
|
|
748
|
+
* should gate on this field.
|
|
749
|
+
*/
|
|
750
|
+
export const DRY_RUN_PLAN_SCHEMA_VERSION = 1;
|
|
751
|
+
function enrichStepFromRegistry(step) {
|
|
752
|
+
if (step.type !== "tool" || !step.tool) {
|
|
753
|
+
return step;
|
|
754
|
+
}
|
|
755
|
+
const namespace = step.tool.split(".")[0];
|
|
756
|
+
const registered = getTool(step.tool);
|
|
757
|
+
const enriched = { ...step };
|
|
758
|
+
if (namespace)
|
|
759
|
+
enriched.namespace = namespace;
|
|
760
|
+
enriched.resolved = Boolean(registered);
|
|
761
|
+
if (registered) {
|
|
762
|
+
enriched.isWrite = registered.isWrite;
|
|
763
|
+
enriched.isConnector = registered.isConnector === true;
|
|
764
|
+
if (enriched.risk === undefined) {
|
|
765
|
+
enriched.risk = registered.riskDefault;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return enriched;
|
|
769
|
+
}
|
|
770
|
+
function summarizePlanSteps(steps) {
|
|
771
|
+
const connectors = new Set();
|
|
772
|
+
let hasWrite = false;
|
|
773
|
+
for (const step of steps) {
|
|
774
|
+
if (step.isConnector && step.namespace)
|
|
775
|
+
connectors.add(step.namespace);
|
|
776
|
+
if (step.isWrite)
|
|
777
|
+
hasWrite = true;
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
connectorNamespaces: [...connectors].sort(),
|
|
781
|
+
hasWriteSteps: hasWrite,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
export async function runRecipeDryPlan(recipeRef, options = {}) {
|
|
785
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
786
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
787
|
+
const triggerType = recipe.trigger?.type;
|
|
788
|
+
const selection = options.step
|
|
789
|
+
? selectRecipeStep(recipe, options.step)
|
|
790
|
+
: undefined;
|
|
791
|
+
const recipeToPlan = selection
|
|
792
|
+
? { ...recipe, steps: [selection.step] }
|
|
793
|
+
: recipe;
|
|
794
|
+
const generatedAt = new Date().toISOString();
|
|
795
|
+
if (triggerType === "chained") {
|
|
796
|
+
const { generateExecutionPlan } = await import("../recipes/chainedRunner.js");
|
|
797
|
+
const plan = generateExecutionPlan(recipeToPlan);
|
|
798
|
+
const steps = plan.steps.map((step) => {
|
|
799
|
+
const base = { id: step.id, type: step.type };
|
|
800
|
+
if (step.optional !== undefined)
|
|
801
|
+
base.optional = step.optional;
|
|
802
|
+
if (step.dependencies)
|
|
803
|
+
base.dependencies = step.dependencies;
|
|
804
|
+
if (step.condition !== undefined)
|
|
805
|
+
base.condition = step.condition;
|
|
806
|
+
if (step.risk !== undefined)
|
|
807
|
+
base.risk = step.risk;
|
|
808
|
+
const raw = step;
|
|
809
|
+
if (typeof raw.tool === "string")
|
|
810
|
+
base.tool = raw.tool;
|
|
811
|
+
if (typeof raw.into === "string")
|
|
812
|
+
base.into = raw.into;
|
|
813
|
+
return enrichStepFromRegistry(base);
|
|
814
|
+
});
|
|
815
|
+
return {
|
|
816
|
+
schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
|
|
817
|
+
generatedAt,
|
|
818
|
+
recipe: recipe.name,
|
|
819
|
+
mode: "dry-run",
|
|
820
|
+
triggerType,
|
|
821
|
+
...(selection ? { stepSelection: toStepSelection(selection) } : {}),
|
|
822
|
+
steps,
|
|
823
|
+
parallelGroups: plan.parallelGroups,
|
|
824
|
+
maxDepth: plan.maxDepth,
|
|
825
|
+
...summarizePlanSteps(steps),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const steps = buildSimpleRecipeDryRunSteps(recipeToPlan, options.vars ?? {}).map(enrichStepFromRegistry);
|
|
829
|
+
return {
|
|
830
|
+
schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
|
|
831
|
+
generatedAt,
|
|
832
|
+
recipe: recipe.name,
|
|
833
|
+
mode: "dry-run",
|
|
834
|
+
triggerType: typeof triggerType === "string" ? triggerType : "manual",
|
|
835
|
+
...(selection ? { stepSelection: toStepSelection(selection) } : {}),
|
|
836
|
+
steps,
|
|
837
|
+
...summarizePlanSteps(steps),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Static policy check over a recipe: lint + dry-plan + unresolved/write/fixture checks.
|
|
842
|
+
* No connector calls, no agent calls — safe to run in CI.
|
|
843
|
+
*/
|
|
844
|
+
export async function runPreflight(recipeRef, options = {}) {
|
|
845
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
846
|
+
const issues = [];
|
|
847
|
+
const lint = runLint(recipePath);
|
|
848
|
+
for (const issue of lint.issues) {
|
|
849
|
+
issues.push({
|
|
850
|
+
level: issue.level,
|
|
851
|
+
code: issue.level === "error" ? "lint-error" : "lint-warning",
|
|
852
|
+
message: issue.message,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
const plan = await runRecipeDryPlan(recipeRef, options);
|
|
856
|
+
const requireWriteAck = options.requireWriteAck ?? true;
|
|
857
|
+
const allowlist = new Set(options.allowWrites ?? []);
|
|
858
|
+
for (const step of plan.steps) {
|
|
859
|
+
if (step.type === "tool" && step.tool && step.resolved === false) {
|
|
860
|
+
issues.push({
|
|
861
|
+
level: "error",
|
|
862
|
+
code: "unresolved-tool",
|
|
863
|
+
message: `Tool "${step.tool}" is not registered`,
|
|
864
|
+
stepId: step.id,
|
|
865
|
+
tool: step.tool,
|
|
866
|
+
...(step.namespace ? { namespace: step.namespace } : {}),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (requireWriteAck &&
|
|
870
|
+
step.isWrite === true &&
|
|
871
|
+
step.tool &&
|
|
872
|
+
!allowlist.has(step.tool) &&
|
|
873
|
+
!(step.namespace && allowlist.has(step.namespace))) {
|
|
874
|
+
issues.push({
|
|
875
|
+
level: "error",
|
|
876
|
+
code: "unacknowledged-write",
|
|
877
|
+
message: `Step "${step.id}" performs a write via "${step.tool}" but is not acknowledged via allowWrites`,
|
|
878
|
+
stepId: step.id,
|
|
879
|
+
tool: step.tool,
|
|
880
|
+
...(step.namespace ? { namespace: step.namespace } : {}),
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (options.requireFixtures && plan.connectorNamespaces) {
|
|
885
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
886
|
+
for (const ns of plan.connectorNamespaces) {
|
|
887
|
+
const library = loadFixtureLibrary(join(fixturesDir, `${ns}.json`));
|
|
888
|
+
if (!library) {
|
|
889
|
+
issues.push({
|
|
890
|
+
level: "error",
|
|
891
|
+
code: "missing-fixture",
|
|
892
|
+
message: `Missing fixture library for connector "${ns}" at ${fixturesDir}/${ns}.json`,
|
|
893
|
+
namespace: ns,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const ok = !issues.some((issue) => issue.level === "error");
|
|
899
|
+
return { ok, recipe: plan.recipe, issues, plan };
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Watch a recipe file and run preflight on every save (debounced). Composes
|
|
903
|
+
* runWatch + runPreflight so editor integrations get live policy feedback
|
|
904
|
+
* without spawning the CLI per keystroke.
|
|
905
|
+
*
|
|
906
|
+
* Returns a stop function. If a preflight is in-flight when a new save lands,
|
|
907
|
+
* at most one rerun is queued (matches runWatch semantics).
|
|
908
|
+
*/
|
|
909
|
+
export function runPreflightWatch(options) {
|
|
910
|
+
const { recipePath, onResult, onError, debounceMs, watchFactory, ...preflightOptions } = options;
|
|
911
|
+
const watchOptions = {
|
|
912
|
+
recipePath,
|
|
913
|
+
onChange: async () => {
|
|
914
|
+
const result = await runPreflight(recipePath, preflightOptions);
|
|
915
|
+
await onResult(result);
|
|
916
|
+
},
|
|
917
|
+
...(onError ? { onError } : {}),
|
|
918
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
919
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
920
|
+
};
|
|
921
|
+
return runWatch(watchOptions);
|
|
922
|
+
}
|
|
923
|
+
function resolveRecipePath(recipeRef) {
|
|
924
|
+
const directPath = resolve(recipeRef);
|
|
925
|
+
if (existsSync(directPath) && statSync(directPath).isFile()) {
|
|
926
|
+
return directPath;
|
|
927
|
+
}
|
|
928
|
+
const bundledDir = fileURLToPath(new URL("../../templates/recipes", import.meta.url));
|
|
929
|
+
const normalizedRef = recipeRef.replace(/\.(yaml|yml|json)$/i, "");
|
|
930
|
+
const candidates = [
|
|
931
|
+
join(RECIPES_DIR, `${normalizedRef}.yaml`),
|
|
932
|
+
join(RECIPES_DIR, `${normalizedRef}.yml`),
|
|
933
|
+
join(RECIPES_DIR, `${normalizedRef}.json`),
|
|
934
|
+
join(bundledDir, `${normalizedRef}.yaml`),
|
|
935
|
+
join(bundledDir, `${normalizedRef}.yml`),
|
|
936
|
+
];
|
|
937
|
+
for (const candidate of candidates) {
|
|
938
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
939
|
+
return candidate;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
throw new Error(`recipe "${basename(recipeRef)}" not found in ${RECIPES_DIR}`);
|
|
943
|
+
}
|
|
944
|
+
function selectRecipeStep(recipe, query) {
|
|
945
|
+
const matches = recipe.steps
|
|
946
|
+
.map((step) => {
|
|
947
|
+
const match = matchRecipeStep(step, query);
|
|
948
|
+
return match ? { ...match, query, step } : undefined;
|
|
949
|
+
})
|
|
950
|
+
.filter((match) => Boolean(match));
|
|
951
|
+
if (matches.length === 0) {
|
|
952
|
+
throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
|
|
953
|
+
}
|
|
954
|
+
if (matches.length > 1) {
|
|
955
|
+
const labels = matches
|
|
956
|
+
.map((match) => `${match.matchedBy}:${match.matchedValue}`)
|
|
957
|
+
.join(", ");
|
|
958
|
+
throw new Error(`Step "${query}" is ambiguous in recipe "${recipe.name}": ${labels}`);
|
|
959
|
+
}
|
|
960
|
+
const [match] = matches;
|
|
961
|
+
if (!match) {
|
|
962
|
+
throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
|
|
963
|
+
}
|
|
964
|
+
return match;
|
|
965
|
+
}
|
|
966
|
+
function matchRecipeStep(step, query) {
|
|
967
|
+
const id = typeof step.id === "string" ? step.id : undefined;
|
|
968
|
+
if (id === query) {
|
|
969
|
+
return { matchedBy: "id", matchedValue: id };
|
|
970
|
+
}
|
|
971
|
+
const into = getStepInto(step);
|
|
972
|
+
if (into === query) {
|
|
973
|
+
return { matchedBy: "into", matchedValue: into };
|
|
974
|
+
}
|
|
975
|
+
const tool = typeof step.tool === "string" ? step.tool : undefined;
|
|
976
|
+
if (tool === query) {
|
|
977
|
+
return { matchedBy: "tool", matchedValue: tool };
|
|
978
|
+
}
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
function getStepInto(step) {
|
|
982
|
+
if (typeof step.into === "string" && step.into) {
|
|
983
|
+
return step.into;
|
|
984
|
+
}
|
|
985
|
+
if (step.agent &&
|
|
986
|
+
typeof step.agent === "object" &&
|
|
987
|
+
typeof step.agent.into === "string" &&
|
|
988
|
+
step.agent.into) {
|
|
989
|
+
return step.agent.into;
|
|
990
|
+
}
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
function toStepSelection(selection) {
|
|
994
|
+
return {
|
|
995
|
+
query: selection.query,
|
|
996
|
+
matchedBy: selection.matchedBy,
|
|
997
|
+
matchedValue: selection.matchedValue,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
function buildSimpleRecipeDryRunSteps(recipe, vars) {
|
|
1001
|
+
const now = new Date();
|
|
1002
|
+
const ctx = {
|
|
1003
|
+
date: now.toISOString().slice(0, 10),
|
|
1004
|
+
time: now.toTimeString().slice(0, 5),
|
|
1005
|
+
...vars,
|
|
1006
|
+
};
|
|
1007
|
+
return recipe.steps.map((step, index) => {
|
|
1008
|
+
const id = (typeof step.id === "string" && step.id) ||
|
|
1009
|
+
getStepInto(step) ||
|
|
1010
|
+
step.tool ||
|
|
1011
|
+
`step_${index}`;
|
|
1012
|
+
if (step.agent) {
|
|
1013
|
+
const prompt = render(step.agent.prompt, ctx);
|
|
1014
|
+
const into = getStepInto(step);
|
|
1015
|
+
if (into) {
|
|
1016
|
+
ctx[into] = `[dry-run:${id}]`;
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
id,
|
|
1020
|
+
type: "agent",
|
|
1021
|
+
into,
|
|
1022
|
+
optional: step.optional,
|
|
1023
|
+
prompt,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const params = {};
|
|
1027
|
+
for (const [key, value] of Object.entries(step)) {
|
|
1028
|
+
if (key === "tool" || key === "agent" || key === "into" || key === "id") {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
params[key] = typeof value === "string" ? render(value, ctx) : value;
|
|
1032
|
+
}
|
|
1033
|
+
const into = getStepInto(step);
|
|
1034
|
+
if (into) {
|
|
1035
|
+
ctx[into] = `[dry-run:${id}]`;
|
|
1036
|
+
if (step.tool) {
|
|
1037
|
+
seedToolOutputPreviewContext(step.tool, into, id, ctx);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
id,
|
|
1042
|
+
type: "tool",
|
|
1043
|
+
tool: step.tool,
|
|
1044
|
+
into,
|
|
1045
|
+
optional: step.optional,
|
|
1046
|
+
params,
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
export async function runRecord(recipePath, options = {}) {
|
|
1051
|
+
const lint = runLint(recipePath);
|
|
1052
|
+
const issues = [...lint.issues];
|
|
1053
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
1054
|
+
let recordedFixtures = [];
|
|
1055
|
+
let stepsRun = 0;
|
|
1056
|
+
let outputs = [];
|
|
1057
|
+
if (issues.every((issue) => issue.level !== "error")) {
|
|
1058
|
+
try {
|
|
1059
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
1060
|
+
recordedFixtures = getRequiredFixtureNamespaces(recipe.steps);
|
|
1061
|
+
const run = await runYamlRecipe(recipe, {
|
|
1062
|
+
...options.deps,
|
|
1063
|
+
recordFixturesDir: fixturesDir,
|
|
1064
|
+
});
|
|
1065
|
+
stepsRun = run.stepsRun;
|
|
1066
|
+
outputs = run.outputs;
|
|
1067
|
+
if (run.errorMessage) {
|
|
1068
|
+
issues.push({
|
|
1069
|
+
level: "error",
|
|
1070
|
+
message: run.errorMessage,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
issues.push({
|
|
1076
|
+
level: "error",
|
|
1077
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const errors = issues.filter((issue) => issue.level === "error").length;
|
|
1082
|
+
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
1083
|
+
return {
|
|
1084
|
+
valid: errors === 0,
|
|
1085
|
+
issues,
|
|
1086
|
+
warnings,
|
|
1087
|
+
errors,
|
|
1088
|
+
recordedFixtures,
|
|
1089
|
+
stepsRun,
|
|
1090
|
+
outputs,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
export async function runTest(recipePath, options = {}) {
|
|
1094
|
+
const lint = runLint(recipePath);
|
|
1095
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
1096
|
+
const issues = [...lint.issues];
|
|
1097
|
+
let requiredFixtures = [];
|
|
1098
|
+
let stepsRun = 0;
|
|
1099
|
+
let outputs = [];
|
|
1100
|
+
let assertionFailures = [];
|
|
1101
|
+
if (existsSync(recipePath)) {
|
|
1102
|
+
try {
|
|
1103
|
+
const recipe = parseYaml(readFileSync(recipePath, "utf-8"));
|
|
1104
|
+
requiredFixtures = getRequiredFixtureNamespaces(recipe.steps ?? []);
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
requiredFixtures = [];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const missingFixtures = requiredFixtures.filter((provider) => !existsSync(join(fixturesDir, `${provider}.json`)));
|
|
1111
|
+
for (const provider of missingFixtures) {
|
|
1112
|
+
issues.push({
|
|
1113
|
+
level: "error",
|
|
1114
|
+
message: `Missing fixture library for connector '${provider}' at ${join(fixturesDir, `${provider}.json`)}`,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
if (issues.every((issue) => issue.level !== "error")) {
|
|
1118
|
+
try {
|
|
1119
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
1120
|
+
const mockConnectors = createMockToolConnectors(recipe.steps, fixturesDir);
|
|
1121
|
+
const run = await runYamlRecipe(recipe, {
|
|
1122
|
+
testMode: true,
|
|
1123
|
+
mockConnectors,
|
|
1124
|
+
readFile: (filePath) => readFileSync(filePath, "utf-8"),
|
|
1125
|
+
writeFile: () => { },
|
|
1126
|
+
appendFile: () => { },
|
|
1127
|
+
mkdir: () => { },
|
|
1128
|
+
gitLogSince: () => "[mock git log]",
|
|
1129
|
+
gitStaleBranches: () => "[mock stale branches]",
|
|
1130
|
+
getDiagnostics: () => "[mock diagnostics]",
|
|
1131
|
+
claudeFn: async () => "[mock agent output]",
|
|
1132
|
+
claudeCodeFn: async () => "[mock agent output]",
|
|
1133
|
+
providerDriverFn: async () => "[mock agent output]",
|
|
1134
|
+
});
|
|
1135
|
+
stepsRun = run.stepsRun;
|
|
1136
|
+
outputs = run.outputs;
|
|
1137
|
+
if (run.assertionFailures && run.assertionFailures.length > 0) {
|
|
1138
|
+
assertionFailures = run.assertionFailures;
|
|
1139
|
+
for (const failure of run.assertionFailures) {
|
|
1140
|
+
issues.push({ level: "error", message: failure.message });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (run.errorMessage) {
|
|
1144
|
+
issues.push({
|
|
1145
|
+
level: "error",
|
|
1146
|
+
message: run.errorMessage,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
issues.push({
|
|
1152
|
+
level: "error",
|
|
1153
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
const errors = issues.filter((issue) => issue.level === "error").length;
|
|
1158
|
+
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
1159
|
+
return {
|
|
1160
|
+
valid: errors === 0,
|
|
1161
|
+
issues,
|
|
1162
|
+
warnings,
|
|
1163
|
+
errors,
|
|
1164
|
+
requiredFixtures,
|
|
1165
|
+
missingFixtures,
|
|
1166
|
+
stepsRun,
|
|
1167
|
+
outputs,
|
|
1168
|
+
assertionFailures,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Watch a recipe file and re-run `patchwork recipe test` on every save (debounced).
|
|
1173
|
+
* Mirrors runPreflightWatch — composes runWatch + runTest.
|
|
1174
|
+
* Returns a stop function.
|
|
1175
|
+
*/
|
|
1176
|
+
export function runTestWatch(options) {
|
|
1177
|
+
const { recipePath, fixturesDir, onResult, onError, debounceMs, watchFactory, } = options;
|
|
1178
|
+
return runWatch({
|
|
1179
|
+
recipePath,
|
|
1180
|
+
onChange: async () => {
|
|
1181
|
+
const result = await runTest(recipePath, { fixturesDir });
|
|
1182
|
+
await onResult(result);
|
|
1183
|
+
},
|
|
1184
|
+
...(onError ? { onError } : {}),
|
|
1185
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
1186
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
function getRequiredFixtureNamespaces(steps) {
|
|
1190
|
+
const namespaces = new Set();
|
|
1191
|
+
for (const step of steps) {
|
|
1192
|
+
const tool = step.tool;
|
|
1193
|
+
if (typeof tool !== "string") {
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
const namespace = tool.split(".")[0];
|
|
1197
|
+
if (namespace && isConnectorNamespace(namespace)) {
|
|
1198
|
+
namespaces.add(namespace);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return [...namespaces].sort();
|
|
1202
|
+
}
|
|
1203
|
+
function createMockToolConnectors(steps, fixturesDir) {
|
|
1204
|
+
const providerConnectors = new Map();
|
|
1205
|
+
const toolConnectors = {};
|
|
1206
|
+
for (const step of steps) {
|
|
1207
|
+
const tool = step.tool;
|
|
1208
|
+
if (typeof tool !== "string") {
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const [namespace, operation] = tool.split(".");
|
|
1212
|
+
if (!namespace || !operation || !isConnectorNamespace(namespace)) {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
let connector = providerConnectors.get(namespace);
|
|
1216
|
+
if (!connector) {
|
|
1217
|
+
connector = new MockConnector(namespace, {
|
|
1218
|
+
fixturePath: join(fixturesDir, `${namespace}.json`),
|
|
1219
|
+
});
|
|
1220
|
+
providerConnectors.set(namespace, connector);
|
|
1221
|
+
}
|
|
1222
|
+
toolConnectors[tool] = {
|
|
1223
|
+
invoke: async (_unusedOperation, input) => {
|
|
1224
|
+
const output = await connector.invoke(operation, input);
|
|
1225
|
+
return (typeof output === "string" ? output : JSON.stringify(output));
|
|
1226
|
+
},
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
return toolConnectors;
|
|
1230
|
+
}
|
|
1231
|
+
function normalizeChangedFile(changedFile) {
|
|
1232
|
+
if (typeof changedFile === "string") {
|
|
1233
|
+
return changedFile;
|
|
1234
|
+
}
|
|
1235
|
+
if (changedFile instanceof Buffer) {
|
|
1236
|
+
return changedFile.toString();
|
|
1237
|
+
}
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
export function runWatch(options) {
|
|
1241
|
+
const dir = dirname(resolve(options.recipePath));
|
|
1242
|
+
const filename = basename(options.recipePath);
|
|
1243
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
1244
|
+
const watchFactory = options.watchFactory ??
|
|
1245
|
+
((watchPath, watchOptions, listener) => watch(watchPath, watchOptions, listener));
|
|
1246
|
+
let debounceTimer = null;
|
|
1247
|
+
let running = false;
|
|
1248
|
+
let rerunQueued = false;
|
|
1249
|
+
let stopped = false;
|
|
1250
|
+
const handleError = (err) => {
|
|
1251
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
1252
|
+
};
|
|
1253
|
+
const finishChange = () => {
|
|
1254
|
+
running = false;
|
|
1255
|
+
if (stopped || !rerunQueued) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
rerunQueued = false;
|
|
1259
|
+
executeChange();
|
|
1260
|
+
};
|
|
1261
|
+
const executeChange = () => {
|
|
1262
|
+
if (stopped) {
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (running) {
|
|
1266
|
+
rerunQueued = true;
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
running = true;
|
|
1270
|
+
try {
|
|
1271
|
+
const changeResult = options.onChange();
|
|
1272
|
+
void Promise.resolve(changeResult)
|
|
1273
|
+
.catch(handleError)
|
|
1274
|
+
.finally(finishChange);
|
|
1275
|
+
}
|
|
1276
|
+
catch (err) {
|
|
1277
|
+
handleError(err);
|
|
1278
|
+
finishChange();
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
const scheduleChange = () => {
|
|
1282
|
+
if (stopped) {
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (running) {
|
|
1286
|
+
rerunQueued = true;
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (debounceTimer) {
|
|
1290
|
+
clearTimeout(debounceTimer);
|
|
1291
|
+
}
|
|
1292
|
+
debounceTimer = setTimeout(() => {
|
|
1293
|
+
debounceTimer = null;
|
|
1294
|
+
executeChange();
|
|
1295
|
+
}, debounceMs);
|
|
1296
|
+
};
|
|
1297
|
+
const watcher = watchFactory(dir, { recursive: false }, (_eventType, changedFile) => {
|
|
1298
|
+
const changedName = normalizeChangedFile(changedFile);
|
|
1299
|
+
if (changedName === filename) {
|
|
1300
|
+
scheduleChange();
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
// Return cleanup function
|
|
1304
|
+
return () => {
|
|
1305
|
+
stopped = true;
|
|
1306
|
+
if (debounceTimer) {
|
|
1307
|
+
clearTimeout(debounceTimer);
|
|
1308
|
+
debounceTimer = null;
|
|
1309
|
+
}
|
|
1310
|
+
watcher.close();
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
//# sourceMappingURL=recipe.js.map
|