@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.
@@ -0,0 +1,709 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server";
3
+
4
+ const logLevel = v.union(
5
+ v.literal("debug"),
6
+ v.literal("info"),
7
+ v.literal("warn"),
8
+ v.literal("error"),
9
+ );
10
+
11
+ async function nextRunEventSequence(ctx: any, runId: any) {
12
+ const latest = await ctx.db
13
+ .query("runEvents")
14
+ .withIndex("by_run_sequence", (q: any) => q.eq("runId", runId))
15
+ .order("desc")
16
+ .first();
17
+
18
+ return (latest?.sequence ?? 0) + 1;
19
+ }
20
+
21
+ async function appendRunEvent(ctx: any, runId: any, event: any) {
22
+ const now = Date.now();
23
+ const sequence = await nextRunEventSequence(ctx, runId);
24
+
25
+ await ctx.db.insert("runEvents", {
26
+ runId,
27
+ sequence,
28
+ eventType: event.eventType,
29
+ level: event.level ?? "info",
30
+ nodeId: event.nodeId,
31
+ edgeId: event.edgeId,
32
+ message: event.message,
33
+ payload: event.payload,
34
+ createdAt: now,
35
+ });
36
+
37
+ await ctx.db.patch(runId, { updatedAt: now });
38
+ }
39
+
40
+ async function getLeaseAndAction(ctx: any, claimId: string) {
41
+ const lease = await ctx.db
42
+ .query("bridgeLeases")
43
+ .withIndex("by_claim", (q: any) => q.eq("claimId", claimId))
44
+ .unique();
45
+
46
+ if (!lease || lease.status !== "active") {
47
+ throw new Error(`Active lease not found: ${claimId}`);
48
+ }
49
+
50
+ const action = await ctx.db.get(lease.actionId);
51
+ if (!action) {
52
+ throw new Error(`Host action not found for lease: ${claimId}`);
53
+ }
54
+
55
+ return { lease, action };
56
+ }
57
+
58
+ function isActionSupported(action: any, capabilities: string[] | undefined) {
59
+ if (!capabilities?.length) {
60
+ return true;
61
+ }
62
+
63
+ return capabilities.includes(action.actionType) || capabilities.includes("*");
64
+ }
65
+
66
+ export const getOperatorPacket = query({
67
+ args: {
68
+ runId: v.id("loopRuns"),
69
+ eventLimit: v.optional(v.number()),
70
+ logLimit: v.optional(v.number()),
71
+ },
72
+ handler: async (ctx, args) => {
73
+ const run = await ctx.db.get(args.runId);
74
+ if (!run) {
75
+ return null;
76
+ }
77
+
78
+ const eventLimit = Math.min(args.eventLimit ?? 100, 500);
79
+ const logLimit = Math.min(args.logLimit ?? 100, 500);
80
+
81
+ const [pendingActions, events, logs] = await Promise.all([
82
+ ctx.db
83
+ .query("hostActions")
84
+ .withIndex("by_run_status", (q) => q.eq("runId", args.runId))
85
+ .filter((q) =>
86
+ q.or(
87
+ q.eq(q.field("status"), "pending"),
88
+ q.eq(q.field("status"), "claimed"),
89
+ q.eq(q.field("status"), "applying"),
90
+ ),
91
+ )
92
+ .take(100),
93
+ ctx.db
94
+ .query("runEvents")
95
+ .withIndex("by_run_sequence", (q) => q.eq("runId", args.runId))
96
+ .order("desc")
97
+ .take(eventLimit),
98
+ ctx.db
99
+ .query("bridgeLogs")
100
+ .withIndex("by_run_createdAt", (q) => q.eq("runId", args.runId))
101
+ .order("desc")
102
+ .take(logLimit),
103
+ ]);
104
+
105
+ return {
106
+ run,
107
+ pendingActions,
108
+ events: events.reverse(),
109
+ logs: logs.reverse(),
110
+ };
111
+ },
112
+ });
113
+
114
+ export const listWorkerState = query({
115
+ args: {
116
+ sessionLimit: v.optional(v.number()),
117
+ },
118
+ handler: async (ctx, args) => {
119
+ const sessionLimit = Math.min(args.sessionLimit ?? 12, 50);
120
+ const now = Date.now();
121
+ const [sessions, pendingActions, claimedActions, applyingActions, failedActions] =
122
+ await Promise.all([
123
+ ctx.db
124
+ .query("bridgeSessions")
125
+ .withIndex("by_lastSeenAt")
126
+ .order("desc")
127
+ .take(sessionLimit),
128
+ ctx.db
129
+ .query("hostActions")
130
+ .withIndex("by_status_createdAt", (q) => q.eq("status", "pending"))
131
+ .order("desc")
132
+ .take(100),
133
+ ctx.db
134
+ .query("hostActions")
135
+ .withIndex("by_status_createdAt", (q) => q.eq("status", "claimed"))
136
+ .order("desc")
137
+ .take(100),
138
+ ctx.db
139
+ .query("hostActions")
140
+ .withIndex("by_status_createdAt", (q) => q.eq("status", "applying"))
141
+ .order("desc")
142
+ .take(100),
143
+ ctx.db
144
+ .query("hostActions")
145
+ .withIndex("by_status_createdAt", (q) => q.eq("status", "failed"))
146
+ .order("desc")
147
+ .take(20),
148
+ ]);
149
+
150
+ const activeActions = [...claimedActions, ...applyingActions];
151
+
152
+ return {
153
+ pendingCount: pendingActions.length,
154
+ activeCount: activeActions.length,
155
+ workers: sessions.map((session) => {
156
+ const currentAction =
157
+ activeActions.find((action) => action.claimedBy === session.name) ?? null;
158
+ const lastError =
159
+ failedActions.find((action) => action.claimedBy === session.name)?.error ?? null;
160
+ const connected =
161
+ session.status === "online" && now - session.lastSeenAt < 90_000;
162
+
163
+ return {
164
+ session,
165
+ connected,
166
+ currentAction,
167
+ lastError,
168
+ };
169
+ }),
170
+ };
171
+ },
172
+ });
173
+
174
+ export const registerSession = mutation({
175
+ args: {
176
+ name: v.string(),
177
+ kind: v.optional(v.string()),
178
+ capabilities: v.optional(v.array(v.string())),
179
+ },
180
+ handler: async (ctx, args) => {
181
+ const now = Date.now();
182
+
183
+ return await ctx.db.insert("bridgeSessions", {
184
+ name: args.name,
185
+ kind: args.kind ?? "codex-harness",
186
+ status: "online",
187
+ capabilities: args.capabilities ?? ["*"],
188
+ lastSeenAt: now,
189
+ createdAt: now,
190
+ updatedAt: now,
191
+ });
192
+ },
193
+ });
194
+
195
+ export const enqueueHostAction = mutation({
196
+ args: {
197
+ runId: v.id("loopRuns"),
198
+ nodeId: v.optional(v.string()),
199
+ actionType: v.string(),
200
+ title: v.string(),
201
+ idempotencyKey: v.string(),
202
+ payload: v.any(),
203
+ codexTool: v.optional(v.any()),
204
+ },
205
+ handler: async (ctx, args) => {
206
+ const run = await ctx.db.get(args.runId);
207
+ if (!run) {
208
+ throw new Error(`Run not found: ${args.runId}`);
209
+ }
210
+
211
+ const existing = await ctx.db
212
+ .query("hostActions")
213
+ .withIndex("by_idempotency", (q) =>
214
+ q.eq("idempotencyKey", args.idempotencyKey),
215
+ )
216
+ .unique();
217
+
218
+ if (existing) {
219
+ return existing._id;
220
+ }
221
+
222
+ const now = Date.now();
223
+ const actionId = await ctx.db.insert("hostActions", {
224
+ runId: args.runId,
225
+ loopSlug: run.loopSlug,
226
+ nodeId: args.nodeId,
227
+ actionType: args.actionType,
228
+ title: args.title,
229
+ status: "pending",
230
+ idempotencyKey: args.idempotencyKey,
231
+ payload: args.payload,
232
+ codexTool: args.codexTool,
233
+ createdAt: now,
234
+ updatedAt: now,
235
+ });
236
+
237
+ await appendRunEvent(ctx, args.runId, {
238
+ eventType: "host_action.enqueued",
239
+ nodeId: args.nodeId,
240
+ message: args.title,
241
+ payload: {
242
+ actionId,
243
+ actionType: args.actionType,
244
+ idempotencyKey: args.idempotencyKey,
245
+ },
246
+ });
247
+
248
+ return actionId;
249
+ },
250
+ });
251
+
252
+ export const claimNextAction = mutation({
253
+ args: {
254
+ claimId: v.string(),
255
+ claimedBy: v.string(),
256
+ sessionId: v.optional(v.id("bridgeSessions")),
257
+ capabilities: v.optional(v.array(v.string())),
258
+ leaseMs: v.optional(v.number()),
259
+ },
260
+ handler: async (ctx, args) => {
261
+ const now = Date.now();
262
+ const leaseMs = Math.max(1000, Math.min(args.leaseMs ?? 60_000, 30 * 60_000));
263
+ const existingLease = await ctx.db
264
+ .query("bridgeLeases")
265
+ .withIndex("by_claim", (q) => q.eq("claimId", args.claimId))
266
+ .unique();
267
+
268
+ if (existingLease) {
269
+ const existingAction = await ctx.db.get(existingLease.actionId);
270
+ if (!existingAction) {
271
+ throw new Error(`Claim exists without a host action: ${args.claimId}`);
272
+ }
273
+ if (existingLease.status !== "active") {
274
+ throw new Error(`Claim is already closed: ${args.claimId}`);
275
+ }
276
+
277
+ return {
278
+ action: existingAction,
279
+ leaseExpiresAt: existingLease.leaseExpiresAt,
280
+ };
281
+ }
282
+
283
+ const candidates = await ctx.db
284
+ .query("hostActions")
285
+ .withIndex("by_status_createdAt", (q) => q.eq("status", "pending"))
286
+ .take(50);
287
+
288
+ const action = candidates.find((candidate) =>
289
+ isActionSupported(candidate, args.capabilities),
290
+ );
291
+
292
+ if (!action) {
293
+ if (args.sessionId) {
294
+ await ctx.db.patch(args.sessionId, {
295
+ status: "online",
296
+ lastSeenAt: now,
297
+ updatedAt: now,
298
+ });
299
+ }
300
+ return null;
301
+ }
302
+
303
+ const leaseExpiresAt = now + leaseMs;
304
+
305
+ await ctx.db.patch(action._id, {
306
+ status: "claimed",
307
+ claimId: args.claimId,
308
+ claimedBy: args.claimedBy,
309
+ claimedAt: now,
310
+ leaseExpiresAt,
311
+ updatedAt: now,
312
+ });
313
+
314
+ if (args.sessionId) {
315
+ await ctx.db.patch(args.sessionId, {
316
+ status: "online",
317
+ lastSeenAt: now,
318
+ updatedAt: now,
319
+ });
320
+ }
321
+
322
+ await ctx.db.insert("bridgeLeases", {
323
+ actionId: action._id,
324
+ sessionId: args.sessionId,
325
+ claimId: args.claimId,
326
+ status: "active",
327
+ leaseExpiresAt,
328
+ heartbeatAt: now,
329
+ createdAt: now,
330
+ updatedAt: now,
331
+ });
332
+
333
+ await appendRunEvent(ctx, action.runId, {
334
+ eventType: "host_action.claimed",
335
+ nodeId: action.nodeId,
336
+ message: `${args.claimedBy} claimed ${action.title}`,
337
+ payload: {
338
+ actionId: action._id,
339
+ claimId: args.claimId,
340
+ leaseExpiresAt,
341
+ },
342
+ });
343
+
344
+ return {
345
+ action: {
346
+ ...action,
347
+ status: "claimed",
348
+ claimId: args.claimId,
349
+ claimedBy: args.claimedBy,
350
+ claimedAt: now,
351
+ leaseExpiresAt,
352
+ },
353
+ leaseExpiresAt,
354
+ };
355
+ },
356
+ });
357
+
358
+ export const heartbeatLease = mutation({
359
+ args: {
360
+ claimId: v.string(),
361
+ extendMs: v.optional(v.number()),
362
+ },
363
+ handler: async (ctx, args) => {
364
+ const { lease, action } = await getLeaseAndAction(ctx, args.claimId);
365
+ const now = Date.now();
366
+ const extendMs = Math.max(1000, Math.min(args.extendMs ?? 60_000, 30 * 60_000));
367
+ const leaseExpiresAt = now + extendMs;
368
+
369
+ await ctx.db.patch(lease._id, {
370
+ leaseExpiresAt,
371
+ heartbeatAt: now,
372
+ updatedAt: now,
373
+ });
374
+ await ctx.db.patch(action._id, {
375
+ leaseExpiresAt,
376
+ updatedAt: now,
377
+ });
378
+
379
+ return { leaseExpiresAt };
380
+ },
381
+ });
382
+
383
+ export const expireStaleLeases = mutation({
384
+ args: {
385
+ limit: v.optional(v.number()),
386
+ },
387
+ handler: async (ctx, args) => {
388
+ const now = Date.now();
389
+ const limit = Math.min(args.limit ?? 25, 100);
390
+ const leases = await ctx.db
391
+ .query("bridgeLeases")
392
+ .withIndex("by_expiry", (q) => q.lt("leaseExpiresAt", now))
393
+ .filter((q) => q.eq(q.field("status"), "active"))
394
+ .take(limit);
395
+
396
+ let expired = 0;
397
+
398
+ for (const lease of leases) {
399
+ const action = await ctx.db.get(lease.actionId);
400
+ await ctx.db.patch(lease._id, {
401
+ status: "expired",
402
+ updatedAt: now,
403
+ });
404
+
405
+ if (!action) {
406
+ continue;
407
+ }
408
+
409
+ await ctx.db.patch(action._id, {
410
+ status: "pending",
411
+ claimId: undefined,
412
+ claimedBy: undefined,
413
+ claimedAt: undefined,
414
+ leaseExpiresAt: undefined,
415
+ updatedAt: now,
416
+ });
417
+
418
+ await appendRunEvent(ctx, action.runId, {
419
+ eventType: "host_action.lease_expired",
420
+ level: "warn",
421
+ nodeId: action.nodeId,
422
+ message: `${action.title} returned to the queue after a stale lease`,
423
+ payload: {
424
+ actionId: action._id,
425
+ claimId: lease.claimId,
426
+ },
427
+ });
428
+
429
+ expired += 1;
430
+ }
431
+
432
+ return { expired };
433
+ },
434
+ });
435
+
436
+ export const markApplying = mutation({
437
+ args: {
438
+ claimId: v.string(),
439
+ note: v.optional(v.string()),
440
+ },
441
+ handler: async (ctx, args) => {
442
+ const { action } = await getLeaseAndAction(ctx, args.claimId);
443
+ const now = Date.now();
444
+
445
+ await ctx.db.patch(action._id, {
446
+ status: "applying",
447
+ updatedAt: now,
448
+ });
449
+
450
+ await appendRunEvent(ctx, action.runId, {
451
+ eventType: "host_action.applying",
452
+ nodeId: action.nodeId,
453
+ message: args.note ?? action.title,
454
+ payload: {
455
+ actionId: action._id,
456
+ claimId: args.claimId,
457
+ },
458
+ });
459
+ },
460
+ });
461
+
462
+ export const emitLog = mutation({
463
+ args: {
464
+ claimId: v.string(),
465
+ level: logLevel,
466
+ message: v.string(),
467
+ source: v.optional(v.string()),
468
+ data: v.optional(v.any()),
469
+ },
470
+ handler: async (ctx, args) => {
471
+ const { action } = await getLeaseAndAction(ctx, args.claimId);
472
+ const now = Date.now();
473
+
474
+ const logId = await ctx.db.insert("bridgeLogs", {
475
+ runId: action.runId,
476
+ actionId: action._id,
477
+ claimId: args.claimId,
478
+ source: args.source ?? "codex-harness",
479
+ level: args.level,
480
+ message: args.message,
481
+ data: args.data,
482
+ createdAt: now,
483
+ });
484
+
485
+ await appendRunEvent(ctx, action.runId, {
486
+ eventType: "bridge.log",
487
+ level: args.level,
488
+ nodeId: action.nodeId,
489
+ message: args.message,
490
+ payload: {
491
+ actionId: action._id,
492
+ claimId: args.claimId,
493
+ logId,
494
+ },
495
+ });
496
+
497
+ return logId;
498
+ },
499
+ });
500
+
501
+ export const completeAction = mutation({
502
+ args: {
503
+ claimId: v.string(),
504
+ result: v.optional(v.any()),
505
+ note: v.optional(v.string()),
506
+ },
507
+ handler: async (ctx, args) => {
508
+ const { lease, action } = await getLeaseAndAction(ctx, args.claimId);
509
+ const now = Date.now();
510
+
511
+ await ctx.db.patch(action._id, {
512
+ status: "completed",
513
+ result: args.result,
514
+ completedAt: now,
515
+ updatedAt: now,
516
+ });
517
+ await ctx.db.patch(lease._id, {
518
+ status: "completed",
519
+ updatedAt: now,
520
+ });
521
+
522
+ await appendRunEvent(ctx, action.runId, {
523
+ eventType: "host_action.completed",
524
+ nodeId: action.nodeId,
525
+ message: args.note ?? action.title,
526
+ payload: {
527
+ actionId: action._id,
528
+ claimId: args.claimId,
529
+ result: args.result,
530
+ },
531
+ });
532
+
533
+ await maybeAdvanceLoopStep(ctx, action, now);
534
+ },
535
+ });
536
+
537
+ export const failAction = mutation({
538
+ args: {
539
+ claimId: v.string(),
540
+ error: v.string(),
541
+ data: v.optional(v.any()),
542
+ },
543
+ handler: async (ctx, args) => {
544
+ const { lease, action } = await getLeaseAndAction(ctx, args.claimId);
545
+ const now = Date.now();
546
+
547
+ await ctx.db.patch(action._id, {
548
+ status: "failed",
549
+ error: args.error,
550
+ result: args.data,
551
+ completedAt: now,
552
+ updatedAt: now,
553
+ });
554
+ await ctx.db.patch(lease._id, {
555
+ status: "failed",
556
+ updatedAt: now,
557
+ });
558
+
559
+ await appendRunEvent(ctx, action.runId, {
560
+ eventType: "host_action.failed",
561
+ level: "error",
562
+ nodeId: action.nodeId,
563
+ message: args.error,
564
+ payload: {
565
+ actionId: action._id,
566
+ claimId: args.claimId,
567
+ data: args.data,
568
+ },
569
+ });
570
+ },
571
+ });
572
+
573
+ export const releaseAction = mutation({
574
+ args: {
575
+ claimId: v.string(),
576
+ reason: v.optional(v.string()),
577
+ },
578
+ handler: async (ctx, args) => {
579
+ const { lease, action } = await getLeaseAndAction(ctx, args.claimId);
580
+ const now = Date.now();
581
+
582
+ await ctx.db.patch(action._id, {
583
+ status: "pending",
584
+ claimId: undefined,
585
+ claimedBy: undefined,
586
+ claimedAt: undefined,
587
+ leaseExpiresAt: undefined,
588
+ updatedAt: now,
589
+ });
590
+ await ctx.db.patch(lease._id, {
591
+ status: "released",
592
+ updatedAt: now,
593
+ });
594
+
595
+ await appendRunEvent(ctx, action.runId, {
596
+ eventType: "host_action.released",
597
+ level: "warn",
598
+ nodeId: action.nodeId,
599
+ message: args.reason ?? "Action released back to the queue",
600
+ payload: {
601
+ actionId: action._id,
602
+ claimId: args.claimId,
603
+ },
604
+ });
605
+ },
606
+ });
607
+
608
+ async function maybeAdvanceLoopStep(ctx: any, action: any, now: number) {
609
+ const loopStep = action.payload?.loopStep;
610
+ if (!loopStep || !Array.isArray(loopStep.steps)) {
611
+ return;
612
+ }
613
+
614
+ const run = await ctx.db.get(action.runId);
615
+ if (!run || run.status !== "running") {
616
+ return;
617
+ }
618
+
619
+ const steps = loopStep.steps;
620
+ const nextIndex = Number(loopStep.stepIndex ?? -1) + 1;
621
+ const nextStep = steps[nextIndex];
622
+
623
+ if (!nextStep) {
624
+ await ctx.db.patch(action.runId, {
625
+ status: "completed",
626
+ activeNodeId: undefined,
627
+ completedAt: now,
628
+ updatedAt: now,
629
+ transitionCount: (run.transitionCount ?? 0) + 1,
630
+ });
631
+
632
+ await appendRunEvent(ctx, action.runId, {
633
+ eventType: "loop.completed",
634
+ level: "info",
635
+ message: `Loop completed after ${steps.length} step(s).`,
636
+ payload: {
637
+ finalActionId: action._id,
638
+ totalSteps: steps.length,
639
+ },
640
+ });
641
+ return;
642
+ }
643
+
644
+ await ctx.db.patch(action.runId, {
645
+ activeNodeId: nextStep.id,
646
+ updatedAt: now,
647
+ transitionCount: (run.transitionCount ?? 0) + 1,
648
+ });
649
+
650
+ const actionId = await ctx.db.insert("hostActions", {
651
+ runId: action.runId,
652
+ loopSlug: action.loopSlug,
653
+ nodeId: nextStep.id,
654
+ actionType: nextStep.actionType || "codex.cli",
655
+ title: `${action.payload?.loop?.name ?? action.loopSlug}: ${nextStep.label}`,
656
+ status: "pending",
657
+ idempotencyKey: `loop-step:${action.runId}:${nextStep.id}`,
658
+ payload: {
659
+ prompt: renderLoopStepPrompt({
660
+ loop: action.payload?.loop,
661
+ prompt: run.prompt,
662
+ step: nextStep,
663
+ stepIndex: nextIndex,
664
+ steps,
665
+ }),
666
+ loopStep: {
667
+ ...loopStep,
668
+ stepIndex: nextIndex,
669
+ nextStep: steps[nextIndex + 1] ?? null,
670
+ },
671
+ loop: action.payload?.loop,
672
+ step: nextStep,
673
+ },
674
+ codexTool: action.codexTool,
675
+ createdAt: now,
676
+ updatedAt: now,
677
+ });
678
+
679
+ await appendRunEvent(ctx, action.runId, {
680
+ eventType: "loop.step.enqueued",
681
+ level: "info",
682
+ nodeId: nextStep.id,
683
+ message: `Queued step ${nextIndex + 1}: ${nextStep.label}`,
684
+ payload: {
685
+ actionId,
686
+ stepIndex: nextIndex,
687
+ totalSteps: steps.length,
688
+ },
689
+ });
690
+ }
691
+
692
+ function renderLoopStepPrompt({ loop, prompt, step, stepIndex, steps }: any) {
693
+ return [
694
+ `Loop: ${loop?.name ?? "SkillHub loop"}`,
695
+ `Step ${stepIndex + 1} of ${steps.length}: ${step.label}`,
696
+ step.skillSlug ? `Skill reference: ${step.skillSlug}` : null,
697
+ "",
698
+ "Loop objective:",
699
+ prompt,
700
+ "",
701
+ "Step instructions:",
702
+ step.instructions,
703
+ "",
704
+ "All loop steps:",
705
+ ...steps.map((item: any, index: number) => `${index + 1}. ${item.label}`),
706
+ ]
707
+ .filter(Boolean)
708
+ .join("\n");
709
+ }
@@ -0,0 +1,8 @@
1
+ import { defineApp } from "convex/server";
2
+ import workflow from "@convex-dev/workflow/convex.config.js";
3
+
4
+ const app = defineApp();
5
+
6
+ app.use(workflow);
7
+
8
+ export default app;