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
|
@@ -25,16 +25,87 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, write
|
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { parse as parseYaml } from "yaml";
|
|
28
|
+
import { captureFixture } from "../connectors/fixtureRecorder.js";
|
|
29
|
+
import { normalizeRecipeForRuntime } from "./legacyRecipeCompat.js";
|
|
30
|
+
// Import tool registry and trigger tool self-registration
|
|
31
|
+
import { applyToolOutputContext, executeTool, getTool, hasTool, registerPluginTools, } from "./toolRegistry.js";
|
|
32
|
+
import "./tools/index.js";
|
|
33
|
+
export function evaluateExpect(result, expect) {
|
|
34
|
+
const failures = [];
|
|
35
|
+
if (expect.stepsRun !== undefined && result.stepsRun !== expect.stepsRun) {
|
|
36
|
+
failures.push({
|
|
37
|
+
assertion: "stepsRun",
|
|
38
|
+
expected: expect.stepsRun,
|
|
39
|
+
actual: result.stepsRun,
|
|
40
|
+
message: `Expected stepsRun=${expect.stepsRun}, got ${result.stepsRun}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (expect.errorMessage !== undefined) {
|
|
44
|
+
const expected = expect.errorMessage ?? null;
|
|
45
|
+
const actual = result.errorMessage ?? null;
|
|
46
|
+
if (expected !== actual) {
|
|
47
|
+
failures.push({
|
|
48
|
+
assertion: "errorMessage",
|
|
49
|
+
expected,
|
|
50
|
+
actual,
|
|
51
|
+
message: expected === null
|
|
52
|
+
? `Expected clean run (no error), got: ${actual}`
|
|
53
|
+
: `Expected error "${expected}", got: ${actual === null ? "(none)" : actual}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (expect.outputs !== undefined) {
|
|
58
|
+
for (const key of expect.outputs) {
|
|
59
|
+
if (!result.outputs.includes(key)) {
|
|
60
|
+
failures.push({
|
|
61
|
+
assertion: "outputs",
|
|
62
|
+
expected: key,
|
|
63
|
+
actual: result.outputs,
|
|
64
|
+
message: `Expected output key "${key}" not found in [${result.outputs.join(", ")}]`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (expect.context !== undefined) {
|
|
70
|
+
for (const [key, expectedVal] of Object.entries(expect.context)) {
|
|
71
|
+
const actual = result.context[key];
|
|
72
|
+
if (actual === undefined) {
|
|
73
|
+
failures.push({
|
|
74
|
+
assertion: `context.${key}`,
|
|
75
|
+
expected: expectedVal,
|
|
76
|
+
actual: undefined,
|
|
77
|
+
message: `Expected context key "${key}" to equal "${expectedVal}", but key is missing`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else if (!actual.includes(expectedVal)) {
|
|
81
|
+
failures.push({
|
|
82
|
+
assertion: `context.${key}`,
|
|
83
|
+
expected: expectedVal,
|
|
84
|
+
actual,
|
|
85
|
+
message: `Expected context["${key}"] to contain "${expectedVal}", got "${actual}"`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return failures;
|
|
91
|
+
}
|
|
92
|
+
// Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
|
|
93
|
+
function stripLeadingNarration(text) {
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
|
|
96
|
+
return firstMarkdown > 0 ? lines.slice(firstMarkdown).join("\n") : text;
|
|
97
|
+
}
|
|
28
98
|
export function loadYamlRecipe(filePath) {
|
|
29
99
|
const text = readFileSync(filePath, "utf-8");
|
|
30
100
|
const raw = parseYaml(text);
|
|
31
101
|
return validateYamlRecipe(raw);
|
|
32
102
|
}
|
|
33
103
|
export function validateYamlRecipe(raw) {
|
|
34
|
-
|
|
104
|
+
const normalized = normalizeRecipeForRuntime(raw);
|
|
105
|
+
if (typeof normalized !== "object" || normalized === null) {
|
|
35
106
|
throw new Error("recipe must be an object");
|
|
36
107
|
}
|
|
37
|
-
const r =
|
|
108
|
+
const r = normalized;
|
|
38
109
|
if (typeof r.name !== "string" || !r.name) {
|
|
39
110
|
throw new Error("recipe.name required");
|
|
40
111
|
}
|
|
@@ -44,51 +115,80 @@ export function validateYamlRecipe(raw) {
|
|
|
44
115
|
if (!Array.isArray(r.steps) || r.steps.length === 0) {
|
|
45
116
|
throw new Error("recipe.steps must be a non-empty array");
|
|
46
117
|
}
|
|
118
|
+
if (r.servers !== undefined &&
|
|
119
|
+
(!Array.isArray(r.servers) ||
|
|
120
|
+
r.servers.some((s) => typeof s !== "string"))) {
|
|
121
|
+
throw new Error("recipe.servers must be an array of strings if present");
|
|
122
|
+
}
|
|
47
123
|
return r;
|
|
48
124
|
}
|
|
125
|
+
/** Track already-loaded plugin specs to avoid double-loading within a process. */
|
|
126
|
+
const loadedPluginSpecs = new Set();
|
|
127
|
+
/**
|
|
128
|
+
* Load plugin specs declared in `recipe.servers` and register their tools into
|
|
129
|
+
* the recipe tool registry. Errors per-spec are logged as warnings — never fatal.
|
|
130
|
+
*/
|
|
131
|
+
export async function loadRecipeServers(specs) {
|
|
132
|
+
const toLoad = specs.filter((s) => !loadedPluginSpecs.has(s));
|
|
133
|
+
if (toLoad.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
let loadPluginsFull;
|
|
136
|
+
try {
|
|
137
|
+
({ loadPluginsFull } = await import("../pluginLoader.js"));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
console.warn(`[recipe servers] failed to import pluginLoader: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const minimalConfig = {
|
|
144
|
+
workspace: process.cwd(),
|
|
145
|
+
workspaceFolders: [process.cwd()],
|
|
146
|
+
commandTimeout: 30_000,
|
|
147
|
+
maxResultSize: 1_048_576,
|
|
148
|
+
};
|
|
149
|
+
const minimalLogger = {
|
|
150
|
+
info: (msg) => console.info(`[recipe servers] ${msg}`),
|
|
151
|
+
warn: (msg) => console.warn(`[recipe servers] ${msg}`),
|
|
152
|
+
error: (msg) => console.error(`[recipe servers] ${msg}`),
|
|
153
|
+
debug: (_msg) => { },
|
|
154
|
+
};
|
|
155
|
+
for (const spec of toLoad) {
|
|
156
|
+
try {
|
|
157
|
+
const loaded = await loadPluginsFull([spec], minimalConfig, minimalLogger);
|
|
158
|
+
let toolCount = 0;
|
|
159
|
+
for (const plugin of loaded) {
|
|
160
|
+
const pluginTools = plugin.tools.map((t) => ({
|
|
161
|
+
name: t.schema.name,
|
|
162
|
+
handler: t.handler,
|
|
163
|
+
schema: t.schema,
|
|
164
|
+
}));
|
|
165
|
+
toolCount += registerPluginTools(pluginTools);
|
|
166
|
+
}
|
|
167
|
+
loadedPluginSpecs.add(spec);
|
|
168
|
+
if (toolCount > 0) {
|
|
169
|
+
console.info(`[recipe servers] loaded "${spec}" — ${toolCount} tool(s) registered`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
console.warn(`[recipe servers] failed to load "${spec}": ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
49
177
|
export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
178
|
+
if (recipe.servers?.length) {
|
|
179
|
+
await loadRecipeServers(recipe.servers);
|
|
180
|
+
}
|
|
50
181
|
const now = deps.now ? deps.now() : new Date();
|
|
51
182
|
const ctx = {
|
|
52
183
|
date: now.toISOString().slice(0, 10),
|
|
53
184
|
time: now.toTimeString().slice(0, 5),
|
|
54
185
|
...seedContext,
|
|
55
186
|
};
|
|
56
|
-
const
|
|
57
|
-
const writeFile = deps.writeFile ??
|
|
58
|
-
((p, content) => {
|
|
59
|
-
const abs = expandHome(p);
|
|
60
|
-
mkdirSync(path.dirname(abs), { recursive: true });
|
|
61
|
-
writeFileSync(abs, content);
|
|
62
|
-
});
|
|
63
|
-
const appendFile = deps.appendFile ??
|
|
64
|
-
((p, content) => {
|
|
65
|
-
const abs = expandHome(p);
|
|
66
|
-
mkdirSync(path.dirname(abs), { recursive: true });
|
|
67
|
-
appendFileSync(abs, content);
|
|
68
|
-
});
|
|
69
|
-
const mkdir = deps.mkdir ??
|
|
70
|
-
((p) => mkdirSync(expandHome(p), { recursive: true }));
|
|
187
|
+
const stepDeps = resolveStepDeps(deps);
|
|
71
188
|
const outputs = [];
|
|
189
|
+
const stepResults = [];
|
|
72
190
|
let stepsRun = 0;
|
|
73
|
-
|
|
74
|
-
const stepDeps = {
|
|
75
|
-
readFile,
|
|
76
|
-
writeFile,
|
|
77
|
-
appendFile,
|
|
78
|
-
mkdir,
|
|
79
|
-
workdir,
|
|
80
|
-
gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
|
|
81
|
-
gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
|
|
82
|
-
getDiagnostics: deps.getDiagnostics ?? (() => ""),
|
|
83
|
-
fetchFn: deps.fetchFn ?? globalThis.fetch,
|
|
84
|
-
claudeFn: deps.claudeFn ?? defaultClaudeFn,
|
|
85
|
-
claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
|
|
86
|
-
getGmailToken: deps.getGmailToken ??
|
|
87
|
-
(async () => {
|
|
88
|
-
const { getValidAccessToken } = await import("../connectors/gmail.js");
|
|
89
|
-
return getValidAccessToken();
|
|
90
|
-
}),
|
|
91
|
-
};
|
|
191
|
+
let runError;
|
|
92
192
|
for (const step of recipe.steps) {
|
|
93
193
|
// Handle agent steps separately
|
|
94
194
|
if (step.agent) {
|
|
@@ -96,64 +196,147 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
96
196
|
const renderedPrompt = render(agentCfg.prompt, ctx);
|
|
97
197
|
const model = agentCfg.model ?? "claude-haiku-4-5-20251001";
|
|
98
198
|
const intoKey = agentCfg.into ?? "agent_output";
|
|
199
|
+
const stepId = intoKey;
|
|
200
|
+
const stepStart = Date.now();
|
|
99
201
|
let agentResult;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
202
|
+
try {
|
|
203
|
+
if (agentCfg.driver === "claude-code") {
|
|
204
|
+
agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
|
|
205
|
+
}
|
|
206
|
+
else if (agentCfg.driver === "api") {
|
|
207
|
+
agentResult = await stepDeps.claudeFn(renderedPrompt, model);
|
|
208
|
+
}
|
|
209
|
+
else if (agentCfg.driver === "openai" ||
|
|
210
|
+
agentCfg.driver === "grok" ||
|
|
211
|
+
agentCfg.driver === "gemini") {
|
|
212
|
+
agentResult = await stepDeps.providerDriverFn(agentCfg.driver, renderedPrompt, agentCfg.model);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Default driver: use API path. If no ANTHROPIC_API_KEY and caller did not provide a
|
|
216
|
+
// custom claudeFn (i.e. using the built-in default that returns a skip message), probe
|
|
217
|
+
// for the claude CLI and fall back automatically.
|
|
218
|
+
const usingDefaultClaudeFn = deps.claudeFn === undefined;
|
|
219
|
+
if (!process.env.ANTHROPIC_API_KEY && usingDefaultClaudeFn) {
|
|
220
|
+
const probe = spawnSync("claude", ["--version"], {
|
|
221
|
+
encoding: "utf-8",
|
|
222
|
+
timeout: 5000,
|
|
223
|
+
});
|
|
224
|
+
if (!probe.error) {
|
|
225
|
+
agentResult = await stepDeps.claudeCodeFn(renderedPrompt);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
agentResult = await stepDeps.claudeFn(renderedPrompt, model);
|
|
229
|
+
}
|
|
118
230
|
}
|
|
119
231
|
else {
|
|
120
232
|
agentResult = await stepDeps.claudeFn(renderedPrompt, model);
|
|
121
233
|
}
|
|
122
234
|
}
|
|
235
|
+
if (agentResult.startsWith("[agent step failed:")) {
|
|
236
|
+
runError = runError ?? agentResult;
|
|
237
|
+
stepResults.push({
|
|
238
|
+
id: stepId,
|
|
239
|
+
tool: "agent",
|
|
240
|
+
status: "error",
|
|
241
|
+
error: agentResult,
|
|
242
|
+
durationMs: Date.now() - stepStart,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
123
245
|
else {
|
|
124
|
-
|
|
246
|
+
const stripped = stripLeadingNarration(agentResult);
|
|
247
|
+
if (!stripped.trim()) {
|
|
248
|
+
const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
|
|
249
|
+
runError = runError ?? errMsg;
|
|
250
|
+
stepResults.push({
|
|
251
|
+
id: stepId,
|
|
252
|
+
tool: "agent",
|
|
253
|
+
status: "error",
|
|
254
|
+
error: errMsg,
|
|
255
|
+
durationMs: Date.now() - stepStart,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
ctx[intoKey] = stripped;
|
|
260
|
+
outputs.push(intoKey);
|
|
261
|
+
stepResults.push({
|
|
262
|
+
id: stepId,
|
|
263
|
+
tool: "agent",
|
|
264
|
+
status: "ok",
|
|
265
|
+
durationMs: Date.now() - stepStart,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
125
268
|
}
|
|
126
269
|
}
|
|
127
|
-
|
|
128
|
-
|
|
270
|
+
catch (err) {
|
|
271
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
272
|
+
runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
|
|
273
|
+
stepResults.push({
|
|
274
|
+
id: stepId,
|
|
275
|
+
tool: "agent",
|
|
276
|
+
status: "error",
|
|
277
|
+
error: msg,
|
|
278
|
+
durationMs: Date.now() - stepStart,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
129
281
|
stepsRun++;
|
|
130
282
|
continue;
|
|
131
283
|
}
|
|
132
|
-
const
|
|
284
|
+
const stepStart = Date.now();
|
|
285
|
+
const stepId = step.into ?? step.tool ?? `step_${stepsRun}`;
|
|
286
|
+
let result;
|
|
287
|
+
try {
|
|
288
|
+
result = await executeStep(step, ctx, stepDeps);
|
|
289
|
+
// Detect tool-level errors reported as JSON {ok: false, error: ...}
|
|
290
|
+
let stepError;
|
|
291
|
+
if (result !== null) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(result);
|
|
294
|
+
if (parsed.ok === false && typeof parsed.error === "string") {
|
|
295
|
+
stepError = parsed.error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
/* non-JSON result is fine */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
stepResults.push({
|
|
303
|
+
id: stepId,
|
|
304
|
+
tool: step.tool,
|
|
305
|
+
status: result === null ? "skipped" : stepError ? "error" : "ok",
|
|
306
|
+
error: stepError,
|
|
307
|
+
durationMs: Date.now() - stepStart,
|
|
308
|
+
});
|
|
309
|
+
if (stepError)
|
|
310
|
+
runError = runError ?? `${step.tool} failed: ${stepError}`;
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
314
|
+
runError = runError ?? `${step.tool} failed: ${msg}`;
|
|
315
|
+
stepResults.push({
|
|
316
|
+
id: stepId,
|
|
317
|
+
tool: step.tool,
|
|
318
|
+
status: "error",
|
|
319
|
+
error: msg,
|
|
320
|
+
durationMs: Date.now() - stepStart,
|
|
321
|
+
});
|
|
322
|
+
result = null;
|
|
323
|
+
}
|
|
133
324
|
stepsRun++;
|
|
134
325
|
if (result !== null) {
|
|
326
|
+
// Apply transform if present — render template with $result injected
|
|
327
|
+
if (step.transform) {
|
|
328
|
+
try {
|
|
329
|
+
result = render(step.transform, { ...ctx, $result: result });
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
// warn but fall through with original result
|
|
333
|
+
console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
135
336
|
if (step.into) {
|
|
136
337
|
ctx[step.into] = result;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
step.tool === "gmail.search" ||
|
|
140
|
-
step.tool === "gmail.fetch_thread";
|
|
141
|
-
if (isGmailStep) {
|
|
142
|
-
try {
|
|
143
|
-
const parsed = JSON.parse(result);
|
|
144
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
145
|
-
if (typeof v === "string" || typeof v === "number") {
|
|
146
|
-
ctx[`${step.into}.${k}`] = String(v);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// Also expose messages array as JSON string for agent prompts
|
|
150
|
-
if (Array.isArray(parsed.messages)) {
|
|
151
|
-
ctx[`${step.into}.json`] = JSON.stringify(parsed.messages);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// non-JSON result, skip
|
|
156
|
-
}
|
|
338
|
+
if (step.tool) {
|
|
339
|
+
applyToolOutputContext(step.tool, step.into, result, ctx);
|
|
157
340
|
}
|
|
158
341
|
}
|
|
159
342
|
if (step.tool === "file.write" || step.tool === "file.append") {
|
|
@@ -161,7 +344,222 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
161
344
|
}
|
|
162
345
|
}
|
|
163
346
|
}
|
|
164
|
-
|
|
347
|
+
// Evaluate expect block before persisting so failures are stored in the run log
|
|
348
|
+
const assertionFailures = recipe.expect
|
|
349
|
+
? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
|
|
350
|
+
: [];
|
|
351
|
+
// Write to RecipeRunLog so the dashboard Runs page shows this execution
|
|
352
|
+
if (!stepDeps.testMode) {
|
|
353
|
+
try {
|
|
354
|
+
const { RecipeRunLog } = await import("../runLog.js");
|
|
355
|
+
const { homedir } = await import("node:os");
|
|
356
|
+
const resolvedLogDir = deps.logDir ?? path.join(homedir(), ".patchwork");
|
|
357
|
+
const log = new RecipeRunLog({ dir: resolvedLogDir });
|
|
358
|
+
const trigger = recipe.trigger?.type ?? "manual";
|
|
359
|
+
const createdAt = now.getTime();
|
|
360
|
+
const doneAt = Date.now();
|
|
361
|
+
const outputTail = stepResults
|
|
362
|
+
.map((s) => `[${s.status}] ${s.tool ?? s.id}${s.error ? `: ${s.error}` : ""}`)
|
|
363
|
+
.join("\n")
|
|
364
|
+
.slice(0, 2000);
|
|
365
|
+
log.appendDirect({
|
|
366
|
+
taskId: `yaml:${recipe.name}:${createdAt}`,
|
|
367
|
+
recipeName: recipe.name,
|
|
368
|
+
trigger: (["cron", "webhook", "recipe"].includes(trigger)
|
|
369
|
+
? trigger
|
|
370
|
+
: "recipe"),
|
|
371
|
+
status: runError ? "error" : "done",
|
|
372
|
+
createdAt,
|
|
373
|
+
startedAt: createdAt,
|
|
374
|
+
doneAt,
|
|
375
|
+
durationMs: doneAt - createdAt,
|
|
376
|
+
outputTail,
|
|
377
|
+
errorMessage: runError,
|
|
378
|
+
stepResults: stepResults.map((s) => ({
|
|
379
|
+
id: s.id,
|
|
380
|
+
tool: s.tool,
|
|
381
|
+
status: s.status,
|
|
382
|
+
error: s.error,
|
|
383
|
+
durationMs: s.durationMs,
|
|
384
|
+
})),
|
|
385
|
+
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Non-fatal — run log write failure should never break recipe execution
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Notify via Slack if any step failed
|
|
393
|
+
if (runError && !stepDeps.testMode) {
|
|
394
|
+
try {
|
|
395
|
+
const { isConnected, postMessage } = await import("../connectors/slack.js");
|
|
396
|
+
if (isConnected()) {
|
|
397
|
+
// Read notification channel from ~/.patchwork/config.json, fallback to first available
|
|
398
|
+
let notifyChannel = "all-massappealdesigns";
|
|
399
|
+
try {
|
|
400
|
+
const cfgPath = path.join(os.homedir(), ".patchwork", "config.json");
|
|
401
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
402
|
+
const notifications = cfg.notifications;
|
|
403
|
+
if (typeof notifications?.slackChannel === "string") {
|
|
404
|
+
notifyChannel = notifications.slackChannel;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* use default */
|
|
409
|
+
}
|
|
410
|
+
const failedSteps = stepResults
|
|
411
|
+
.filter((s) => s.status === "error")
|
|
412
|
+
.map((s) => `• ${s.tool ?? s.id}: ${s.error ?? "unknown error"}`)
|
|
413
|
+
.join("\n");
|
|
414
|
+
await postMessage(notifyChannel, `⚠️ *Recipe failed: ${recipe.name}*\n\n${failedSteps}\n\n_${new Date().toISOString()}_`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Non-fatal — notification failure should never mask the original error
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
recipe: recipe.name,
|
|
423
|
+
stepsRun,
|
|
424
|
+
outputs,
|
|
425
|
+
context: ctx,
|
|
426
|
+
stepResults,
|
|
427
|
+
errorMessage: runError,
|
|
428
|
+
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
export async function executeStep(step, ctx, deps) {
|
|
432
|
+
const toolId = step.tool;
|
|
433
|
+
if (!toolId) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
// Check if tool is registered in the new registry
|
|
437
|
+
if (hasTool(toolId)) {
|
|
438
|
+
const tool = getTool(toolId);
|
|
439
|
+
// Build params with template rendering for string values
|
|
440
|
+
const params = {};
|
|
441
|
+
for (const [key, value] of Object.entries(step)) {
|
|
442
|
+
if (key === "tool" || key === "agent" || key === "into")
|
|
443
|
+
continue;
|
|
444
|
+
if (typeof value === "string") {
|
|
445
|
+
params[key] = render(value, ctx);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
params[key] = value;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Check if mock connector is available for this tool
|
|
452
|
+
if (deps.mockConnectors?.[toolId]) {
|
|
453
|
+
return deps.mockConnectors[toolId].invoke("execute", params);
|
|
454
|
+
}
|
|
455
|
+
if (tool &&
|
|
456
|
+
deps.recordFixturesDir &&
|
|
457
|
+
tool.namespace !== "file" &&
|
|
458
|
+
tool.namespace !== "git" &&
|
|
459
|
+
tool.namespace !== "diagnostics") {
|
|
460
|
+
return captureFixture(path.join(deps.recordFixturesDir, `${tool.namespace}.json`), tool.namespace, toolId.split(".")[1] ?? toolId, params, async () => executeTool(toolId, { params, step, ctx, deps }));
|
|
461
|
+
}
|
|
462
|
+
return executeTool(toolId, { params, step, ctx, deps });
|
|
463
|
+
}
|
|
464
|
+
// Unknown tool — skip, don't throw (forward compat)
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
/** Minimal `{{ expr }}` renderer — replaces against flat context map. */
|
|
468
|
+
export function render(template, ctx) {
|
|
469
|
+
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
|
|
470
|
+
const key = expr.trim();
|
|
471
|
+
return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
function expandHome(p) {
|
|
475
|
+
if (p.startsWith("~/"))
|
|
476
|
+
return path.join(os.homedir(), p.slice(2));
|
|
477
|
+
return p;
|
|
478
|
+
}
|
|
479
|
+
function parseSinceToGitArg(since) {
|
|
480
|
+
const m = /^(\d+)(h|d)$/i.exec(since.trim());
|
|
481
|
+
if (!m)
|
|
482
|
+
return since;
|
|
483
|
+
const [, num, unit = "h"] = m;
|
|
484
|
+
return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
|
|
485
|
+
}
|
|
486
|
+
function defaultGitLogSince(since, workdir) {
|
|
487
|
+
try {
|
|
488
|
+
const sinceArg = parseSinceToGitArg(since);
|
|
489
|
+
const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
|
|
490
|
+
cwd: workdir ?? process.cwd(),
|
|
491
|
+
encoding: "utf-8",
|
|
492
|
+
timeout: 5000,
|
|
493
|
+
});
|
|
494
|
+
if (result.error || result.status !== 0)
|
|
495
|
+
return "(git log unavailable)";
|
|
496
|
+
return (result.stdout ?? "").trim();
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return "(git log unavailable)";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function defaultGitStaleBranches(days, workdir) {
|
|
503
|
+
try {
|
|
504
|
+
const cutoff = new Date(Date.now() - days * 86_400_000)
|
|
505
|
+
.toISOString()
|
|
506
|
+
.slice(0, 10);
|
|
507
|
+
const r = spawnSync("git", [
|
|
508
|
+
"branch",
|
|
509
|
+
"--no-column",
|
|
510
|
+
"--sort=-committerdate",
|
|
511
|
+
"--format=%(refname:short)",
|
|
512
|
+
`--since=${cutoff}`,
|
|
513
|
+
], {
|
|
514
|
+
cwd: workdir ?? process.cwd(),
|
|
515
|
+
encoding: "utf-8",
|
|
516
|
+
timeout: 5000,
|
|
517
|
+
});
|
|
518
|
+
if (r.error || r.status !== 0)
|
|
519
|
+
return "(git branches unavailable)";
|
|
520
|
+
return (r.stdout ?? "").trim();
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return "(git branches unavailable)";
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Resolve all RunnerDeps to concrete StepDeps with production defaults filled in. */
|
|
527
|
+
function resolveStepDeps(deps) {
|
|
528
|
+
const workdir = deps.workdir ?? process.cwd();
|
|
529
|
+
return {
|
|
530
|
+
readFile: deps.readFile ?? ((p) => readFileSync(expandHome(p), "utf-8")),
|
|
531
|
+
writeFile: deps.writeFile ??
|
|
532
|
+
((p, content) => {
|
|
533
|
+
const abs = expandHome(p);
|
|
534
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
535
|
+
writeFileSync(abs, content);
|
|
536
|
+
}),
|
|
537
|
+
appendFile: deps.appendFile ??
|
|
538
|
+
((p, content) => {
|
|
539
|
+
const abs = expandHome(p);
|
|
540
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
541
|
+
appendFileSync(abs, content);
|
|
542
|
+
}),
|
|
543
|
+
mkdir: deps.mkdir ??
|
|
544
|
+
((p) => mkdirSync(expandHome(p), { recursive: true })),
|
|
545
|
+
workdir,
|
|
546
|
+
gitLogSince: deps.gitLogSince ?? defaultGitLogSince,
|
|
547
|
+
gitStaleBranches: deps.gitStaleBranches ?? defaultGitStaleBranches,
|
|
548
|
+
getDiagnostics: deps.getDiagnostics ?? (() => ""),
|
|
549
|
+
fetchFn: deps.fetchFn ?? globalThis.fetch,
|
|
550
|
+
claudeFn: deps.claudeFn ?? defaultClaudeFn,
|
|
551
|
+
claudeCodeFn: deps.claudeCodeFn ?? defaultClaudeCodeFn,
|
|
552
|
+
providerDriverFn: deps.providerDriverFn ?? defaultProviderDriverFn,
|
|
553
|
+
mockConnectors: deps.mockConnectors ?? {},
|
|
554
|
+
recordFixturesDir: deps.recordFixturesDir,
|
|
555
|
+
getGmailToken: deps.getGmailToken ??
|
|
556
|
+
(async () => {
|
|
557
|
+
const { getValidAccessToken } = await import("../connectors/gmail.js");
|
|
558
|
+
return getValidAccessToken();
|
|
559
|
+
}),
|
|
560
|
+
logDir: deps.logDir,
|
|
561
|
+
testMode: deps.testMode ?? false,
|
|
562
|
+
};
|
|
165
563
|
}
|
|
166
564
|
function defaultClaudeCodeFn(prompt) {
|
|
167
565
|
try {
|
|
@@ -188,6 +586,49 @@ function defaultClaudeCodeFn(prompt) {
|
|
|
188
586
|
return Promise.resolve(`[agent step failed: ${err instanceof Error ? err.message : String(err)}]`);
|
|
189
587
|
}
|
|
190
588
|
}
|
|
589
|
+
// Cache provider drivers across steps within a single recipe process.
|
|
590
|
+
const providerDriverCache = new Map();
|
|
591
|
+
async function defaultProviderDriverFn(driverName, prompt, model) {
|
|
592
|
+
try {
|
|
593
|
+
let driver = providerDriverCache.get(driverName);
|
|
594
|
+
if (!driver) {
|
|
595
|
+
const { createDriver } = await import("../drivers/index.js");
|
|
596
|
+
const d = createDriver(driverName, { binary: "claude", antBinary: "ant" }, () => { });
|
|
597
|
+
if (!d)
|
|
598
|
+
return `[agent step failed: ${driverName} driver returned null]`;
|
|
599
|
+
driver = d;
|
|
600
|
+
providerDriverCache.set(driverName, driver);
|
|
601
|
+
}
|
|
602
|
+
const controller = new AbortController();
|
|
603
|
+
const timeoutMs = 300_000;
|
|
604
|
+
const startupTimeoutMs = 30_000;
|
|
605
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
606
|
+
try {
|
|
607
|
+
const result = await driver.run({
|
|
608
|
+
prompt,
|
|
609
|
+
workspace: process.cwd(),
|
|
610
|
+
timeoutMs,
|
|
611
|
+
startupTimeoutMs,
|
|
612
|
+
signal: controller.signal,
|
|
613
|
+
model,
|
|
614
|
+
});
|
|
615
|
+
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
616
|
+
const detail = result.stderrTail ?? result.text ?? "";
|
|
617
|
+
return `[agent step failed: ${driverName} exited ${result.exitCode}${detail ? ` — ${detail.slice(0, 200)}` : ""}]`;
|
|
618
|
+
}
|
|
619
|
+
if (!result.text) {
|
|
620
|
+
return `[agent step failed: ${driverName} returned empty output (possible timeout or auth error)]`;
|
|
621
|
+
}
|
|
622
|
+
return result.text;
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
clearTimeout(timeout);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
191
632
|
async function defaultClaudeFn(prompt, model) {
|
|
192
633
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
193
634
|
if (!apiKey)
|
|
@@ -222,283 +663,108 @@ async function defaultClaudeFn(prompt, model) {
|
|
|
222
663
|
return `[agent step failed: ${err instanceof Error ? err.message : String(err)}]`;
|
|
223
664
|
}
|
|
224
665
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (when && !evalWhen(when, ctx))
|
|
249
|
-
return null;
|
|
250
|
-
deps.appendFile(p, content);
|
|
251
|
-
return content;
|
|
252
|
-
}
|
|
253
|
-
case "git.log_since": {
|
|
254
|
-
const since = render(String(step.since ?? "24h"), ctx);
|
|
255
|
-
return deps.gitLogSince(since, deps.workdir);
|
|
256
|
-
}
|
|
257
|
-
case "git.stale_branches": {
|
|
258
|
-
const days = typeof step.days === "number" ? step.days : 30;
|
|
259
|
-
return deps.gitStaleBranches(days, deps.workdir);
|
|
260
|
-
}
|
|
261
|
-
case "diagnostics.get": {
|
|
262
|
-
const uri = render(String(step.uri ?? ""), ctx);
|
|
263
|
-
return deps.getDiagnostics(uri);
|
|
264
|
-
}
|
|
265
|
-
case "gmail.fetch_unread": {
|
|
266
|
-
const since = render(String(step.since ?? "24h"), ctx);
|
|
267
|
-
const MAX_GMAIL_RESULTS = 50;
|
|
268
|
-
const max = Math.min(typeof step.max === "number" ? step.max : 20, MAX_GMAIL_RESULTS);
|
|
269
|
-
const query = `is:unread newer_than:${sinceToGmailQuery(since)}`;
|
|
270
|
-
return gmailSearch(query, max, deps);
|
|
666
|
+
/**
|
|
667
|
+
* Build ExecutionDeps for ChainedRecipeRunner backed by the yamlRunner step
|
|
668
|
+
* handlers. This lets chained recipes use the same tool set (file.*, git.*,
|
|
669
|
+
* gmail.*, github.*, linear.*, diagnostics.*) as simple YAML recipes.
|
|
670
|
+
*
|
|
671
|
+
* Pass the result as `chainedDeps` when calling `dispatchRecipe` or
|
|
672
|
+
* `runChainedRecipe` so that `executeTool` is properly wired.
|
|
673
|
+
*/
|
|
674
|
+
export function buildChainedDeps(runnerDeps, claudeCodeFnOverride) {
|
|
675
|
+
const stepDeps = resolveStepDeps(runnerDeps);
|
|
676
|
+
const executeTool = async (tool, params) => {
|
|
677
|
+
// Construct a YamlStep-compatible object so we can reuse executeStep.
|
|
678
|
+
const step = { tool, ...params };
|
|
679
|
+
// executeStep uses a RunContext for {{}} rendering — by the time executeTool
|
|
680
|
+
// is called the chained runner has already resolved templates, so we pass
|
|
681
|
+
// an empty context (no double-rendering).
|
|
682
|
+
const result = await executeStep(step, {}, stepDeps);
|
|
683
|
+
return result ?? "";
|
|
684
|
+
};
|
|
685
|
+
const executeAgent = async (prompt, model, driver) => {
|
|
686
|
+
const claudeCodeFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
|
|
687
|
+
if (driver === "claude-code") {
|
|
688
|
+
return claudeCodeFn(prompt);
|
|
271
689
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const MAX_GMAIL_RESULTS = 50;
|
|
275
|
-
const max = Math.min(typeof step.max === "number" ? step.max : 10, MAX_GMAIL_RESULTS);
|
|
276
|
-
return gmailSearch(query, max, deps);
|
|
690
|
+
if (driver === "claude" || driver === "anthropic") {
|
|
691
|
+
return stepDeps.claudeFn(prompt, model ?? "claude-haiku-4-5-20251001");
|
|
277
692
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return gmailFetchThread(id, deps);
|
|
693
|
+
if (driver === "openai" || driver === "grok" || driver === "gemini") {
|
|
694
|
+
return stepDeps.providerDriverFn(driver, prompt, model);
|
|
281
695
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
: "
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
696
|
+
// No driver specified — mirror runYamlRecipe fallback logic:
|
|
697
|
+
// prefer API if key is set, otherwise probe for claude CLI.
|
|
698
|
+
const usingDefaultClaudeFn = runnerDeps.claudeFn === undefined;
|
|
699
|
+
if (!process.env.ANTHROPIC_API_KEY && usingDefaultClaudeFn) {
|
|
700
|
+
const probe = spawnSync("claude", ["--version"], {
|
|
701
|
+
encoding: "utf-8",
|
|
702
|
+
timeout: 5000,
|
|
703
|
+
});
|
|
704
|
+
if (!probe.error) {
|
|
705
|
+
return claudeCodeFn(prompt);
|
|
706
|
+
}
|
|
291
707
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
708
|
+
return stepDeps.claudeFn(prompt, model ?? "claude-haiku-4-5-20251001");
|
|
709
|
+
};
|
|
710
|
+
const loadNestedRecipe = async (name) => {
|
|
711
|
+
const { homedir } = await import("node:os");
|
|
712
|
+
const recipesDir = path.join(homedir(), ".patchwork", "recipes");
|
|
713
|
+
const candidates = [
|
|
714
|
+
path.join(recipesDir, `${name}.yaml`),
|
|
715
|
+
path.join(recipesDir, `${name}.yml`),
|
|
716
|
+
];
|
|
717
|
+
for (const p of candidates) {
|
|
718
|
+
try {
|
|
719
|
+
const raw = stepDeps.readFile(p);
|
|
720
|
+
const { parse } = await import("yaml");
|
|
721
|
+
const parsed = parse(raw);
|
|
722
|
+
if (parsed?.steps)
|
|
723
|
+
return parsed;
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// try next candidate
|
|
727
|
+
}
|
|
299
728
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
/** Minimal `{{ expr }}` renderer — replaces against flat context map. */
|
|
306
|
-
export function render(template, ctx) {
|
|
307
|
-
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, expr) => {
|
|
308
|
-
const key = expr.trim();
|
|
309
|
-
return Object.hasOwn(ctx, key) ? (ctx[key] ?? "") : "";
|
|
310
|
-
});
|
|
729
|
+
return null;
|
|
730
|
+
};
|
|
731
|
+
return { executeTool, executeAgent, loadNestedRecipe };
|
|
311
732
|
}
|
|
312
733
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
734
|
+
* Dispatch a loaded recipe to the appropriate runner.
|
|
735
|
+
*
|
|
736
|
+
* Recipes with `trigger.type: "chained"` are routed to the ChainedRecipeRunner
|
|
737
|
+
* (parallel execution, template variables, nested recipes, dry-run).
|
|
738
|
+
* All other recipes use the existing synchronous yamlRunner path.
|
|
739
|
+
*
|
|
740
|
+
* `chainedDeps` is only required when the recipe is chained; omit for simple recipes.
|
|
316
741
|
*/
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return l < r;
|
|
340
|
-
case ">=":
|
|
341
|
-
return l >= r;
|
|
342
|
-
case "<=":
|
|
343
|
-
return l <= r;
|
|
344
|
-
case "==":
|
|
345
|
-
return l === r;
|
|
346
|
-
case "!=":
|
|
347
|
-
return l !== r;
|
|
348
|
-
default:
|
|
349
|
-
return true;
|
|
742
|
+
export async function dispatchRecipe(recipe, deps, seedContext = {}) {
|
|
743
|
+
const triggerType = recipe.trigger
|
|
744
|
+
?.type;
|
|
745
|
+
if (triggerType === "chained") {
|
|
746
|
+
const { runChainedRecipe } = await import("./chainedRunner.js");
|
|
747
|
+
const chainedRecipe = recipe;
|
|
748
|
+
const now = deps.now ? deps.now() : new Date();
|
|
749
|
+
const options = {
|
|
750
|
+
env: {
|
|
751
|
+
...process.env,
|
|
752
|
+
DATE: now.toISOString().slice(0, 10),
|
|
753
|
+
TIME: now.toTimeString().slice(0, 5),
|
|
754
|
+
...seedContext,
|
|
755
|
+
},
|
|
756
|
+
maxConcurrency: chainedRecipe.maxConcurrency ?? 4,
|
|
757
|
+
maxDepth: chainedRecipe.maxDepth ?? 3,
|
|
758
|
+
dryRun: deps.chainedOptions?.dryRun ?? false,
|
|
759
|
+
onStepStart: deps.chainedOptions?.onStepStart,
|
|
760
|
+
onStepComplete: deps.chainedOptions?.onStepComplete,
|
|
761
|
+
};
|
|
762
|
+
if (!deps.chainedDeps) {
|
|
763
|
+
throw new Error("chainedDeps required for chained recipes (provide executeTool, executeAgent, loadNestedRecipe)");
|
|
350
764
|
}
|
|
765
|
+
return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
|
|
351
766
|
}
|
|
352
|
-
|
|
353
|
-
return true;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
function sinceToGmailQuery(since) {
|
|
357
|
-
// "24h" → "1d", "7d" → "7d", "1h" → "1d" (round up)
|
|
358
|
-
const m = /^(\d+)(h|d)$/.exec(since.trim().toLowerCase());
|
|
359
|
-
if (!m)
|
|
360
|
-
return "1d";
|
|
361
|
-
const [, num, unit] = m;
|
|
362
|
-
if (unit === "d")
|
|
363
|
-
return `${num}d`;
|
|
364
|
-
// hours → round up to days (min 1d)
|
|
365
|
-
const days = Math.max(1, Math.ceil(Number(num) / 24));
|
|
366
|
-
return `${days}d`;
|
|
367
|
-
}
|
|
368
|
-
function getHeader(headers, name) {
|
|
369
|
-
return (headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ??
|
|
370
|
-
"");
|
|
371
|
-
}
|
|
372
|
-
async function gmailSearch(query, max, deps) {
|
|
373
|
-
const errorResult = (msg) => JSON.stringify({ count: 0, messages: [], error: msg });
|
|
374
|
-
let token;
|
|
375
|
-
try {
|
|
376
|
-
token = await deps.getGmailToken();
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
return errorResult("Gmail not connected");
|
|
380
|
-
}
|
|
381
|
-
try {
|
|
382
|
-
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${max}`;
|
|
383
|
-
const listRes = await deps.fetchFn(listUrl, {
|
|
384
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
385
|
-
});
|
|
386
|
-
if (!listRes.ok)
|
|
387
|
-
return errorResult("Gmail API error");
|
|
388
|
-
const listJson = (await listRes.json());
|
|
389
|
-
const ids = listJson.messages ?? [];
|
|
390
|
-
const messages = await Promise.all(ids.slice(0, max).map(async (m) => {
|
|
391
|
-
const detailUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${m.id}?format=metadata&metadataHeaders=Subject,From,Date`;
|
|
392
|
-
const detailRes = await deps.fetchFn(detailUrl, {
|
|
393
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
394
|
-
});
|
|
395
|
-
if (!detailRes.ok)
|
|
396
|
-
return { id: m.id, subject: "", from: "", date: "", snippet: "" };
|
|
397
|
-
const detail = (await detailRes.json());
|
|
398
|
-
const hdrs = detail.payload?.headers ?? [];
|
|
399
|
-
return {
|
|
400
|
-
id: detail.id,
|
|
401
|
-
subject: getHeader(hdrs, "Subject"),
|
|
402
|
-
from: getHeader(hdrs, "From"),
|
|
403
|
-
date: getHeader(hdrs, "Date"),
|
|
404
|
-
snippet: detail.snippet ?? "",
|
|
405
|
-
};
|
|
406
|
-
}));
|
|
407
|
-
const result = { count: messages.length, messages };
|
|
408
|
-
return JSON.stringify(result);
|
|
409
|
-
}
|
|
410
|
-
catch {
|
|
411
|
-
return errorResult("Gmail fetch failed");
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
async function gmailFetchThread(id, deps) {
|
|
415
|
-
const errorResult = (msg) => JSON.stringify({ subject: "", messages: [], error: msg });
|
|
416
|
-
let token;
|
|
417
|
-
try {
|
|
418
|
-
token = await deps.getGmailToken();
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
return errorResult("Gmail not connected");
|
|
422
|
-
}
|
|
423
|
-
try {
|
|
424
|
-
const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${id}?format=metadata&metadataHeaders=Subject,From,Date`;
|
|
425
|
-
const res = await deps.fetchFn(url, {
|
|
426
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
427
|
-
});
|
|
428
|
-
if (!res.ok)
|
|
429
|
-
return errorResult("Gmail API error");
|
|
430
|
-
const thread = (await res.json());
|
|
431
|
-
const msgs = thread.messages ?? [];
|
|
432
|
-
const firstHdrs = msgs[0]?.payload?.headers ?? [];
|
|
433
|
-
const subject = getHeader(firstHdrs, "Subject");
|
|
434
|
-
const messages = msgs.map((m) => {
|
|
435
|
-
const hdrs = m.payload?.headers ?? [];
|
|
436
|
-
return {
|
|
437
|
-
from: getHeader(hdrs, "From"),
|
|
438
|
-
date: getHeader(hdrs, "Date"),
|
|
439
|
-
body_snippet: m.snippet ?? "",
|
|
440
|
-
};
|
|
441
|
-
});
|
|
442
|
-
const result = { subject, messages };
|
|
443
|
-
return JSON.stringify(result);
|
|
444
|
-
}
|
|
445
|
-
catch {
|
|
446
|
-
return errorResult("Gmail fetch failed");
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
function expandHome(p) {
|
|
450
|
-
if (p.startsWith("~/"))
|
|
451
|
-
return path.join(os.homedir(), p.slice(2));
|
|
452
|
-
return p;
|
|
453
|
-
}
|
|
454
|
-
function parseSinceToGitArg(since) {
|
|
455
|
-
const m = /^(\d+)(h|d)$/i.exec(since.trim());
|
|
456
|
-
if (!m)
|
|
457
|
-
return since;
|
|
458
|
-
const [, num, unit = "h"] = m;
|
|
459
|
-
return unit.toLowerCase() === "h" ? `${num} hours ago` : `${num} days ago`;
|
|
460
|
-
}
|
|
461
|
-
function defaultGitLogSince(since, workdir) {
|
|
462
|
-
try {
|
|
463
|
-
const sinceArg = parseSinceToGitArg(since);
|
|
464
|
-
const result = spawnSync("git", ["log", "--oneline", `--since=${sinceArg}`], {
|
|
465
|
-
cwd: workdir ?? process.cwd(),
|
|
466
|
-
encoding: "utf-8",
|
|
467
|
-
timeout: 5000,
|
|
468
|
-
});
|
|
469
|
-
if (result.error || result.status !== 0)
|
|
470
|
-
return "(git log unavailable)";
|
|
471
|
-
return (result.stdout ?? "").trim();
|
|
472
|
-
}
|
|
473
|
-
catch {
|
|
474
|
-
return "(git log unavailable)";
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
function defaultGitStaleBranches(days, workdir) {
|
|
478
|
-
try {
|
|
479
|
-
const cutoff = new Date(Date.now() - days * 86_400_000)
|
|
480
|
-
.toISOString()
|
|
481
|
-
.slice(0, 10);
|
|
482
|
-
const r = spawnSync("git", ["branch", "--format=%(refname:short) %(committerdate:short)"], {
|
|
483
|
-
cwd: workdir ?? process.cwd(),
|
|
484
|
-
encoding: "utf-8",
|
|
485
|
-
timeout: 5000,
|
|
486
|
-
});
|
|
487
|
-
const branches = r.error || r.status !== 0 ? "" : (r.stdout ?? "").trim();
|
|
488
|
-
if (!branches)
|
|
489
|
-
return "(no local branches)";
|
|
490
|
-
return (branches
|
|
491
|
-
.split("\n")
|
|
492
|
-
.filter((line) => {
|
|
493
|
-
const parts = line.trim().split(/\s+/);
|
|
494
|
-
const dateStr = parts[1];
|
|
495
|
-
return dateStr && dateStr < cutoff;
|
|
496
|
-
})
|
|
497
|
-
.join("\n") || "(none older than 30 days)");
|
|
498
|
-
}
|
|
499
|
-
catch {
|
|
500
|
-
return "(git unavailable)";
|
|
501
|
-
}
|
|
767
|
+
return runYamlRecipe(recipe, deps, seedContext);
|
|
502
768
|
}
|
|
503
769
|
/** List all YAML recipes in a directory. Returns names. */
|
|
504
770
|
export function listYamlRecipes(recipesDir) {
|