@webgrow/skillhub 0.1.0
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.md +114 -0
- package/bridge/.env.example +14 -0
- package/bridge/README.md +74 -0
- package/bridge/adapters.mjs +209 -0
- package/bridge/convex-functions.mjs +13 -0
- package/bridge/doctor.mjs +448 -0
- package/bridge/harness.mjs +217 -0
- package/convex/_generated/ai/ai-files.state.json +6 -0
- package/convex/_generated/ai/guidelines.md +368 -0
- package/convex/_generated/api.d.ts +59 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/bridge.ts +709 -0
- package/convex/convex.config.ts +8 -0
- package/convex/http.ts +37 -0
- package/convex/loops.ts +546 -0
- package/convex/runs.ts +183 -0
- package/convex/schema.ts +220 -0
- package/convex/skills.ts +413 -0
- package/convex/tsconfig.json +25 -0
- package/dashboard/.env.example +1 -0
- package/dashboard/index.html +12 -0
- package/dashboard/src/App.jsx +1743 -0
- package/dashboard/src/convexRefs.js +32 -0
- package/dashboard/src/main.jsx +46 -0
- package/dashboard/src/styles.css +982 -0
- package/dashboard/tsconfig.json +17 -0
- package/dashboard/vite.config.mjs +23 -0
- package/package.json +48 -0
- package/src/bridge-command.mjs +101 -0
- package/src/cli.mjs +246 -0
- package/src/cloud-catalog.mjs +588 -0
- package/src/config.mjs +150 -0
- package/src/connect.mjs +410 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { access, stat } from "node:fs/promises";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
6
|
+
import { makeFunctionReference } from "convex/server";
|
|
7
|
+
import dotenv from "dotenv";
|
|
8
|
+
import { getCodexCliSettings, redactArgs } from "./adapters.mjs";
|
|
9
|
+
import { getRuntimeConfig } from "../src/config.mjs";
|
|
10
|
+
|
|
11
|
+
dotenv.config({ path: ".env", quiet: true });
|
|
12
|
+
dotenv.config({ path: "bridge/.env.local", quiet: true });
|
|
13
|
+
|
|
14
|
+
const checks = [];
|
|
15
|
+
const options = parseArgs(process.argv.slice(2));
|
|
16
|
+
const runtimeConfig = getRuntimeConfig();
|
|
17
|
+
const mode = options.mode || runtimeConfig.bridgeMode || process.env.SKILLHUB_HARNESS_MODE || "dry-run";
|
|
18
|
+
const convexUrl = options.convexUrl || runtimeConfig.convexUrl || process.env.CONVEX_URL;
|
|
19
|
+
|
|
20
|
+
await main().catch((error) => {
|
|
21
|
+
fail("doctor", error instanceof Error ? error.message : String(error));
|
|
22
|
+
reportAndExit();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
pass("mode", mode);
|
|
27
|
+
|
|
28
|
+
if (!["dry-run", "codex-cli"].includes(mode)) {
|
|
29
|
+
fail("SKILLHUB_HARNESS_MODE", `Unsupported mode: ${mode}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await checkConvex();
|
|
33
|
+
checkCapabilities();
|
|
34
|
+
|
|
35
|
+
if (mode !== "codex-cli") {
|
|
36
|
+
warn(
|
|
37
|
+
"codex-cli",
|
|
38
|
+
"Skipped because the harness mode is dry-run. Use --mode codex-cli to check real Codex execution.",
|
|
39
|
+
);
|
|
40
|
+
return reportAndExit();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const settings = checkCodexSettings();
|
|
44
|
+
if (settings) {
|
|
45
|
+
await checkWorkdir(settings);
|
|
46
|
+
await checkCodexCommand(settings);
|
|
47
|
+
await maybeRunCodexSmoke(settings);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return reportAndExit();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function checkConvex() {
|
|
54
|
+
if (options.skipConvex) {
|
|
55
|
+
skip("CONVEX_URL", "Convex check skipped by --skip-convex.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!convexUrl) {
|
|
60
|
+
fail("CONVEX_URL", "Missing Convex deployment URL.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pass("CONVEX_URL", redactUrl(convexUrl));
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const client = new ConvexHttpClient(convexUrl);
|
|
68
|
+
if (process.env.CONVEX_AUTH_TOKEN) {
|
|
69
|
+
client.setAuth(process.env.CONVEX_AUTH_TOKEN);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const listActive = makeFunctionReference("runs:listActive");
|
|
73
|
+
const runs = await client.query(listActive, { limit: 1 });
|
|
74
|
+
pass("Convex connectivity", `runs.listActive returned ${runs.length} row(s).`);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
fail(
|
|
77
|
+
"Convex connectivity",
|
|
78
|
+
error instanceof Error ? error.message : String(error),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkCapabilities() {
|
|
84
|
+
const capabilities = parseCapabilities(process.env.SKILLHUB_CAPABILITIES);
|
|
85
|
+
if (!capabilities.length) {
|
|
86
|
+
warn(
|
|
87
|
+
"SKILLHUB_CAPABILITIES",
|
|
88
|
+
"Not set; harness will use its default capabilities.",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pass("SKILLHUB_CAPABILITIES", capabilities.join(", "));
|
|
94
|
+
|
|
95
|
+
if (!capabilities.includes("*") && !capabilities.includes("codex.cli")) {
|
|
96
|
+
warn(
|
|
97
|
+
"codex.cli capability",
|
|
98
|
+
"Worker will not claim loop steps unless codex.cli or * is advertised.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkCodexSettings() {
|
|
104
|
+
let settings;
|
|
105
|
+
try {
|
|
106
|
+
settings = getCodexCliSettings();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
fail("CODEX_ARGS_JSON", error instanceof Error ? error.message : String(error));
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pass("CODEX_COMMAND", settings.command);
|
|
113
|
+
pass("CODEX_WORKDIR", settings.cwd);
|
|
114
|
+
|
|
115
|
+
if (!Number.isFinite(settings.timeoutMs) || settings.timeoutMs <= 0) {
|
|
116
|
+
fail("CODEX_TIMEOUT_MS", "Must be a positive number of milliseconds.");
|
|
117
|
+
} else {
|
|
118
|
+
pass("CODEX_TIMEOUT_MS", `${settings.timeoutMs} ms`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!settings.args.length) {
|
|
122
|
+
fail(
|
|
123
|
+
"CODEX_ARGS_JSON",
|
|
124
|
+
'Missing base args. Recommended: ["--ask-for-approval","never","exec","--sandbox","workspace-write","--json"]',
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
pass("CODEX_ARGS_JSON", JSON.stringify(redactArgs(settings.args)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!settings.args.includes("exec")) {
|
|
131
|
+
fail("codex exec", "CODEX_ARGS_JSON must include the exec subcommand.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!settings.args.includes("--json")) {
|
|
135
|
+
warn("codex --json", "Recommended so harness logs can parse event streams later.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!hasFlagValue(settings.args, "--ask-for-approval", "never")) {
|
|
139
|
+
warn(
|
|
140
|
+
"approval policy",
|
|
141
|
+
"Recommended: --ask-for-approval never for non-interactive workers.",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!settings.args.includes("--sandbox")) {
|
|
146
|
+
warn(
|
|
147
|
+
"sandbox",
|
|
148
|
+
"No explicit --sandbox flag. Codex exec defaults may be read-only.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (settings.passPromptOnStdin && !settings.args.includes("-")) {
|
|
153
|
+
warn(
|
|
154
|
+
"CODEX_STDIN_PROMPT",
|
|
155
|
+
"When true, CODEX_ARGS_JSON should usually include '-' for codex exec stdin prompts.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return settings;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function checkWorkdir(settings) {
|
|
163
|
+
try {
|
|
164
|
+
const info = await stat(settings.cwd);
|
|
165
|
+
if (!info.isDirectory()) {
|
|
166
|
+
fail("CODEX_WORKDIR", "Path exists but is not a directory.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await access(settings.cwd);
|
|
170
|
+
pass("CODEX_WORKDIR exists", settings.cwd);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
fail("CODEX_WORKDIR exists", error instanceof Error ? error.message : String(error));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const skipGitCheck = settings.args.includes("--skip-git-repo-check");
|
|
177
|
+
const git = await spawnCapture({
|
|
178
|
+
command: "git",
|
|
179
|
+
args: ["-C", settings.cwd, "rev-parse", "--is-inside-work-tree"],
|
|
180
|
+
timeoutMs: 10_000,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (git.code === 0 && git.stdout.trim() === "true") {
|
|
184
|
+
pass("git repository", "Workdir is inside a git repository.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (skipGitCheck) {
|
|
189
|
+
warn(
|
|
190
|
+
"git repository",
|
|
191
|
+
"Workdir did not verify as git, but --skip-git-repo-check is configured.",
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
fail(
|
|
195
|
+
"git repository",
|
|
196
|
+
"Codex exec requires a git repository unless --skip-git-repo-check is set.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function checkCodexCommand(settings) {
|
|
202
|
+
const version = await spawnCapture({
|
|
203
|
+
command: settings.command,
|
|
204
|
+
args: ["--version"],
|
|
205
|
+
cwd: settings.cwd,
|
|
206
|
+
timeoutMs: 20_000,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (version.error) {
|
|
210
|
+
fail("Codex command", version.error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (version.code === 0) {
|
|
215
|
+
pass("Codex command", `${settings.command} ${version.stdout.trim()}`.trim());
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fail(
|
|
220
|
+
"Codex command",
|
|
221
|
+
formatCommandFailure(version, `${settings.command} --version`),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function maybeRunCodexSmoke(settings) {
|
|
226
|
+
if (!options.codexSmoke) {
|
|
227
|
+
warn(
|
|
228
|
+
"Codex smoke",
|
|
229
|
+
"Skipped. Run npm run harness:doctor -- --mode codex-cli --codex-smoke after the command check passes.",
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (hasFailures()) {
|
|
235
|
+
skip("Codex smoke", "Skipped because earlier checks failed.");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const prompt =
|
|
240
|
+
"SkillHub harness doctor smoke test. Reply in one short sentence that Codex exec is reachable. Do not edit files.";
|
|
241
|
+
const args = settings.passPromptOnStdin ? settings.args : [...settings.args, prompt];
|
|
242
|
+
const smoke = await spawnCapture({
|
|
243
|
+
command: settings.command,
|
|
244
|
+
args,
|
|
245
|
+
cwd: settings.cwd,
|
|
246
|
+
input: settings.passPromptOnStdin ? prompt : "",
|
|
247
|
+
timeoutMs: Math.min(settings.timeoutMs, options.timeoutMs || 120_000),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (smoke.error) {
|
|
251
|
+
fail("Codex smoke", smoke.error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (smoke.code === 0) {
|
|
256
|
+
pass("Codex smoke", tail(smoke.stdout || smoke.stderr || "completed"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fail("Codex smoke", formatCommandFailure(smoke, "codex exec smoke"));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseArgs(args) {
|
|
264
|
+
const parsed = {
|
|
265
|
+
codexSmoke: false,
|
|
266
|
+
convexUrl: "",
|
|
267
|
+
json: false,
|
|
268
|
+
mode: "",
|
|
269
|
+
skipConvex: false,
|
|
270
|
+
timeoutMs: 0,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
274
|
+
const arg = args[index];
|
|
275
|
+
if (arg === "--codex-smoke") parsed.codexSmoke = true;
|
|
276
|
+
else if (arg === "--convex-url") parsed.convexUrl = args[++index] ?? "";
|
|
277
|
+
else if (arg === "--json") parsed.json = true;
|
|
278
|
+
else if (arg === "--mode") parsed.mode = args[++index] ?? "";
|
|
279
|
+
else if (arg === "--skip-convex") parsed.skipConvex = true;
|
|
280
|
+
else if (arg === "--timeout-ms") parsed.timeoutMs = Number(args[++index] ?? 0);
|
|
281
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return parsed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseCapabilities(value) {
|
|
288
|
+
if (!value) return [];
|
|
289
|
+
return value
|
|
290
|
+
.split(",")
|
|
291
|
+
.map((item) => item.trim())
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hasFlagValue(args, flag, value) {
|
|
296
|
+
const index = args.indexOf(flag);
|
|
297
|
+
return index >= 0 && args[index + 1] === value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function pass(name, detail = "") {
|
|
301
|
+
checks.push({ status: "pass", name, detail });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function warn(name, detail = "") {
|
|
305
|
+
checks.push({ status: "warn", name, detail });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fail(name, detail = "") {
|
|
309
|
+
checks.push({ status: "fail", name, detail });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function skip(name, detail = "") {
|
|
313
|
+
checks.push({ status: "skip", name, detail });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hasFailures() {
|
|
317
|
+
return checks.some((check) => check.status === "fail");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function reportAndExit() {
|
|
321
|
+
const failures = checks.filter((check) => check.status === "fail").length;
|
|
322
|
+
const warnings = checks.filter((check) => check.status === "warn").length;
|
|
323
|
+
|
|
324
|
+
if (options.json) {
|
|
325
|
+
console.log(
|
|
326
|
+
JSON.stringify(
|
|
327
|
+
{
|
|
328
|
+
ok: failures === 0,
|
|
329
|
+
failures,
|
|
330
|
+
warnings,
|
|
331
|
+
mode,
|
|
332
|
+
checks,
|
|
333
|
+
},
|
|
334
|
+
null,
|
|
335
|
+
2,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
console.log("SkillHub harness doctor");
|
|
340
|
+
console.log(`Mode: ${mode}`);
|
|
341
|
+
console.log("");
|
|
342
|
+
for (const check of checks) {
|
|
343
|
+
const label = check.status.toUpperCase().padEnd(4, " ");
|
|
344
|
+
const detail = check.detail ? ` - ${check.detail}` : "";
|
|
345
|
+
console.log(`${label} ${check.name}${detail}`);
|
|
346
|
+
}
|
|
347
|
+
console.log("");
|
|
348
|
+
console.log(
|
|
349
|
+
failures === 0
|
|
350
|
+
? `Result: ready with ${warnings} warning(s).`
|
|
351
|
+
: `Result: blocked by ${failures} failure(s), ${warnings} warning(s).`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
process.exitCode = failures === 0 ? 0 : 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function spawnCapture({
|
|
359
|
+
command,
|
|
360
|
+
args,
|
|
361
|
+
cwd = process.cwd(),
|
|
362
|
+
env = process.env,
|
|
363
|
+
input = "",
|
|
364
|
+
timeoutMs = 30_000,
|
|
365
|
+
}) {
|
|
366
|
+
return await new Promise((resolve) => {
|
|
367
|
+
let child;
|
|
368
|
+
let settled = false;
|
|
369
|
+
const stdout = [];
|
|
370
|
+
const stderr = [];
|
|
371
|
+
|
|
372
|
+
const finish = (result) => {
|
|
373
|
+
if (settled) return;
|
|
374
|
+
settled = true;
|
|
375
|
+
resolve({
|
|
376
|
+
stdout: stdout.join(""),
|
|
377
|
+
stderr: stderr.join(""),
|
|
378
|
+
...result,
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
child = spawn(command, args, {
|
|
384
|
+
cwd,
|
|
385
|
+
env,
|
|
386
|
+
shell: false,
|
|
387
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
388
|
+
windowsHide: true,
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
finish({ code: null, signal: null, error: formatError(error) });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const timeout = setTimeout(() => {
|
|
396
|
+
child.kill("SIGTERM");
|
|
397
|
+
finish({
|
|
398
|
+
code: null,
|
|
399
|
+
signal: "SIGTERM",
|
|
400
|
+
error: `Timed out after ${timeoutMs} ms.`,
|
|
401
|
+
});
|
|
402
|
+
}, timeoutMs);
|
|
403
|
+
|
|
404
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk.toString()));
|
|
405
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk.toString()));
|
|
406
|
+
child.on("error", (error) => {
|
|
407
|
+
clearTimeout(timeout);
|
|
408
|
+
finish({ code: null, signal: null, error: formatError(error) });
|
|
409
|
+
});
|
|
410
|
+
child.on("close", (code, signal) => {
|
|
411
|
+
clearTimeout(timeout);
|
|
412
|
+
finish({ code, signal });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
child.stdin.end(input);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function formatCommandFailure(result, command) {
|
|
420
|
+
return [
|
|
421
|
+
`${command} failed`,
|
|
422
|
+
result.code !== null && result.code !== undefined ? `exit=${result.code}` : null,
|
|
423
|
+
result.signal ? `signal=${result.signal}` : null,
|
|
424
|
+
result.stderr ? `stderr=${tail(result.stderr)}` : null,
|
|
425
|
+
result.stdout && !result.stderr ? `stdout=${tail(result.stdout)}` : null,
|
|
426
|
+
]
|
|
427
|
+
.filter(Boolean)
|
|
428
|
+
.join("; ");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function formatError(error) {
|
|
432
|
+
if (typeof error === "string") return error;
|
|
433
|
+
if (!error) return "Unknown error";
|
|
434
|
+
const parts = [error.message ?? String(error)];
|
|
435
|
+
if (error.code) parts.push(`code=${error.code}`);
|
|
436
|
+
if (error.path) parts.push(`path=${error.path}`);
|
|
437
|
+
return parts.join("; ");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function tail(text) {
|
|
441
|
+
const normalized = text.replace(/\r/g, "").trim();
|
|
442
|
+
if (normalized.length <= 900) return normalized;
|
|
443
|
+
return `${normalized.slice(0, 320)}...${normalized.slice(-520)}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function redactUrl(value) {
|
|
447
|
+
return value.replace(/([?&](?:token|key|secret)=)[^&]+/gi, "$1<redacted>");
|
|
448
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { executeAction, defaultCapabilities } from "./adapters.mjs";
|
|
8
|
+
import { bridgeFunctions } from "./convex-functions.mjs";
|
|
9
|
+
import { getRuntimeConfig } from "../src/config.mjs";
|
|
10
|
+
|
|
11
|
+
dotenv.config({ path: ".env", quiet: true });
|
|
12
|
+
dotenv.config({ path: "bridge/.env.local", quiet: true });
|
|
13
|
+
|
|
14
|
+
const options = parseArgs(process.argv.slice(2));
|
|
15
|
+
const runtimeConfig = getRuntimeConfig();
|
|
16
|
+
const convexUrl = options.convexUrl || runtimeConfig.convexUrl || process.env.CONVEX_URL;
|
|
17
|
+
const mode = options.dryRun
|
|
18
|
+
? "dry-run"
|
|
19
|
+
: options.mode || runtimeConfig.bridgeMode || process.env.SKILLHUB_HARNESS_MODE || "dry-run";
|
|
20
|
+
const workerName =
|
|
21
|
+
options.name ||
|
|
22
|
+
process.env.SKILLHUB_WORKER_NAME ||
|
|
23
|
+
`${os.hostname()}-${process.pid}`;
|
|
24
|
+
const pollMs = Number(options.pollMs || process.env.SKILLHUB_POLL_MS || 1500);
|
|
25
|
+
const leaseMs = Number(options.leaseMs || process.env.SKILLHUB_LEASE_MS || 60_000);
|
|
26
|
+
const capabilities = options.capabilities.length
|
|
27
|
+
? options.capabilities
|
|
28
|
+
: parseCapabilities(process.env.SKILLHUB_CAPABILITIES) ?? defaultCapabilities;
|
|
29
|
+
|
|
30
|
+
if (!convexUrl) {
|
|
31
|
+
console.error("Run skillhub connect before starting the SkillHub bridge.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const client = new ConvexHttpClient(convexUrl);
|
|
36
|
+
if (process.env.CONVEX_AUTH_TOKEN) {
|
|
37
|
+
client.setAuth(process.env.CONVEX_AUTH_TOKEN);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let stopping = false;
|
|
41
|
+
let activeAbort = null;
|
|
42
|
+
|
|
43
|
+
process.on("SIGINT", stop);
|
|
44
|
+
process.on("SIGTERM", stop);
|
|
45
|
+
|
|
46
|
+
await main().catch((error) => {
|
|
47
|
+
console.error(error);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
console.log(`SkillHub bridge starting as ${workerName}`);
|
|
53
|
+
console.log(`Mode: ${mode}`);
|
|
54
|
+
console.log(`Capabilities: ${capabilities.join(", ")}`);
|
|
55
|
+
|
|
56
|
+
const sessionId = await client.mutation(bridgeFunctions.registerSession, {
|
|
57
|
+
name: workerName,
|
|
58
|
+
kind: process.env.RAILWAY_SERVICE_NAME ? "railway-worker" : "local-worker",
|
|
59
|
+
capabilities,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
while (!stopping) {
|
|
63
|
+
await expireStaleLeases();
|
|
64
|
+
|
|
65
|
+
const claimId = randomUUID();
|
|
66
|
+
const claimed = await client.mutation(bridgeFunctions.claimNextAction, {
|
|
67
|
+
claimId,
|
|
68
|
+
claimedBy: workerName,
|
|
69
|
+
sessionId,
|
|
70
|
+
capabilities,
|
|
71
|
+
leaseMs,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!claimed) {
|
|
75
|
+
if (options.once) {
|
|
76
|
+
console.log("No pending host action matched this worker.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await sleep(pollMs);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await handleClaim({ claimId, action: claimed.action });
|
|
84
|
+
|
|
85
|
+
if (options.once) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleClaim({ claimId, action }) {
|
|
92
|
+
console.log(`Claimed ${action._id}: ${action.title}`);
|
|
93
|
+
|
|
94
|
+
const abortController = new AbortController();
|
|
95
|
+
activeAbort = abortController;
|
|
96
|
+
|
|
97
|
+
const heartbeatEveryMs = Math.max(1000, Math.floor(leaseMs / 3));
|
|
98
|
+
const heartbeat = setInterval(() => {
|
|
99
|
+
client
|
|
100
|
+
.mutation(bridgeFunctions.heartbeatLease, {
|
|
101
|
+
claimId,
|
|
102
|
+
extendMs: leaseMs,
|
|
103
|
+
})
|
|
104
|
+
.catch((error) => {
|
|
105
|
+
console.error(`Heartbeat failed for ${claimId}: ${error.message}`);
|
|
106
|
+
});
|
|
107
|
+
}, heartbeatEveryMs);
|
|
108
|
+
|
|
109
|
+
const emitLog = async (level, message, data = undefined) => {
|
|
110
|
+
await client.mutation(bridgeFunctions.emitLog, {
|
|
111
|
+
claimId,
|
|
112
|
+
level,
|
|
113
|
+
source: workerName,
|
|
114
|
+
message,
|
|
115
|
+
data,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await emitLog("info", "SkillHub bridge claimed host action.", {
|
|
121
|
+
actionId: action._id,
|
|
122
|
+
actionType: action.actionType,
|
|
123
|
+
mode,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await client.mutation(bridgeFunctions.markApplying, {
|
|
127
|
+
claimId,
|
|
128
|
+
note: `SkillHub bridge ${workerName} started ${mode}.`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await executeAction(action, {
|
|
132
|
+
mode,
|
|
133
|
+
emitLog,
|
|
134
|
+
signal: abortController.signal,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await client.mutation(bridgeFunctions.completeAction, {
|
|
138
|
+
claimId,
|
|
139
|
+
result,
|
|
140
|
+
note: `SkillHub bridge ${workerName} completed ${action.title}.`,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(`Completed ${action._id}`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const result = error?.result ?? {
|
|
146
|
+
source: "skillhub-bridge",
|
|
147
|
+
mode,
|
|
148
|
+
message: error instanceof Error ? error.message : String(error),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await client.mutation(bridgeFunctions.failAction, {
|
|
152
|
+
claimId,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
data: result,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
console.error(`Failed ${action._id}: ${error.message}`);
|
|
158
|
+
} finally {
|
|
159
|
+
clearInterval(heartbeat);
|
|
160
|
+
activeAbort = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function expireStaleLeases() {
|
|
165
|
+
const result = await client.mutation(bridgeFunctions.expireStaleLeases, {
|
|
166
|
+
limit: 25,
|
|
167
|
+
});
|
|
168
|
+
if (result.expired > 0) {
|
|
169
|
+
console.log(`Expired ${result.expired} stale bridge lease(s).`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stop() {
|
|
174
|
+
stopping = true;
|
|
175
|
+
activeAbort?.abort();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseArgs(args) {
|
|
179
|
+
const parsed = {
|
|
180
|
+
capabilities: [],
|
|
181
|
+
convexUrl: "",
|
|
182
|
+
dryRun: false,
|
|
183
|
+
leaseMs: "",
|
|
184
|
+
mode: "",
|
|
185
|
+
name: "",
|
|
186
|
+
once: false,
|
|
187
|
+
pollMs: "",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
191
|
+
const arg = args[index];
|
|
192
|
+
if (arg === "--once") parsed.once = true;
|
|
193
|
+
else if (arg === "--dry-run") parsed.dryRun = true;
|
|
194
|
+
else if (arg === "--convex-url") parsed.convexUrl = args[++index] ?? "";
|
|
195
|
+
else if (arg === "--mode") parsed.mode = args[++index] ?? "";
|
|
196
|
+
else if (arg === "--name") parsed.name = args[++index] ?? "";
|
|
197
|
+
else if (arg === "--poll-ms") parsed.pollMs = args[++index] ?? "";
|
|
198
|
+
else if (arg === "--lease-ms") parsed.leaseMs = args[++index] ?? "";
|
|
199
|
+
else if (arg === "--capability") parsed.capabilities.push(args[++index] ?? "");
|
|
200
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
parsed.capabilities = parsed.capabilities.filter(Boolean);
|
|
204
|
+
return parsed;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseCapabilities(value) {
|
|
208
|
+
if (!value) return null;
|
|
209
|
+
return value
|
|
210
|
+
.split(",")
|
|
211
|
+
.map((item) => item.trim())
|
|
212
|
+
.filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sleep(ms) {
|
|
216
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"guidelinesHash": "31cdf5763fda9ffee83f538073d80fd995883c95a2bfaf4f6441010f3c391819",
|
|
3
|
+
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
|
4
|
+
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
|
5
|
+
"agentSkillsSha": "ec1e6baae7d86c7843c22938c75979c016f5c6e9"
|
|
6
|
+
}
|