patchwork-os 0.2.0-alpha.30 → 0.2.0-alpha.32

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/dist/bridge.d.ts CHANGED
@@ -39,6 +39,7 @@ export declare class Bridge {
39
39
  private pluginTools;
40
40
  private pluginWatcher;
41
41
  private automationHooks;
42
+ private recipeOrchestration;
42
43
  private recipeScheduler;
43
44
  private recipeRunLog;
44
45
  private recipeOrchestrator;
@@ -80,12 +81,6 @@ export declare class Bridge {
80
81
  /** Returns the auth token for this bridge instance. */
81
82
  getAuthToken(): string;
82
83
  start(): Promise<void>;
83
- /**
84
- * Load and fire a YAML recipe in the background via the orchestrator.
85
- * Returns `{ ok, taskId, name? }` immediately; execution continues async.
86
- * Both the webhook path and runRecipeFn use this to eliminate duplication.
87
- */
88
- private _fireYamlRecipe;
89
84
  /** Start the bridge-level WebSocket keepalive heartbeat. Idempotent. */
90
85
  private _startWsHeartbeat;
91
86
  stop(): Promise<void>;
package/dist/bridge.js CHANGED
@@ -3,7 +3,6 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { WebSocket } from "ws";
6
- import { recordRecipeRun } from "./activationMetrics.js";
7
6
  import { ActivityLog } from "./activityLog.js";
8
7
  import { buildSummary } from "./analyticsAggregator.js";
9
8
  import { getAnalyticsPref } from "./analyticsPrefs.js";
@@ -22,13 +21,12 @@ import { buildEnforcementReminder } from "./instructionsUtils.js";
22
21
  import { LockFileManager } from "./lockfile.js";
23
22
  import { Logger } from "./logger.js";
24
23
  import { OAuthServerImpl } from "./oauth.js";
25
- import { loadConfig as loadPatchworkConfig, saveConfig as savePatchworkConfig, } from "./patchworkConfig.js";
24
+ import { loadConfig as loadPatchworkConfig } from "./patchworkConfig.js";
26
25
  import { loadPlugins, loadPluginsFull } from "./pluginLoader.js";
27
26
  import { PluginWatcher } from "./pluginWatcher.js";
28
27
  import { probeAll } from "./probe.js";
28
+ import { RecipeOrchestration } from "./recipeOrchestration.js";
29
29
  import { RecipeOrchestrator } from "./recipes/RecipeOrchestrator.js";
30
- import { RecipeScheduler } from "./recipes/scheduler.js";
31
- import { findWebhookRecipe, findYamlRecipePath, listInstalledRecipes, loadRecipeContent, loadRecipePrompt, renderWebhookPrompt, saveRecipe, saveRecipeContent, } from "./recipesHttp.js";
32
30
  import { classifyTool } from "./riskTier.js";
33
31
  import { RecipeRunLog } from "./runLog.js";
34
32
  import { Server } from "./server.js";
@@ -115,6 +113,7 @@ export class Bridge {
115
113
  pluginTools = [];
116
114
  pluginWatcher = null;
117
115
  automationHooks = undefined;
116
+ recipeOrchestration = null;
118
117
  recipeScheduler = null;
119
118
  recipeRunLog = null;
120
119
  recipeOrchestrator = null;
@@ -786,17 +785,21 @@ export class Bridge {
786
785
  this.recipeOrchestrator = new RecipeOrchestrator({
787
786
  workdir: this.config.workspace,
788
787
  });
789
- // Patchwork: start cron-trigger scheduler once the orchestrator exists.
788
+ // Patchwork: wire recipe server fns + build cron scheduler.
790
789
  const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
791
- this.recipeScheduler = new RecipeScheduler({
790
+ this.recipeOrchestration = new RecipeOrchestration({
791
+ server: this.server,
792
+ getOrchestrator: () => this.orchestrator,
793
+ recipeOrchestrator: this.recipeOrchestrator,
794
+ recipeRunLog: this.recipeRunLog,
795
+ workdir: this.config.workspace,
796
+ logger: this.logger,
797
+ });
798
+ this.recipeOrchestration.wireServerFns();
799
+ this.recipeScheduler = RecipeOrchestration.buildScheduler({
792
800
  recipesDir,
801
+ runRecipeFn: async (name) => this.server.runRecipeFn?.(name),
793
802
  enqueue: (opts) => this.orchestrator?.enqueue(opts) ?? "",
794
- runYaml: async (name) => {
795
- const result = await this.server.runRecipeFn?.(name);
796
- if (result && !result.ok) {
797
- throw new Error(result.error ?? "unknown error");
798
- }
799
- },
800
803
  logger: this.logger,
801
804
  });
802
805
  // scheduler.start() deferred to after this.port is set (see below)
@@ -1027,217 +1030,6 @@ export class Bridge {
1027
1030
  this.server.approvalWebhookUrl =
1028
1031
  this.config.approvalWebhookUrl ?? undefined;
1029
1032
  this.server.onApprovalDecision = (event, meta) => this.activityLog.recordEvent(event, meta);
1030
- this.server.recipesFn = () => {
1031
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1032
- return listInstalledRecipes(recipesDir);
1033
- };
1034
- this.server.loadRecipeContentFn = (name) => {
1035
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1036
- return loadRecipeContent(recipesDir, name);
1037
- };
1038
- this.server.saveRecipeContentFn = (name, content) => {
1039
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1040
- return saveRecipeContent(recipesDir, name, content);
1041
- };
1042
- this.server.saveRecipeFn = (draft) => {
1043
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1044
- return saveRecipe(recipesDir, draft);
1045
- };
1046
- this.server.setRecipeEnabledFn = (name, enabled) => {
1047
- try {
1048
- const cfg = loadPatchworkConfig();
1049
- const disabled = new Set(cfg.recipes?.disabled ??
1050
- []);
1051
- if (enabled)
1052
- disabled.delete(name);
1053
- else
1054
- disabled.add(name);
1055
- savePatchworkConfig({
1056
- ...cfg,
1057
- recipes: {
1058
- ...cfg.recipes,
1059
- disabled: [...disabled],
1060
- },
1061
- });
1062
- return { ok: true };
1063
- }
1064
- catch (err) {
1065
- return {
1066
- ok: false,
1067
- error: err instanceof Error ? err.message : String(err),
1068
- };
1069
- }
1070
- };
1071
- this.server.runsFn = (q) => {
1072
- if (!this.recipeRunLog)
1073
- return [];
1074
- return this.recipeRunLog.query({
1075
- ...(q.limit !== undefined && { limit: q.limit }),
1076
- ...(q.trigger !== undefined && {
1077
- trigger: q.trigger,
1078
- }),
1079
- ...(q.status !== undefined && {
1080
- status: q.status,
1081
- }),
1082
- ...(q.recipe !== undefined && { recipe: q.recipe }),
1083
- ...(q.after !== undefined && { after: q.after }),
1084
- });
1085
- };
1086
- this.server.runDetailFn = (seq) => {
1087
- if (!this.recipeRunLog)
1088
- return null;
1089
- return this.recipeRunLog.getBySeq(seq);
1090
- };
1091
- this.server.runPlanFn = async (recipeName) => {
1092
- const { runRecipeDryPlan } = await import("./commands/recipe.js");
1093
- return (await runRecipeDryPlan(recipeName));
1094
- };
1095
- this.server.sessionsFn = () => [...this.sessions.values()].map((s) => {
1096
- const tools = this.activityLog.querySessionTools(s.id, 1);
1097
- return {
1098
- id: s.id,
1099
- connectedAt: new Date(s.connectedAt).toISOString(),
1100
- openedFileCount: s.openedFiles.size,
1101
- pendingApprovals: getApprovalQueue()
1102
- .list()
1103
- .filter((a) => a.sessionId === s.id).length,
1104
- firstTool: tools[0]?.tool,
1105
- remoteAddr: s.remoteAddr,
1106
- };
1107
- });
1108
- this.server.sessionDetailFn = (id) => {
1109
- const s = this.sessions.get(id);
1110
- const summary = s
1111
- ? {
1112
- id: s.id,
1113
- connectedAt: new Date(s.connectedAt).toISOString(),
1114
- openedFileCount: s.openedFiles.size,
1115
- pendingApprovals: getApprovalQueue()
1116
- .list()
1117
- .filter((a) => a.sessionId === s.id).length,
1118
- }
1119
- : null;
1120
- const lifecycle = this.activityLog.querySessionLifecycle(id, 100);
1121
- const tools = this.activityLog.querySessionTools(id, 100);
1122
- const decisions = this.decisionTraceLog?.query({ sessionId: id, limit: 50 }) ?? [];
1123
- const approvals = getApprovalQueue()
1124
- .list()
1125
- .filter((a) => a.sessionId === id);
1126
- return {
1127
- summary,
1128
- lifecycle: lifecycle,
1129
- tools: tools,
1130
- decisions: decisions,
1131
- approvals: approvals,
1132
- };
1133
- };
1134
- this.server.webhookFn = async (hookPath, payload) => {
1135
- if (!this.orchestrator) {
1136
- return {
1137
- ok: false,
1138
- error: "orchestrator_unavailable",
1139
- };
1140
- }
1141
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1142
- const match = findWebhookRecipe(recipesDir, hookPath);
1143
- if (!match) {
1144
- return { ok: false, error: "not_found" };
1145
- }
1146
- if (match.format === "yaml") {
1147
- let payloadText;
1148
- if (payload !== undefined) {
1149
- try {
1150
- payloadText = JSON.stringify(payload);
1151
- }
1152
- catch {
1153
- payloadText = String(payload);
1154
- }
1155
- if (payloadText.length > 8_000) {
1156
- payloadText = `${payloadText.slice(0, 8_000)}\n…[truncated]`;
1157
- }
1158
- }
1159
- const seedContext = {
1160
- hook_path: hookPath,
1161
- webhook_path: hookPath,
1162
- ...(payloadText !== undefined
1163
- ? { payload: payloadText, webhook_payload: payloadText }
1164
- : {}),
1165
- };
1166
- return this._fireYamlRecipe({
1167
- filePath: match.filePath,
1168
- name: match.name,
1169
- taskIdPrefix: `yaml-webhook-${match.name}`,
1170
- triggerSourceSuffix: `webhook:${match.name}`,
1171
- logLabel: `webhook "${match.name}"`,
1172
- seedContext,
1173
- });
1174
- }
1175
- const loaded = loadRecipePrompt(recipesDir, path.basename(match.filePath, path.extname(match.filePath)));
1176
- if (!loaded) {
1177
- return { ok: false, error: "recipe_file_missing" };
1178
- }
1179
- try {
1180
- const taskId = this.orchestrator.enqueue({
1181
- prompt: renderWebhookPrompt(loaded.prompt, payload),
1182
- triggerSource: `webhook:${match.name}`,
1183
- });
1184
- return { ok: true, taskId, name: match.name };
1185
- }
1186
- catch (err) {
1187
- return {
1188
- ok: false,
1189
- error: err instanceof Error ? err.message : String(err),
1190
- };
1191
- }
1192
- };
1193
- this.server.runRecipeFn = async (name, vars) => {
1194
- if (!this.orchestrator) {
1195
- return {
1196
- ok: false,
1197
- error: "Orchestrator unavailable — start bridge with --claude-driver subprocess",
1198
- };
1199
- }
1200
- const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
1201
- // Try JSON recipe first (legacy path: enqueue prompt as a task).
1202
- const loaded = loadRecipePrompt(recipesDir, name);
1203
- if (loaded) {
1204
- try {
1205
- let prompt = loaded.prompt;
1206
- if (vars && Object.keys(vars).length > 0) {
1207
- const varLines = Object.entries(vars)
1208
- .map(([k, v]) => `${k}=${v}`)
1209
- .join("\n");
1210
- prompt = `Variables:\n${varLines}\n\n${prompt}`;
1211
- }
1212
- const taskId = this.orchestrator.enqueue({
1213
- prompt,
1214
- triggerSource: `recipe:${name}`,
1215
- });
1216
- return { ok: true, taskId };
1217
- }
1218
- catch (err) {
1219
- return {
1220
- ok: false,
1221
- error: err instanceof Error ? err.message : String(err),
1222
- };
1223
- }
1224
- }
1225
- // Fall through to YAML runner for .yaml/.yml recipes.
1226
- const ymlPath = findYamlRecipePath(recipesDir, name);
1227
- if (!ymlPath) {
1228
- return {
1229
- ok: false,
1230
- error: `Recipe "${name}" not found in ${recipesDir}`,
1231
- };
1232
- }
1233
- return this._fireYamlRecipe({
1234
- filePath: ymlPath,
1235
- name,
1236
- taskIdPrefix: `yaml-recipe-${name}`,
1237
- triggerSourceSuffix: `recipe:${name}`,
1238
- logLabel: `"${name}"`,
1239
- });
1240
- };
1241
1033
  this.server.readyFn = () => {
1242
1034
  // Count tools from the first active session (all sessions share the same tool set)
1243
1035
  const anySession = [...this.sessions.values()][0];
@@ -1525,63 +1317,6 @@ export class Bridge {
1525
1317
  })}`);
1526
1318
  }
1527
1319
  }
1528
- /**
1529
- * Load and fire a YAML recipe in the background via the orchestrator.
1530
- * Returns `{ ok, taskId, name? }` immediately; execution continues async.
1531
- * Both the webhook path and runRecipeFn use this to eliminate duplication.
1532
- */
1533
- async _fireYamlRecipe(opts) {
1534
- if (!this.recipeOrchestrator) {
1535
- return { ok: false, error: "recipe orchestrator unavailable" };
1536
- }
1537
- const orch = this.orchestrator;
1538
- const { buildChainedDeps, dispatchRecipe } = await import("./recipes/yamlRunner.js");
1539
- const claudeCodeFn = async (prompt) => {
1540
- const task = await orch.runAndWait({
1541
- prompt,
1542
- triggerSource: `${opts.triggerSourceSuffix}:agent`,
1543
- timeoutMs: 600_000,
1544
- });
1545
- return task.output ?? task.errorMessage ?? "";
1546
- };
1547
- const runnerDeps = { workdir: this.config.workspace, claudeCodeFn };
1548
- const chainedOptions = {
1549
- sourcePath: opts.filePath,
1550
- runLogDir: this.recipeRunLog
1551
- ? path.join(os.homedir(), ".patchwork")
1552
- : undefined,
1553
- };
1554
- const fireResult = await this.recipeOrchestrator
1555
- .fire({
1556
- filePath: opts.filePath,
1557
- name: opts.name,
1558
- triggerSource: opts.triggerSourceSuffix,
1559
- seedContext: opts.seedContext,
1560
- dispatchFn: async (recipe, _deps, seedContext) => {
1561
- const result = await dispatchRecipe(recipe, {
1562
- ...runnerDeps,
1563
- chainedDeps: buildChainedDeps(runnerDeps, claudeCodeFn),
1564
- chainedOptions,
1565
- }, seedContext);
1566
- const steps = "stepsRun" in result
1567
- ? result.stepsRun
1568
- : (result.summary?.total ?? "?");
1569
- const succeeded = "stepsRun" in result ? !result.errorMessage : result.success;
1570
- if (succeeded)
1571
- recordRecipeRun();
1572
- this.logger.info?.(`[recipe] ${opts.logLabel} finished: ${steps} steps`);
1573
- return result;
1574
- },
1575
- })
1576
- .catch((err) => {
1577
- this.logger.warn?.(`[recipe] ${opts.logLabel} error: ${err instanceof Error ? err.message : String(err)}`);
1578
- return {
1579
- ok: false,
1580
- error: err instanceof Error ? err.message : String(err),
1581
- };
1582
- });
1583
- return fireResult;
1584
- }
1585
1320
  /** Start the bridge-level WebSocket keepalive heartbeat. Idempotent. */
1586
1321
  _startWsHeartbeat() {
1587
1322
  if (this.wsHeartbeatInterval || this.config.wsPingIntervalMs === 0)