patchwork-os 0.2.0-alpha.3 → 0.2.0-alpha.30
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/deploy/deploy-dashboard.sh +174 -0
- package/deploy/deploy-landing.sh +79 -0
- package/dist/activationMetrics.d.ts +67 -0
- package/dist/activationMetrics.js +255 -0
- package/dist/activationMetrics.js.map +1 -0
- package/dist/approvalHttp.d.ts +24 -2
- package/dist/approvalHttp.js +150 -10
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +16 -1
- package/dist/approvalQueue.js +44 -3
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.d.ts +20 -0
- package/dist/automation.js +54 -1
- package/dist/automation.js.map +1 -1
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.js +225 -35
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeToken.js +57 -19
- package/dist/bridgeToken.js.map +1 -1
- package/dist/ccPermissions.js +6 -4
- package/dist/ccPermissions.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 +258 -0
- package/dist/commands/recipe.js +1130 -0
- package/dist/commands/recipe.js.map +1 -0
- package/dist/commands/recipeInstall.d.ts +72 -0
- package/dist/commands/recipeInstall.js +339 -0
- package/dist/commands/recipeInstall.js.map +1 -0
- package/dist/config.d.ts +14 -1
- package/dist/config.js +99 -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/datadog.d.ts +116 -0
- package/dist/connectors/datadog.js +385 -0
- package/dist/connectors/datadog.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 +79 -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/hubspot.d.ts +112 -0
- package/dist/connectors/hubspot.js +408 -0
- package/dist/connectors/hubspot.js.map +1 -0
- package/dist/connectors/intercom.d.ts +102 -0
- package/dist/connectors/intercom.js +402 -0
- package/dist/connectors/intercom.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 +69 -19
- package/dist/connectors/linear.js +170 -129
- package/dist/connectors/linear.js.map +1 -1
- 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 +17 -21
- package/dist/connectors/sentry.js +115 -131
- package/dist/connectors/sentry.js.map +1 -1
- 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/stripe.d.ts +116 -0
- package/dist/connectors/stripe.js +379 -0
- package/dist/connectors/stripe.js.map +1 -0
- package/dist/connectors/tokenStorage.d.ts +35 -0
- package/dist/connectors/tokenStorage.js +459 -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/gemini/index.d.ts +5 -1
- package/dist/drivers/gemini/index.js +39 -5
- package/dist/drivers/gemini/index.js.map +1 -1
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +1 -1
- package/dist/drivers/index.js.map +1 -1
- 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 +621 -61
- 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/RecipeOrchestrator.d.ts +40 -0
- package/dist/recipes/RecipeOrchestrator.js +51 -0
- package/dist/recipes/RecipeOrchestrator.js.map +1 -0
- package/dist/recipes/agentExecutor.d.ts +28 -0
- package/dist/recipes/agentExecutor.js +42 -0
- package/dist/recipes/agentExecutor.js.map +1 -0
- package/dist/recipes/chainedRunner.d.ts +140 -0
- package/dist/recipes/chainedRunner.js +539 -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 +2 -0
- package/dist/recipes/legacyRecipeCompat.js +112 -0
- package/dist/recipes/legacyRecipeCompat.js.map +1 -0
- package/dist/recipes/manifest.d.ts +47 -0
- package/dist/recipes/manifest.js +141 -0
- package/dist/recipes/manifest.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 +131 -41
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +17 -2
- package/dist/recipes/schemaGenerator.d.ts +28 -0
- package/dist/recipes/schemaGenerator.js +565 -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/datadog.d.ts +6 -0
- package/dist/recipes/tools/datadog.js +239 -0
- package/dist/recipes/tools/datadog.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/hubspot.d.ts +6 -0
- package/dist/recipes/tools/hubspot.js +232 -0
- package/dist/recipes/tools/hubspot.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +22 -0
- package/dist/recipes/tools/index.js +25 -0
- package/dist/recipes/tools/index.js.map +1 -0
- package/dist/recipes/tools/intercom.d.ts +6 -0
- package/dist/recipes/tools/intercom.js +226 -0
- package/dist/recipes/tools/intercom.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/stripe.d.ts +6 -0
- package/dist/recipes/tools/stripe.js +265 -0
- package/dist/recipes/tools/stripe.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/validation.d.ts +13 -0
- package/dist/recipes/validation.js +433 -0
- package/dist/recipes/validation.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +87 -0
- package/dist/recipes/yamlRunner.js +693 -409
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +34 -6
- package/dist/recipesHttp.js +285 -15
- 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/schemas/dry-run-plan.v1.json +139 -0
- package/dist/schemas/recipe.v1.json +684 -0
- package/dist/server.d.ts +32 -1
- package/dist/server.js +980 -97
- 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/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/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 +32 -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/testTraceToSource.js +2 -2
- package/dist/tools/testTraceToSource.js.map +1 -1
- 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 +5 -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 +14 -6
- package/templates/recipes/project-health-check.yaml +50 -0
- package/templates/recipes/sentry-to-linear.yaml +77 -0
|
@@ -0,0 +1,1130 @@
|
|
|
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 { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
11
|
+
import "../recipes/tools/index.js";
|
|
12
|
+
import { loadFixtureLibrary } from "../connectors/fixtureLibrary.js";
|
|
13
|
+
import { MockConnector } from "../connectors/mockConnector.js";
|
|
14
|
+
import { normalizeRecipeForRuntime } from "../recipes/legacyRecipeCompat.js";
|
|
15
|
+
import { generateSchemaSet, writeSchemas } from "../recipes/schemaGenerator.js";
|
|
16
|
+
import { getTool, isConnectorNamespace, seedToolOutputPreviewContext, } from "../recipes/toolRegistry.js";
|
|
17
|
+
import { validateRecipeDefinition, } from "../recipes/validation.js";
|
|
18
|
+
import { buildChainedDeps, dispatchRecipe, loadYamlRecipe, render, runYamlRecipe, } from "../recipes/yamlRunner.js";
|
|
19
|
+
import { findYamlRecipePath } from "../recipesHttp.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://patchworkos.com/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
|
+
// Check file exists
|
|
137
|
+
if (!existsSync(recipePath)) {
|
|
138
|
+
return {
|
|
139
|
+
valid: false,
|
|
140
|
+
issues: [{ level: "error", message: `File not found: ${recipePath}` }],
|
|
141
|
+
warnings: 0,
|
|
142
|
+
errors: 1,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let content;
|
|
146
|
+
try {
|
|
147
|
+
content = readFileSync(recipePath, "utf-8");
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return {
|
|
151
|
+
valid: false,
|
|
152
|
+
issues: [
|
|
153
|
+
{
|
|
154
|
+
level: "error",
|
|
155
|
+
message: `Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
warnings: 0,
|
|
159
|
+
errors: 1,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = parseYaml(content);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return {
|
|
168
|
+
valid: false,
|
|
169
|
+
issues: [
|
|
170
|
+
{
|
|
171
|
+
level: "error",
|
|
172
|
+
message: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
warnings: 0,
|
|
176
|
+
errors: 1,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const result = validateRecipeDefinition(parsed);
|
|
180
|
+
// For chained recipes, check that chain: file references resolve on disk.
|
|
181
|
+
const chainIssues = lintChainRefs(parsed, recipePath);
|
|
182
|
+
if (chainIssues.length > 0) {
|
|
183
|
+
result.issues.push(...chainIssues);
|
|
184
|
+
result.errors += chainIssues.filter((i) => i.level === "error").length;
|
|
185
|
+
result.warnings += chainIssues.filter((i) => i.level === "warning").length;
|
|
186
|
+
if (result.errors > 0) {
|
|
187
|
+
result.valid = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Walk chained recipe steps, check that chain:/recipe: refs resolve on disk,
|
|
194
|
+
* and recursively lint any child recipe that does resolve.
|
|
195
|
+
*
|
|
196
|
+
* `visited` tracks absolute paths already linted in this call chain to prevent
|
|
197
|
+
* infinite recursion when two recipes chain each other.
|
|
198
|
+
*/
|
|
199
|
+
function lintChainRefs(parsed, recipePath, visited = new Set()) {
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
201
|
+
return [];
|
|
202
|
+
const r = parsed;
|
|
203
|
+
const trigger = r.trigger && typeof r.trigger === "object"
|
|
204
|
+
? r.trigger
|
|
205
|
+
: undefined;
|
|
206
|
+
if (trigger?.type !== "chained")
|
|
207
|
+
return [];
|
|
208
|
+
const steps = Array.isArray(r.steps)
|
|
209
|
+
? r.steps
|
|
210
|
+
: [];
|
|
211
|
+
const recipeDir = dirname(recipePath);
|
|
212
|
+
const issues = [];
|
|
213
|
+
// Mark the current recipe as visited before descending.
|
|
214
|
+
const absPath = resolve(recipePath);
|
|
215
|
+
visited.add(absPath);
|
|
216
|
+
for (let i = 0; i < steps.length; i++) {
|
|
217
|
+
const step = steps[i];
|
|
218
|
+
if (!step)
|
|
219
|
+
continue;
|
|
220
|
+
issues.push(...lintStep(step, i + 1, recipeDir, visited));
|
|
221
|
+
}
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check a single step (or recurse into its parallel: children).
|
|
226
|
+
* `stepLabel` is the 1-based position string used in issue messages.
|
|
227
|
+
*/
|
|
228
|
+
function lintStep(step, stepLabel, recipeDir, visited) {
|
|
229
|
+
const issues = [];
|
|
230
|
+
// Recurse into parallel: groups — each child is checked independently.
|
|
231
|
+
if (Array.isArray(step.parallel)) {
|
|
232
|
+
for (let j = 0; j < step.parallel.length; j++) {
|
|
233
|
+
const child = step.parallel[j];
|
|
234
|
+
if (!child || typeof child !== "object" || Array.isArray(child))
|
|
235
|
+
continue;
|
|
236
|
+
issues.push(...lintStep(child, stepLabel, recipeDir, visited));
|
|
237
|
+
}
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
const ref = typeof step.chain === "string"
|
|
241
|
+
? step.chain
|
|
242
|
+
: typeof step.recipe === "string"
|
|
243
|
+
? step.recipe
|
|
244
|
+
: null;
|
|
245
|
+
if (!ref)
|
|
246
|
+
return issues;
|
|
247
|
+
const field = typeof step.chain === "string" ? "chain" : "recipe";
|
|
248
|
+
// Refs that look like file paths (extension or separator) → resolve relative to recipe dir.
|
|
249
|
+
const looksLikePath = /\.ya?ml$/i.test(ref) ||
|
|
250
|
+
ref.startsWith("./") ||
|
|
251
|
+
ref.startsWith("../") ||
|
|
252
|
+
/[\\/]/.test(ref);
|
|
253
|
+
if (looksLikePath) {
|
|
254
|
+
const resolved = /^\//.test(ref) ? ref : resolve(recipeDir, ref);
|
|
255
|
+
const candidates = /\.ya?ml$/i.test(resolved)
|
|
256
|
+
? [resolved]
|
|
257
|
+
: [`${resolved}.yaml`, `${resolved}.yml`, resolved];
|
|
258
|
+
const childPath = candidates.find(existsSync) ?? null;
|
|
259
|
+
if (!childPath) {
|
|
260
|
+
issues.push({
|
|
261
|
+
level: "error",
|
|
262
|
+
message: `Step ${stepLabel}: '${field}: ${ref}' — file not found relative to recipe directory (${recipeDir})`,
|
|
263
|
+
});
|
|
264
|
+
return issues;
|
|
265
|
+
}
|
|
266
|
+
issues.push(...lintChildRecipe(childPath, field, ref, stepLabel, visited));
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
// Named ref (no extension, no separator) → check ~/.patchwork/recipes/.
|
|
270
|
+
// Emit a warning rather than error: the recipe may be installed on the
|
|
271
|
+
// deploy target but not the author's machine.
|
|
272
|
+
if (existsSync(RECIPES_DIR)) {
|
|
273
|
+
const found = findYamlRecipePath(RECIPES_DIR, ref) ??
|
|
274
|
+
(existsSync(join(RECIPES_DIR, ref)) ? join(RECIPES_DIR, ref) : null);
|
|
275
|
+
if (!found) {
|
|
276
|
+
issues.push({
|
|
277
|
+
level: "warning",
|
|
278
|
+
message: `Step ${stepLabel}: '${field}: ${ref}' — recipe not found in ${RECIPES_DIR}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
issues.push(...lintChildRecipe(found, field, ref, stepLabel, visited));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return issues;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Read, parse, and validate a resolved child recipe path. Skips the file if
|
|
289
|
+
* it has already been visited (cycle). Issues are prefixed with the parent
|
|
290
|
+
* step context so the author knows where the problem originates.
|
|
291
|
+
*/
|
|
292
|
+
function lintChildRecipe(childPath, field, ref, stepNumber, visited) {
|
|
293
|
+
const absChild = resolve(childPath);
|
|
294
|
+
if (visited.has(absChild))
|
|
295
|
+
return []; // cycle — already linted
|
|
296
|
+
let childParsed;
|
|
297
|
+
try {
|
|
298
|
+
childParsed = parseYaml(readFileSync(childPath, "utf-8"));
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return [
|
|
302
|
+
{
|
|
303
|
+
level: "error",
|
|
304
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — could not read child recipe: ${err instanceof Error ? err.message : String(err)}`,
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
const childResult = validateRecipeDefinition(childParsed);
|
|
309
|
+
const childChainIssues = lintChainRefs(childParsed, childPath, visited);
|
|
310
|
+
return [
|
|
311
|
+
...childResult.issues.map((issue) => ({
|
|
312
|
+
...issue,
|
|
313
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — child recipe invalid: ${issue.message}`,
|
|
314
|
+
})),
|
|
315
|
+
...childChainIssues.map((issue) => ({
|
|
316
|
+
...issue,
|
|
317
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — ${issue.message}`,
|
|
318
|
+
})),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Format/normalize a recipe file.
|
|
323
|
+
* - Normalizes YAML formatting
|
|
324
|
+
* - Sorts keys in consistent order
|
|
325
|
+
* - Validates and re-serializes
|
|
326
|
+
*/
|
|
327
|
+
export function runFmt(recipePath, options = {}) {
|
|
328
|
+
const content = readFileSync(recipePath, "utf-8");
|
|
329
|
+
const { header: schemaHeader } = extractSchemaHeader(content);
|
|
330
|
+
const recipe = normalizeRecipeForRuntime(parseYaml(content), console.warn);
|
|
331
|
+
// Normalize key order
|
|
332
|
+
const normalized = {};
|
|
333
|
+
const keyOrder = [
|
|
334
|
+
"apiVersion",
|
|
335
|
+
"version",
|
|
336
|
+
"name",
|
|
337
|
+
"description",
|
|
338
|
+
"trigger",
|
|
339
|
+
"context",
|
|
340
|
+
"steps",
|
|
341
|
+
"expect",
|
|
342
|
+
"output",
|
|
343
|
+
"on_error",
|
|
344
|
+
];
|
|
345
|
+
for (const key of keyOrder) {
|
|
346
|
+
if (key in recipe) {
|
|
347
|
+
normalized[key] = recipe[key];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Add any extra keys at the end
|
|
351
|
+
for (const key of Object.keys(recipe)) {
|
|
352
|
+
if (!keyOrder.includes(key)) {
|
|
353
|
+
normalized[key] = recipe[key];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Re-serialize with consistent formatting
|
|
357
|
+
const formattedBody = stringifyYaml(normalized, {
|
|
358
|
+
indent: 2,
|
|
359
|
+
lineWidth: 100,
|
|
360
|
+
});
|
|
361
|
+
const formatted = schemaHeader
|
|
362
|
+
? `${schemaHeader}\n${formattedBody}`
|
|
363
|
+
: formattedBody;
|
|
364
|
+
const changed = formatted.trim() !== content.trim();
|
|
365
|
+
if (!options.check) {
|
|
366
|
+
writeFileSync(recipePath, formatted);
|
|
367
|
+
}
|
|
368
|
+
return { formatted, changed };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Watch a recipe file and re-run `runFmt` on every save (debounced).
|
|
372
|
+
* Mirrors runPreflightWatch / runTestWatch — composes runWatch + runFmt.
|
|
373
|
+
* Returns a stop function.
|
|
374
|
+
*/
|
|
375
|
+
export function runFmtWatch(options) {
|
|
376
|
+
const { recipePath, check, onResult, onError, debounceMs, watchFactory } = options;
|
|
377
|
+
return runWatch({
|
|
378
|
+
recipePath,
|
|
379
|
+
onChange: async () => {
|
|
380
|
+
const result = runFmt(recipePath, { check });
|
|
381
|
+
await onResult(result);
|
|
382
|
+
},
|
|
383
|
+
...(onError ? { onError } : {}),
|
|
384
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
385
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
function extractSchemaHeader(content) {
|
|
389
|
+
if (content.startsWith(`${RECIPE_SCHEMA_HEADER}\n`)) {
|
|
390
|
+
return { header: RECIPE_SCHEMA_HEADER };
|
|
391
|
+
}
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
export async function runRecipe(recipeRef, options = {}) {
|
|
395
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
396
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
397
|
+
const triggerType = recipe.trigger?.type;
|
|
398
|
+
if (options.step && triggerType === "chained") {
|
|
399
|
+
throw new Error(`Single-step execution is not supported for chained recipes: ${recipe.name}`);
|
|
400
|
+
}
|
|
401
|
+
const selection = options.step
|
|
402
|
+
? selectRecipeStep(recipe, options.step)
|
|
403
|
+
: undefined;
|
|
404
|
+
const recipeToRun = selection
|
|
405
|
+
? { ...recipe, steps: [selection.step] }
|
|
406
|
+
: recipe;
|
|
407
|
+
const runnerDeps = {
|
|
408
|
+
...options.deps,
|
|
409
|
+
workdir: options.workdir ?? options.deps?.workdir ?? process.cwd(),
|
|
410
|
+
};
|
|
411
|
+
if (options.dryRun) {
|
|
412
|
+
throw new Error("runRecipeDryPlan must be used for dry-run execution");
|
|
413
|
+
}
|
|
414
|
+
const result = await dispatchRecipe(recipeToRun, {
|
|
415
|
+
...runnerDeps,
|
|
416
|
+
chainedDeps: buildChainedDeps(runnerDeps),
|
|
417
|
+
chainedOptions: { sourcePath: recipePath },
|
|
418
|
+
}, options.vars ?? {});
|
|
419
|
+
return {
|
|
420
|
+
recipe,
|
|
421
|
+
recipePath,
|
|
422
|
+
result,
|
|
423
|
+
...(selection
|
|
424
|
+
? {
|
|
425
|
+
stepSelection: {
|
|
426
|
+
query: selection.query,
|
|
427
|
+
matchedBy: selection.matchedBy,
|
|
428
|
+
matchedValue: selection.matchedValue,
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
: {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
export function summarizeRecipeExecution(result) {
|
|
435
|
+
if ("stepsRun" in result) {
|
|
436
|
+
return {
|
|
437
|
+
ok: !result.errorMessage,
|
|
438
|
+
steps: result.stepsRun,
|
|
439
|
+
outputs: result.outputs,
|
|
440
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
ok: result.success,
|
|
445
|
+
steps: result.summary.total,
|
|
446
|
+
outputs: [],
|
|
447
|
+
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
|
448
|
+
failed: result.summary.failed,
|
|
449
|
+
skipped: result.summary.skipped,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Normalize either a yamlRunner RunResult or a chainedRunner ChainedRunResult
|
|
454
|
+
* into the RunStepResult[] shape expected by RecipeRunLog.appendDirect.
|
|
455
|
+
* Returns undefined when the result has no step-level detail.
|
|
456
|
+
*/
|
|
457
|
+
export function extractRunLogStepResults(result) {
|
|
458
|
+
if ("stepsRun" in result) {
|
|
459
|
+
// yamlRunner: stepResults is already StepResult[]
|
|
460
|
+
if (!Array.isArray(result.stepResults))
|
|
461
|
+
return undefined;
|
|
462
|
+
return result.stepResults.map((s) => ({
|
|
463
|
+
id: s.id,
|
|
464
|
+
...(s.tool ? { tool: s.tool } : {}),
|
|
465
|
+
status: s.status,
|
|
466
|
+
...(s.error ? { error: s.error } : {}),
|
|
467
|
+
durationMs: s.durationMs,
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
// chainedRunner: stepResults is Map<string, ChainedStepRunResult>
|
|
471
|
+
return [...result.stepResults.entries()].map(([id, s]) => ({
|
|
472
|
+
id,
|
|
473
|
+
status: s.skipped ? "skipped" : s.success ? "ok" : "error",
|
|
474
|
+
durationMs: s.durationMs ?? 0,
|
|
475
|
+
...(s.error ? { error: s.error.message } : {}),
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
export function formatRunReport(result, recipeName) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
const hr = "─".repeat(48);
|
|
481
|
+
if ("stepsRun" in result) {
|
|
482
|
+
// Simple (non-chained) recipe — compact summary
|
|
483
|
+
const ok = !result.errorMessage;
|
|
484
|
+
lines.push(`${ok ? "✓" : "✗"} ${recipeName} — ${result.stepsRun} step(s)`);
|
|
485
|
+
if (result.outputs.length > 0) {
|
|
486
|
+
for (const o of result.outputs)
|
|
487
|
+
lines.push(` → ${o}`);
|
|
488
|
+
}
|
|
489
|
+
if (result.errorMessage)
|
|
490
|
+
lines.push(` Error: ${result.errorMessage}`);
|
|
491
|
+
return lines.join("\n");
|
|
492
|
+
}
|
|
493
|
+
// Chained recipe — per-step table
|
|
494
|
+
const { stepResults, summary } = result;
|
|
495
|
+
const overallOk = result.success;
|
|
496
|
+
lines.push(hr);
|
|
497
|
+
lines.push(`Recipe: ${recipeName}`);
|
|
498
|
+
lines.push(hr);
|
|
499
|
+
for (const [id, step] of stepResults) {
|
|
500
|
+
const icon = step.skipped ? "↷" : step.success ? "✓" : "✗";
|
|
501
|
+
const dur = step.durationMs !== undefined ? ` (${step.durationMs}ms)` : "";
|
|
502
|
+
const err = step.error ? ` → ${step.error.message}` : "";
|
|
503
|
+
lines.push(` ${icon} ${id}${dur}${err}`);
|
|
504
|
+
}
|
|
505
|
+
lines.push(hr);
|
|
506
|
+
const parts = [`${summary.succeeded} ok`];
|
|
507
|
+
if (summary.skipped > 0)
|
|
508
|
+
parts.push(`${summary.skipped} skipped`);
|
|
509
|
+
if (summary.failed > 0)
|
|
510
|
+
parts.push(`${summary.failed} failed`);
|
|
511
|
+
lines.push(`${overallOk ? "✓" : "✗"} ${parts.join(" · ")}`);
|
|
512
|
+
return lines.join("\n");
|
|
513
|
+
}
|
|
514
|
+
export async function runWatchedRecipe(recipePath, options = {}) {
|
|
515
|
+
const lint = runLint(recipePath);
|
|
516
|
+
if (!lint.valid) {
|
|
517
|
+
return { lint };
|
|
518
|
+
}
|
|
519
|
+
const run = await runRecipe(recipePath, options);
|
|
520
|
+
return {
|
|
521
|
+
lint,
|
|
522
|
+
run,
|
|
523
|
+
summary: summarizeRecipeExecution(run.result),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Stable JSON schema version for machine-readable dry-run plans.
|
|
528
|
+
* Bump on breaking shape changes; consumers (dashboard run timeline, external tools)
|
|
529
|
+
* should gate on this field.
|
|
530
|
+
*/
|
|
531
|
+
export const DRY_RUN_PLAN_SCHEMA_VERSION = 1;
|
|
532
|
+
function enrichStepFromRegistry(step) {
|
|
533
|
+
if (step.type !== "tool" || !step.tool) {
|
|
534
|
+
return step;
|
|
535
|
+
}
|
|
536
|
+
const namespace = step.tool.split(".")[0];
|
|
537
|
+
const registered = getTool(step.tool);
|
|
538
|
+
const enriched = { ...step };
|
|
539
|
+
if (namespace)
|
|
540
|
+
enriched.namespace = namespace;
|
|
541
|
+
enriched.resolved = Boolean(registered);
|
|
542
|
+
if (registered) {
|
|
543
|
+
enriched.isWrite = registered.isWrite;
|
|
544
|
+
enriched.isConnector = registered.isConnector === true;
|
|
545
|
+
if (enriched.risk === undefined) {
|
|
546
|
+
enriched.risk = registered.riskDefault;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return enriched;
|
|
550
|
+
}
|
|
551
|
+
function summarizePlanSteps(steps) {
|
|
552
|
+
const connectors = new Set();
|
|
553
|
+
let hasWrite = false;
|
|
554
|
+
for (const step of steps) {
|
|
555
|
+
if (step.isConnector && step.namespace)
|
|
556
|
+
connectors.add(step.namespace);
|
|
557
|
+
if (step.isWrite)
|
|
558
|
+
hasWrite = true;
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
connectorNamespaces: [...connectors].sort(),
|
|
562
|
+
hasWriteSteps: hasWrite,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
export async function runRecipeDryPlan(recipeRef, options = {}) {
|
|
566
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
567
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
568
|
+
const triggerType = recipe.trigger?.type;
|
|
569
|
+
const selection = options.step
|
|
570
|
+
? selectRecipeStep(recipe, options.step)
|
|
571
|
+
: undefined;
|
|
572
|
+
const recipeToPlan = selection
|
|
573
|
+
? { ...recipe, steps: [selection.step] }
|
|
574
|
+
: recipe;
|
|
575
|
+
const generatedAt = new Date().toISOString();
|
|
576
|
+
if (triggerType === "chained") {
|
|
577
|
+
const { generateExecutionPlan } = await import("../recipes/chainedRunner.js");
|
|
578
|
+
const plan = generateExecutionPlan(recipeToPlan);
|
|
579
|
+
const steps = plan.steps.map((step) => {
|
|
580
|
+
const base = { id: step.id, type: step.type };
|
|
581
|
+
if (step.optional !== undefined)
|
|
582
|
+
base.optional = step.optional;
|
|
583
|
+
if (step.dependencies)
|
|
584
|
+
base.dependencies = step.dependencies;
|
|
585
|
+
if (step.condition !== undefined)
|
|
586
|
+
base.condition = step.condition;
|
|
587
|
+
if (step.risk !== undefined)
|
|
588
|
+
base.risk = step.risk;
|
|
589
|
+
const raw = step;
|
|
590
|
+
if (typeof raw.tool === "string")
|
|
591
|
+
base.tool = raw.tool;
|
|
592
|
+
if (typeof raw.into === "string")
|
|
593
|
+
base.into = raw.into;
|
|
594
|
+
return enrichStepFromRegistry(base);
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
|
|
598
|
+
generatedAt,
|
|
599
|
+
recipe: recipe.name,
|
|
600
|
+
mode: "dry-run",
|
|
601
|
+
triggerType,
|
|
602
|
+
...(selection ? { stepSelection: toStepSelection(selection) } : {}),
|
|
603
|
+
steps,
|
|
604
|
+
parallelGroups: plan.parallelGroups,
|
|
605
|
+
maxDepth: plan.maxDepth,
|
|
606
|
+
...summarizePlanSteps(steps),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const steps = buildSimpleRecipeDryRunSteps(recipeToPlan, options.vars ?? {}).map(enrichStepFromRegistry);
|
|
610
|
+
return {
|
|
611
|
+
schemaVersion: DRY_RUN_PLAN_SCHEMA_VERSION,
|
|
612
|
+
generatedAt,
|
|
613
|
+
recipe: recipe.name,
|
|
614
|
+
mode: "dry-run",
|
|
615
|
+
triggerType: typeof triggerType === "string" ? triggerType : "manual",
|
|
616
|
+
...(selection ? { stepSelection: toStepSelection(selection) } : {}),
|
|
617
|
+
steps,
|
|
618
|
+
...summarizePlanSteps(steps),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Static policy check over a recipe: lint + dry-plan + unresolved/write/fixture checks.
|
|
623
|
+
* No connector calls, no agent calls — safe to run in CI.
|
|
624
|
+
*/
|
|
625
|
+
export async function runPreflight(recipeRef, options = {}) {
|
|
626
|
+
const recipePath = resolveRecipePath(recipeRef);
|
|
627
|
+
const issues = [];
|
|
628
|
+
const lint = runLint(recipePath);
|
|
629
|
+
for (const issue of lint.issues) {
|
|
630
|
+
issues.push({
|
|
631
|
+
level: issue.level,
|
|
632
|
+
code: issue.level === "error" ? "lint-error" : "lint-warning",
|
|
633
|
+
message: issue.message,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const plan = await runRecipeDryPlan(recipeRef, options);
|
|
637
|
+
const requireWriteAck = options.requireWriteAck ?? true;
|
|
638
|
+
const allowlist = new Set(options.allowWrites ?? []);
|
|
639
|
+
for (const step of plan.steps) {
|
|
640
|
+
if (step.type === "tool" && step.tool && step.resolved === false) {
|
|
641
|
+
issues.push({
|
|
642
|
+
level: "error",
|
|
643
|
+
code: "unresolved-tool",
|
|
644
|
+
message: `Tool "${step.tool}" is not registered`,
|
|
645
|
+
stepId: step.id,
|
|
646
|
+
tool: step.tool,
|
|
647
|
+
...(step.namespace ? { namespace: step.namespace } : {}),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (requireWriteAck &&
|
|
651
|
+
step.isWrite === true &&
|
|
652
|
+
step.tool &&
|
|
653
|
+
!allowlist.has(step.tool) &&
|
|
654
|
+
!(step.namespace && allowlist.has(step.namespace))) {
|
|
655
|
+
issues.push({
|
|
656
|
+
level: "error",
|
|
657
|
+
code: "unacknowledged-write",
|
|
658
|
+
message: `Step "${step.id}" performs a write via "${step.tool}" but is not acknowledged via allowWrites`,
|
|
659
|
+
stepId: step.id,
|
|
660
|
+
tool: step.tool,
|
|
661
|
+
...(step.namespace ? { namespace: step.namespace } : {}),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (options.requireFixtures && plan.connectorNamespaces) {
|
|
666
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
667
|
+
for (const ns of plan.connectorNamespaces) {
|
|
668
|
+
const library = loadFixtureLibrary(join(fixturesDir, `${ns}.json`));
|
|
669
|
+
if (!library) {
|
|
670
|
+
issues.push({
|
|
671
|
+
level: "error",
|
|
672
|
+
code: "missing-fixture",
|
|
673
|
+
message: `Missing fixture library for connector "${ns}" at ${fixturesDir}/${ns}.json`,
|
|
674
|
+
namespace: ns,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const ok = !issues.some((issue) => issue.level === "error");
|
|
680
|
+
return { ok, recipe: plan.recipe, issues, plan };
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Watch a recipe file and run preflight on every save (debounced). Composes
|
|
684
|
+
* runWatch + runPreflight so editor integrations get live policy feedback
|
|
685
|
+
* without spawning the CLI per keystroke.
|
|
686
|
+
*
|
|
687
|
+
* Returns a stop function. If a preflight is in-flight when a new save lands,
|
|
688
|
+
* at most one rerun is queued (matches runWatch semantics).
|
|
689
|
+
*/
|
|
690
|
+
export function runPreflightWatch(options) {
|
|
691
|
+
const { recipePath, onResult, onError, debounceMs, watchFactory, ...preflightOptions } = options;
|
|
692
|
+
const watchOptions = {
|
|
693
|
+
recipePath,
|
|
694
|
+
onChange: async () => {
|
|
695
|
+
const result = await runPreflight(recipePath, preflightOptions);
|
|
696
|
+
await onResult(result);
|
|
697
|
+
},
|
|
698
|
+
...(onError ? { onError } : {}),
|
|
699
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
700
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
701
|
+
};
|
|
702
|
+
return runWatch(watchOptions);
|
|
703
|
+
}
|
|
704
|
+
function resolveRecipePath(recipeRef) {
|
|
705
|
+
const directPath = resolve(recipeRef);
|
|
706
|
+
if (existsSync(directPath) && statSync(directPath).isFile()) {
|
|
707
|
+
return directPath;
|
|
708
|
+
}
|
|
709
|
+
const bundledDir = fileURLToPath(new URL("../../templates/recipes", import.meta.url));
|
|
710
|
+
const normalizedRef = recipeRef.replace(/\.(yaml|yml|json)$/i, "");
|
|
711
|
+
const candidates = [
|
|
712
|
+
join(RECIPES_DIR, `${normalizedRef}.yaml`),
|
|
713
|
+
join(RECIPES_DIR, `${normalizedRef}.yml`),
|
|
714
|
+
join(RECIPES_DIR, `${normalizedRef}.json`),
|
|
715
|
+
join(bundledDir, `${normalizedRef}.yaml`),
|
|
716
|
+
join(bundledDir, `${normalizedRef}.yml`),
|
|
717
|
+
];
|
|
718
|
+
for (const candidate of candidates) {
|
|
719
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
720
|
+
return candidate;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
throw new Error(`recipe "${basename(recipeRef)}" not found in ${RECIPES_DIR}`);
|
|
724
|
+
}
|
|
725
|
+
function selectRecipeStep(recipe, query) {
|
|
726
|
+
const matches = recipe.steps
|
|
727
|
+
.map((step) => {
|
|
728
|
+
const match = matchRecipeStep(step, query);
|
|
729
|
+
return match ? { ...match, query, step } : undefined;
|
|
730
|
+
})
|
|
731
|
+
.filter((match) => Boolean(match));
|
|
732
|
+
if (matches.length === 0) {
|
|
733
|
+
throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
|
|
734
|
+
}
|
|
735
|
+
if (matches.length > 1) {
|
|
736
|
+
const labels = matches
|
|
737
|
+
.map((match) => `${match.matchedBy}:${match.matchedValue}`)
|
|
738
|
+
.join(", ");
|
|
739
|
+
throw new Error(`Step "${query}" is ambiguous in recipe "${recipe.name}": ${labels}`);
|
|
740
|
+
}
|
|
741
|
+
const [match] = matches;
|
|
742
|
+
if (!match) {
|
|
743
|
+
throw new Error(`Step "${query}" not found in recipe "${recipe.name}"`);
|
|
744
|
+
}
|
|
745
|
+
return match;
|
|
746
|
+
}
|
|
747
|
+
function matchRecipeStep(step, query) {
|
|
748
|
+
const id = typeof step.id === "string" ? step.id : undefined;
|
|
749
|
+
if (id === query) {
|
|
750
|
+
return { matchedBy: "id", matchedValue: id };
|
|
751
|
+
}
|
|
752
|
+
const into = getStepInto(step);
|
|
753
|
+
if (into === query) {
|
|
754
|
+
return { matchedBy: "into", matchedValue: into };
|
|
755
|
+
}
|
|
756
|
+
const tool = typeof step.tool === "string" ? step.tool : undefined;
|
|
757
|
+
if (tool === query) {
|
|
758
|
+
return { matchedBy: "tool", matchedValue: tool };
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
function getStepInto(step) {
|
|
763
|
+
if (typeof step.into === "string" && step.into) {
|
|
764
|
+
return step.into;
|
|
765
|
+
}
|
|
766
|
+
if (step.agent &&
|
|
767
|
+
typeof step.agent === "object" &&
|
|
768
|
+
typeof step.agent.into === "string" &&
|
|
769
|
+
step.agent.into) {
|
|
770
|
+
return step.agent.into;
|
|
771
|
+
}
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
function toStepSelection(selection) {
|
|
775
|
+
return {
|
|
776
|
+
query: selection.query,
|
|
777
|
+
matchedBy: selection.matchedBy,
|
|
778
|
+
matchedValue: selection.matchedValue,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
function buildSimpleRecipeDryRunSteps(recipe, vars) {
|
|
782
|
+
const now = new Date();
|
|
783
|
+
const ctx = {
|
|
784
|
+
date: now.toISOString().slice(0, 10),
|
|
785
|
+
time: now.toTimeString().slice(0, 5),
|
|
786
|
+
...vars,
|
|
787
|
+
};
|
|
788
|
+
return recipe.steps.map((step, index) => {
|
|
789
|
+
const id = (typeof step.id === "string" && step.id) ||
|
|
790
|
+
getStepInto(step) ||
|
|
791
|
+
step.tool ||
|
|
792
|
+
`step_${index}`;
|
|
793
|
+
if (step.agent) {
|
|
794
|
+
const prompt = render(step.agent.prompt, ctx);
|
|
795
|
+
const into = getStepInto(step);
|
|
796
|
+
if (into) {
|
|
797
|
+
ctx[into] = `[dry-run:${id}]`;
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
id,
|
|
801
|
+
type: "agent",
|
|
802
|
+
into,
|
|
803
|
+
optional: step.optional,
|
|
804
|
+
prompt,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const params = {};
|
|
808
|
+
for (const [key, value] of Object.entries(step)) {
|
|
809
|
+
if (key === "tool" || key === "agent" || key === "into" || key === "id") {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
params[key] = typeof value === "string" ? render(value, ctx) : value;
|
|
813
|
+
}
|
|
814
|
+
const into = getStepInto(step);
|
|
815
|
+
if (into) {
|
|
816
|
+
ctx[into] = `[dry-run:${id}]`;
|
|
817
|
+
if (step.tool) {
|
|
818
|
+
seedToolOutputPreviewContext(step.tool, into, id, ctx);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
id,
|
|
823
|
+
type: "tool",
|
|
824
|
+
tool: step.tool,
|
|
825
|
+
into,
|
|
826
|
+
optional: step.optional,
|
|
827
|
+
params,
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
export async function runRecord(recipePath, options = {}) {
|
|
832
|
+
const lint = runLint(recipePath);
|
|
833
|
+
const issues = [...lint.issues];
|
|
834
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
835
|
+
let recordedFixtures = [];
|
|
836
|
+
let stepsRun = 0;
|
|
837
|
+
let outputs = [];
|
|
838
|
+
if (issues.every((issue) => issue.level !== "error")) {
|
|
839
|
+
try {
|
|
840
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
841
|
+
recordedFixtures = getRequiredFixtureNamespaces(recipe.steps);
|
|
842
|
+
const run = await runYamlRecipe(recipe, {
|
|
843
|
+
...options.deps,
|
|
844
|
+
recordFixturesDir: fixturesDir,
|
|
845
|
+
});
|
|
846
|
+
stepsRun = run.stepsRun;
|
|
847
|
+
outputs = run.outputs;
|
|
848
|
+
if (run.errorMessage) {
|
|
849
|
+
issues.push({
|
|
850
|
+
level: "error",
|
|
851
|
+
message: run.errorMessage,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
issues.push({
|
|
857
|
+
level: "error",
|
|
858
|
+
message: err instanceof Error ? err.message : String(err),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const errors = issues.filter((issue) => issue.level === "error").length;
|
|
863
|
+
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
864
|
+
return {
|
|
865
|
+
valid: errors === 0,
|
|
866
|
+
issues,
|
|
867
|
+
warnings,
|
|
868
|
+
errors,
|
|
869
|
+
recordedFixtures,
|
|
870
|
+
stepsRun,
|
|
871
|
+
outputs,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
export async function runTest(recipePath, options = {}) {
|
|
875
|
+
const lint = runLint(recipePath);
|
|
876
|
+
const fixturesDir = options.fixturesDir ?? FIXTURES_DIR;
|
|
877
|
+
const issues = [...lint.issues];
|
|
878
|
+
let requiredFixtures = [];
|
|
879
|
+
let stepsRun = 0;
|
|
880
|
+
let outputs = [];
|
|
881
|
+
let assertionFailures = [];
|
|
882
|
+
if (existsSync(recipePath)) {
|
|
883
|
+
try {
|
|
884
|
+
const recipe = parseYaml(readFileSync(recipePath, "utf-8"));
|
|
885
|
+
requiredFixtures = getRequiredFixtureNamespaces(recipe.steps ?? []);
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
requiredFixtures = [];
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const missingFixtures = requiredFixtures.filter((provider) => !existsSync(join(fixturesDir, `${provider}.json`)));
|
|
892
|
+
for (const provider of missingFixtures) {
|
|
893
|
+
issues.push({
|
|
894
|
+
level: "error",
|
|
895
|
+
message: `Missing fixture library for connector '${provider}' at ${join(fixturesDir, `${provider}.json`)}`,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
if (issues.every((issue) => issue.level !== "error")) {
|
|
899
|
+
try {
|
|
900
|
+
const recipe = loadYamlRecipe(recipePath);
|
|
901
|
+
const triggerType = recipe.trigger?.type;
|
|
902
|
+
if (triggerType === "chained") {
|
|
903
|
+
// Chained recipes: run through chainedRunner with mocked tool + agent executors
|
|
904
|
+
const { runChainedRecipe } = await import("../recipes/chainedRunner.js");
|
|
905
|
+
const { evaluateExpect } = await import("../recipes/yamlRunner.js");
|
|
906
|
+
const chainedRecipe = recipe;
|
|
907
|
+
const recipeRecord = recipe;
|
|
908
|
+
const run = await runChainedRecipe(chainedRecipe, {
|
|
909
|
+
env: process.env,
|
|
910
|
+
maxConcurrency: recipeRecord.maxConcurrency ?? 4,
|
|
911
|
+
maxDepth: recipeRecord.maxDepth ?? 3,
|
|
912
|
+
dryRun: false,
|
|
913
|
+
sourcePath: recipePath,
|
|
914
|
+
}, {
|
|
915
|
+
executeTool: async (tool) => `[mock:${tool}]`,
|
|
916
|
+
executeAgent: async () => "[mock agent output]",
|
|
917
|
+
loadNestedRecipe: async () => null,
|
|
918
|
+
});
|
|
919
|
+
stepsRun = run.summary.total;
|
|
920
|
+
if (run.errorMessage) {
|
|
921
|
+
issues.push({ level: "error", message: run.errorMessage });
|
|
922
|
+
}
|
|
923
|
+
// Evaluate expect: block against chained run results
|
|
924
|
+
const expectBlock = recipeRecord.expect;
|
|
925
|
+
if (expectBlock) {
|
|
926
|
+
const failures = evaluateExpect({
|
|
927
|
+
stepsRun: run.summary.total,
|
|
928
|
+
outputs: [],
|
|
929
|
+
context: run.context,
|
|
930
|
+
errorMessage: run.errorMessage,
|
|
931
|
+
}, expectBlock);
|
|
932
|
+
assertionFailures = failures;
|
|
933
|
+
for (const failure of failures) {
|
|
934
|
+
issues.push({ level: "error", message: failure.message });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
const mockConnectors = createMockToolConnectors(recipe.steps, fixturesDir);
|
|
940
|
+
const run = await runYamlRecipe(recipe, {
|
|
941
|
+
testMode: true,
|
|
942
|
+
mockConnectors,
|
|
943
|
+
readFile: (filePath) => readFileSync(filePath, "utf-8"),
|
|
944
|
+
writeFile: () => { },
|
|
945
|
+
appendFile: () => { },
|
|
946
|
+
mkdir: () => { },
|
|
947
|
+
gitLogSince: () => "[mock git log]",
|
|
948
|
+
gitStaleBranches: () => "[mock stale branches]",
|
|
949
|
+
getDiagnostics: () => "[mock diagnostics]",
|
|
950
|
+
claudeFn: async () => "[mock agent output]",
|
|
951
|
+
claudeCodeFn: async () => "[mock agent output]",
|
|
952
|
+
providerDriverFn: async () => "[mock agent output]",
|
|
953
|
+
});
|
|
954
|
+
stepsRun = run.stepsRun;
|
|
955
|
+
outputs = run.outputs;
|
|
956
|
+
if (run.assertionFailures && run.assertionFailures.length > 0) {
|
|
957
|
+
assertionFailures = run.assertionFailures;
|
|
958
|
+
for (const failure of run.assertionFailures) {
|
|
959
|
+
issues.push({ level: "error", message: failure.message });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (run.errorMessage) {
|
|
963
|
+
issues.push({ level: "error", message: run.errorMessage });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
issues.push({
|
|
969
|
+
level: "error",
|
|
970
|
+
message: err instanceof Error ? err.message : String(err),
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const errors = issues.filter((issue) => issue.level === "error").length;
|
|
975
|
+
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
976
|
+
return {
|
|
977
|
+
valid: errors === 0,
|
|
978
|
+
issues,
|
|
979
|
+
warnings,
|
|
980
|
+
errors,
|
|
981
|
+
requiredFixtures,
|
|
982
|
+
missingFixtures,
|
|
983
|
+
stepsRun,
|
|
984
|
+
outputs,
|
|
985
|
+
assertionFailures,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Watch a recipe file and re-run `patchwork recipe test` on every save (debounced).
|
|
990
|
+
* Mirrors runPreflightWatch — composes runWatch + runTest.
|
|
991
|
+
* Returns a stop function.
|
|
992
|
+
*/
|
|
993
|
+
export function runTestWatch(options) {
|
|
994
|
+
const { recipePath, fixturesDir, onResult, onError, debounceMs, watchFactory, } = options;
|
|
995
|
+
return runWatch({
|
|
996
|
+
recipePath,
|
|
997
|
+
onChange: async () => {
|
|
998
|
+
const result = await runTest(recipePath, { fixturesDir });
|
|
999
|
+
await onResult(result);
|
|
1000
|
+
},
|
|
1001
|
+
...(onError ? { onError } : {}),
|
|
1002
|
+
...(debounceMs !== undefined ? { debounceMs } : {}),
|
|
1003
|
+
...(watchFactory ? { watchFactory } : {}),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
function getRequiredFixtureNamespaces(steps) {
|
|
1007
|
+
const namespaces = new Set();
|
|
1008
|
+
for (const step of steps) {
|
|
1009
|
+
const tool = step.tool;
|
|
1010
|
+
if (typeof tool !== "string") {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const namespace = tool.split(".")[0];
|
|
1014
|
+
if (namespace && isConnectorNamespace(namespace)) {
|
|
1015
|
+
namespaces.add(namespace);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return [...namespaces].sort();
|
|
1019
|
+
}
|
|
1020
|
+
function createMockToolConnectors(steps, fixturesDir) {
|
|
1021
|
+
const providerConnectors = new Map();
|
|
1022
|
+
const toolConnectors = {};
|
|
1023
|
+
for (const step of steps) {
|
|
1024
|
+
const tool = step.tool;
|
|
1025
|
+
if (typeof tool !== "string") {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const [namespace, operation] = tool.split(".");
|
|
1029
|
+
if (!namespace || !operation || !isConnectorNamespace(namespace)) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
let connector = providerConnectors.get(namespace);
|
|
1033
|
+
if (!connector) {
|
|
1034
|
+
connector = new MockConnector(namespace, {
|
|
1035
|
+
fixturePath: join(fixturesDir, `${namespace}.json`),
|
|
1036
|
+
});
|
|
1037
|
+
providerConnectors.set(namespace, connector);
|
|
1038
|
+
}
|
|
1039
|
+
toolConnectors[tool] = {
|
|
1040
|
+
invoke: async (_unusedOperation, input) => {
|
|
1041
|
+
const output = await connector.invoke(operation, input);
|
|
1042
|
+
return (typeof output === "string" ? output : JSON.stringify(output));
|
|
1043
|
+
},
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
return toolConnectors;
|
|
1047
|
+
}
|
|
1048
|
+
function normalizeChangedFile(changedFile) {
|
|
1049
|
+
if (typeof changedFile === "string") {
|
|
1050
|
+
return changedFile;
|
|
1051
|
+
}
|
|
1052
|
+
if (changedFile instanceof Buffer) {
|
|
1053
|
+
return changedFile.toString();
|
|
1054
|
+
}
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
export function runWatch(options) {
|
|
1058
|
+
const dir = dirname(resolve(options.recipePath));
|
|
1059
|
+
const filename = basename(options.recipePath);
|
|
1060
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
1061
|
+
const watchFactory = options.watchFactory ??
|
|
1062
|
+
((watchPath, watchOptions, listener) => watch(watchPath, watchOptions, listener));
|
|
1063
|
+
let debounceTimer = null;
|
|
1064
|
+
let running = false;
|
|
1065
|
+
let rerunQueued = false;
|
|
1066
|
+
let stopped = false;
|
|
1067
|
+
const handleError = (err) => {
|
|
1068
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
1069
|
+
};
|
|
1070
|
+
const finishChange = () => {
|
|
1071
|
+
running = false;
|
|
1072
|
+
if (stopped || !rerunQueued) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
rerunQueued = false;
|
|
1076
|
+
executeChange();
|
|
1077
|
+
};
|
|
1078
|
+
const executeChange = () => {
|
|
1079
|
+
if (stopped) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (running) {
|
|
1083
|
+
rerunQueued = true;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
running = true;
|
|
1087
|
+
try {
|
|
1088
|
+
const changeResult = options.onChange();
|
|
1089
|
+
void Promise.resolve(changeResult)
|
|
1090
|
+
.catch(handleError)
|
|
1091
|
+
.finally(finishChange);
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
handleError(err);
|
|
1095
|
+
finishChange();
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
const scheduleChange = () => {
|
|
1099
|
+
if (stopped) {
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (running) {
|
|
1103
|
+
rerunQueued = true;
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (debounceTimer) {
|
|
1107
|
+
clearTimeout(debounceTimer);
|
|
1108
|
+
}
|
|
1109
|
+
debounceTimer = setTimeout(() => {
|
|
1110
|
+
debounceTimer = null;
|
|
1111
|
+
executeChange();
|
|
1112
|
+
}, debounceMs);
|
|
1113
|
+
};
|
|
1114
|
+
const watcher = watchFactory(dir, { recursive: false }, (_eventType, changedFile) => {
|
|
1115
|
+
const changedName = normalizeChangedFile(changedFile);
|
|
1116
|
+
if (changedName === filename) {
|
|
1117
|
+
scheduleChange();
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
// Return cleanup function
|
|
1121
|
+
return () => {
|
|
1122
|
+
stopped = true;
|
|
1123
|
+
if (debounceTimer) {
|
|
1124
|
+
clearTimeout(debounceTimer);
|
|
1125
|
+
debounceTimer = null;
|
|
1126
|
+
}
|
|
1127
|
+
watcher.close();
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
//# sourceMappingURL=recipe.js.map
|