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 +1 -6
- package/dist/bridge.js +15 -280
- package/dist/bridge.js.map +1 -1
- package/dist/recipeOrchestration.d.ts +53 -0
- package/dist/recipeOrchestration.js +272 -0
- package/dist/recipeOrchestration.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +98 -0
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
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
|
|
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:
|
|
788
|
+
// Patchwork: wire recipe server fns + build cron scheduler.
|
|
790
789
|
const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
|
|
791
|
-
this.
|
|
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)
|