@vantageos/agent-engine 0.1.0-alpha.7 → 0.3.0-alpha.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.
Files changed (62) hide show
  1. package/README.md +153 -50
  2. package/dist/component/_generated/api.d.ts +14 -27
  3. package/dist/component/_generated/api.js +0 -25
  4. package/dist/component/_generated/component.d.ts +369 -92
  5. package/dist/component/_generated/component.js +0 -9
  6. package/dist/component/_generated/dataModel.d.ts +0 -38
  7. package/dist/component/_generated/dataModel.js +0 -9
  8. package/dist/component/_generated/server.d.ts +0 -106
  9. package/dist/component/_generated/server.js +0 -69
  10. package/dist/component/convex.config.js +0 -44
  11. package/dist/component/engine/agents.js +0 -7
  12. package/dist/component/engine/awaitEventTimeout.d.ts +1 -1
  13. package/dist/component/engine/awaitEventTimeout.js +18 -26
  14. package/dist/component/engine/connections.d.ts +74 -0
  15. package/dist/component/engine/connections.js +254 -0
  16. package/dist/component/engine/delegation.d.ts +1 -1
  17. package/dist/component/engine/delegation.js +0 -74
  18. package/dist/component/engine/directory.js +0 -18
  19. package/dist/component/engine/durableJob.d.ts +34 -4
  20. package/dist/component/engine/durableJob.js +333 -86
  21. package/dist/component/engine/mandates.js +0 -59
  22. package/dist/component/engine/queue.d.ts +97 -0
  23. package/dist/component/engine/queue.js +201 -0
  24. package/dist/component/engine/resolvers.d.ts +1 -1
  25. package/dist/component/engine/resolvers.js +6 -18
  26. package/dist/component/engine/sandboxCallback.d.ts +1 -1
  27. package/dist/component/engine/sandboxCallback.js +2 -79
  28. package/dist/component/engine/sandboxConfigProvider.d.ts +9 -0
  29. package/dist/component/engine/sandboxConfigProvider.js +32 -0
  30. package/dist/component/engine/sessions.d.ts +28 -10
  31. package/dist/component/engine/sessions.js +153 -165
  32. package/dist/component/engine/streams.d.ts +47 -0
  33. package/dist/component/engine/streams.js +162 -0
  34. package/dist/component/engine/toolApprovals.d.ts +1 -1
  35. package/dist/component/engine/toolApprovals.js +12 -37
  36. package/dist/component/engine/vpConnection.js +0 -40
  37. package/dist/component/engine/wakeup.d.ts +5 -7
  38. package/dist/component/engine/wakeup.js +54 -127
  39. package/dist/component/engine/workflows.d.ts +42 -0
  40. package/dist/component/engine/workflows.js +100 -41
  41. package/dist/component/engine/world.d.ts +249 -0
  42. package/dist/component/engine/world.js +665 -0
  43. package/dist/component/lib/auth.js +0 -26
  44. package/dist/component/lib/connectionSecret.d.ts +5 -0
  45. package/dist/component/lib/connectionSecret.js +32 -0
  46. package/dist/component/schema.d.ts +227 -77
  47. package/dist/component/schema.js +137 -250
  48. package/package.json +17 -3
  49. package/dist/component/engine/habits.d.ts +0 -70
  50. package/dist/component/engine/habits.js +0 -218
  51. package/dist/component/testFixtures/callerSteps.d.ts +0 -20
  52. package/dist/component/testFixtures/callerSteps.js +0 -90
  53. package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +0 -18
  54. package/dist/component/testFixtures/delegationDemoWorkflow.js +0 -75
  55. package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +0 -19
  56. package/dist/component/testFixtures/sandboxDemoWorkflow.js +0 -69
  57. package/dist/component/testFixtures/vpTool.d.ts +0 -4
  58. package/dist/component/testFixtures/vpTool.js +0 -25
  59. package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +0 -19
  60. package/dist/component/testFixtures/wakeupDemoWorkflow.js +0 -77
  61. package/dist/component/testFixtures/wakeupWork.d.ts +0 -8
  62. package/dist/component/testFixtures/wakeupWork.js +0 -21
@@ -2,52 +2,38 @@ import { v } from "convex/values";
2
2
  import { WorkflowManager } from "@convex-dev/workflow";
3
3
  import { components, internal } from "../_generated/api.js";
4
4
  import { internalMutation, internalQuery, mutation, query, } from "../_generated/server.js";
5
- import { assertSameOrg, requireOrgId } from "../lib/auth.js";
6
- // durableJobs — THE SURVIVAL CONTRACT: pause at the step boundary,
7
- // deterministic replay, resume, and abandon after N failures. This ENVELOPS
8
- // `@convex-dev/workflow` -- it does not reimplement
9
- // durable execution. Every real unit of work (the caller-supplied
10
- // `stepFunctionHandle`) runs inside its own bounded mutation call; the
11
- // workflow persists `cursor` (steps [0, cursor) done) between calls, so a
12
- // job whose TOTAL wall-clock time exceeds a single Convex action's ceiling
13
- // still completes -- no single call has to survive past the ceiling, only
14
- // the aggregate progress does.
15
- //
16
- // Contract surface (this is what a consumer calls; see
17
- // examples/vantage-peers-long-task for a worked integration):
18
- // 1. `start` -- enqueue a job of N steps, driven by a step function
19
- // the CALLER owns (created via `createFunctionHandle`
20
- // in the caller's own app). We never read the
21
- // caller's tables directly -- component isolation.
22
- // 2. `getStatus` -- poll status/cursor/lastError/abandonedAtStep.
23
- // 3. `cancel` -- best-effort stop (checked at the next step boundary
24
- // and by the workflow's own cancel()).
25
- // 4. (internal) the workflow itself is the pause/resume/replay engine:
26
- // - PAUSE AT STEP BOUNDARY: each loop iteration is one workflow step;
27
- // `@convex-dev/workflow` durably persists after each step returns,
28
- // so the process can die between any two steps with zero data loss.
29
- // - DETERMINISTIC REPLAY: `runStepInternal` is idempotent -- it
30
- // no-ops for any stepIndex < job.cursor, so replaying the workflow
31
- // handler after a resume never re-runs completed work.
32
- // - RESUME: on restart, the workflow replays its journal up to the
33
- // last completed step then continues the same in-memory loop; our
34
- // `cursor` on the `durableJobs` row is the second, redundant source
35
- // of truth a caller can poll without touching workflow internals.
36
- // - ABANDON AFTER N: `runStepInternal` retries the caller's step
37
- // function up to `maxAttemptsPerStep` times INSIDE one workflow
38
- // step (not via workflow-level retry, which does not apply to
39
- // mutations); only once attempts are exhausted does the step throw,
40
- // which the workflow handler catches and turns into
41
- // `status: "abandoned"` with `abandonedAtStep` + `lastError`.
42
- //
43
- // Versioned workflow discipline (workflow#35) -- same as
44
- // `engine/workflows.ts:sessionWorkflowV1`: never edit `durableJobWorkflowV1`
45
- // in place once jobs reference it; ship `...V2` and drain v1 first.
46
- const CURRENT_WORKFLOW_VERSION = "v1";
5
+ import { requireOrgId } from "../lib/auth.js";
6
+ import { appendEventCore, legacyJobOf, loadRun, mirrorToRun, openLegacyRun, } from "./world.js";
7
+ const CURRENT_WORKFLOW_VERSION = "v2";
8
+ async function readJob(ctx, orgId, runId, notFoundMessage = "Job not found") {
9
+ const run = await loadRun(ctx, orgId, runId, "durableJobs", notFoundMessage);
10
+ const num = (field) => {
11
+ const value = run.created[field];
12
+ if (typeof value !== "number") {
13
+ throw new Error(`Job run_created payload is missing ${field}`);
14
+ }
15
+ return value;
16
+ };
17
+ const stepHandle = run.created.stepFunctionHandle;
18
+ if (typeof stepHandle !== "string") {
19
+ throw new Error("Job run_created payload is missing stepFunctionHandle");
20
+ }
21
+ const steps = await ctx.db
22
+ .query("worldSteps")
23
+ .withIndex("by_org_run", (q) => q.eq("orgId", orgId).eq("runId", runId))
24
+ .collect();
25
+ return {
26
+ run,
27
+ totalSteps: num("totalSteps"),
28
+ maxAttemptsPerStep: num("maxAttemptsPerStep"),
29
+ stepFunctionHandle: stepHandle,
30
+ cursor: steps.filter((s) => s.status === "completed").length,
31
+ };
32
+ }
33
+ function publicStatus(status) {
34
+ return status === "failed" ? "abandoned" : status;
35
+ }
47
36
  export const workflow = new WorkflowManager(components.workflow);
48
- // ---------------------------------------------------------------------
49
- // PUBLIC CONTRACT
50
- // ---------------------------------------------------------------------
51
37
  export const start = mutation({
52
38
  args: {
53
39
  orgId: v.string(),
@@ -56,7 +42,7 @@ export const start = mutation({
56
42
  totalSteps: v.number(),
57
43
  maxAttemptsPerStep: v.number(),
58
44
  },
59
- returns: v.id("durableJobs"),
45
+ returns: v.string(),
60
46
  handler: async (ctx, args) => {
61
47
  const orgId = requireOrgId(args.orgId);
62
48
  if (!Number.isInteger(args.totalSteps) || args.totalSteps <= 0) {
@@ -70,7 +56,7 @@ export const start = mutation({
70
56
  throw new Error("Validation: stepFunctionHandle must not be empty");
71
57
  }
72
58
  const now = Date.now();
73
- const jobId = await ctx.db.insert("durableJobs", {
59
+ const legacyJobId = await ctx.db.insert("durableJobs", {
74
60
  orgId,
75
61
  kind: args.kind,
76
62
  stepFunctionHandle: args.stepFunctionHandle,
@@ -82,13 +68,25 @@ export const start = mutation({
82
68
  createdAt: now,
83
69
  updatedAt: now,
84
70
  });
85
- const workflowId = await workflow.start(ctx, internal.engine.durableJob.durableJobWorkflowV1, { jobId });
86
- await ctx.db.patch(jobId, { workflowId });
87
- return jobId;
71
+ const runId = await openLegacyRun(ctx, {
72
+ orgId,
73
+ table: "durableJobs",
74
+ legacyId: legacyJobId,
75
+ data: {
76
+ kind: args.kind,
77
+ stepFunctionHandle: args.stepFunctionHandle,
78
+ totalSteps: args.totalSteps,
79
+ maxAttemptsPerStep: args.maxAttemptsPerStep,
80
+ workflowVersion: CURRENT_WORKFLOW_VERSION,
81
+ },
82
+ });
83
+ const workflowId = await workflow.start(ctx, internal.engine.durableJob.durableJobWorkflowV2, { orgId, runId });
84
+ await ctx.db.patch(legacyJobId, { workflowId });
85
+ return runId;
88
86
  },
89
87
  });
90
88
  export const getStatus = query({
91
- args: { orgId: v.string(), jobId: v.id("durableJobs") },
89
+ args: { orgId: v.string(), jobId: v.string() },
92
90
  returns: v.object({
93
91
  status: v.union(v.literal("running"), v.literal("completed"), v.literal("abandoned"), v.literal("cancelled")),
94
92
  cursor: v.number(),
@@ -98,42 +96,65 @@ export const getStatus = query({
98
96
  }),
99
97
  handler: async (ctx, args) => {
100
98
  const orgId = requireOrgId(args.orgId);
101
- const job = await ctx.db.get(args.jobId);
102
- assertSameOrg(orgId, job, "Job not found");
99
+ const job = await readJob(ctx, orgId, args.jobId);
100
+ const status = publicStatus(job.run.status);
101
+ let lastError;
102
+ let abandonedAtStep;
103
+ if (job.run.status === "failed") {
104
+ const journal = await ctx.db
105
+ .query("worldEvents")
106
+ .withIndex("by_org_run_seq", (q) => q.eq("orgId", orgId).eq("runId", job.run.runId))
107
+ .collect();
108
+ const failed = [...journal].reverse().find((e) => e.eventType === "run_failed");
109
+ if (failed) {
110
+ try {
111
+ const data = JSON.parse(failed.dataJson);
112
+ if (typeof data.lastError === "string")
113
+ lastError = data.lastError;
114
+ if (typeof data.abandonedAtStep === "number") {
115
+ abandonedAtStep = data.abandonedAtStep;
116
+ }
117
+ }
118
+ catch {
119
+ }
120
+ }
121
+ }
103
122
  return {
104
- status: job.status,
123
+ status,
105
124
  cursor: job.cursor,
106
125
  totalSteps: job.totalSteps,
107
- lastError: job.lastError,
108
- abandonedAtStep: job.abandonedAtStep,
126
+ lastError,
127
+ abandonedAtStep,
109
128
  };
110
129
  },
111
130
  });
112
131
  export const cancel = mutation({
113
- args: { orgId: v.string(), jobId: v.id("durableJobs") },
132
+ args: { orgId: v.string(), jobId: v.string() },
114
133
  returns: v.null(),
115
134
  handler: async (ctx, args) => {
116
135
  const orgId = requireOrgId(args.orgId);
117
- const job = await ctx.db.get(args.jobId);
118
- assertSameOrg(orgId, job, "Job not found");
119
- if (job.status !== "running")
136
+ const run = await loadRun(ctx, orgId, args.jobId, "durableJobs", "Job not found");
137
+ if (run.status !== "running")
120
138
  return null;
121
- await ctx.db.patch(args.jobId, {
122
- status: "cancelled",
123
- updatedAt: Date.now(),
139
+ await appendEventCore(ctx, {
140
+ orgId,
141
+ runId: run.runId,
142
+ eventType: "run_cancelled",
143
+ dataJson: "{}",
124
144
  });
125
- if (job.workflowId) {
126
- await workflow.cancel(ctx, job.workflowId);
145
+ const legacy = await legacyJobOf(ctx, run, orgId);
146
+ if (legacy !== null) {
147
+ await ctx.db.patch(legacy._id, {
148
+ status: "cancelled",
149
+ updatedAt: Date.now(),
150
+ });
151
+ if (legacy.workflowId) {
152
+ await workflow.cancel(ctx, legacy.workflowId);
153
+ }
127
154
  }
128
155
  return null;
129
156
  },
130
157
  });
131
- // ---------------------------------------------------------------------
132
- // INTERNAL — server-to-server only, invoked by the workflow's own steps.
133
- // The workflow runs with no end-user identity: every handler below scopes
134
- // strictly by the orgId already recorded on the `durableJobs` row, never a
135
- // client-supplied one.
136
- // ---------------------------------------------------------------------
137
158
  export const getJobInternal = internalQuery({
138
159
  args: { jobId: v.id("durableJobs") },
139
160
  returns: v.union(v.object({
@@ -154,12 +175,6 @@ export const getJobInternal = internalQuery({
154
175
  };
155
176
  },
156
177
  });
157
- // The one real unit of work per step-boundary. DETERMINISTIC REPLAY GUARD:
158
- // no-ops for any stepIndex already covered by job.cursor, so a workflow
159
- // resume (journal replay after a kill mid-run) never re-executes the
160
- // caller's step function for completed steps. ABANDON AFTER N: retries the
161
- // caller's step function up to `maxAttemptsPerStep` times before throwing
162
- // (throwing is what the workflow handler below turns into "abandoned").
163
178
  export const runStepInternal = internalMutation({
164
179
  args: { jobId: v.id("durableJobs"), stepIndex: v.number() },
165
180
  returns: v.null(),
@@ -170,9 +185,23 @@ export const runStepInternal = internalMutation({
170
185
  if (job.status === "cancelled")
171
186
  return null;
172
187
  if (args.stepIndex < job.cursor) {
173
- // Already-completed step replayed by a resumed workflow -- no-op.
174
188
  return null;
175
189
  }
190
+ const stepId = String(args.stepIndex);
191
+ await mirrorToRun(ctx, {
192
+ orgId: job.orgId,
193
+ table: "durableJobs",
194
+ legacyId: args.jobId,
195
+ eventType: "step_created",
196
+ stepId,
197
+ });
198
+ await mirrorToRun(ctx, {
199
+ orgId: job.orgId,
200
+ table: "durableJobs",
201
+ legacyId: args.jobId,
202
+ eventType: "step_started",
203
+ stepId,
204
+ });
176
205
  let lastError;
177
206
  for (let attempt = 1; attempt <= job.maxAttemptsPerStep; attempt++) {
178
207
  try {
@@ -181,6 +210,14 @@ export const runStepInternal = internalMutation({
181
210
  cursor: args.stepIndex + 1,
182
211
  updatedAt: Date.now(),
183
212
  });
213
+ await mirrorToRun(ctx, {
214
+ orgId: job.orgId,
215
+ table: "durableJobs",
216
+ legacyId: args.jobId,
217
+ eventType: "step_completed",
218
+ stepId,
219
+ dataJson: JSON.stringify({ attempt }),
220
+ });
184
221
  return null;
185
222
  }
186
223
  catch (err) {
@@ -205,6 +242,33 @@ export const markAbandonedInternal = internalMutation({
205
242
  lastError: args.error,
206
243
  updatedAt: Date.now(),
207
244
  });
245
+ const stepId = String(args.stepIndex);
246
+ await mirrorToRun(ctx, {
247
+ orgId: job.orgId,
248
+ table: "durableJobs",
249
+ legacyId: args.jobId,
250
+ eventType: "step_created",
251
+ stepId,
252
+ });
253
+ await mirrorToRun(ctx, {
254
+ orgId: job.orgId,
255
+ table: "durableJobs",
256
+ legacyId: args.jobId,
257
+ eventType: "step_failed",
258
+ stepId,
259
+ dataJson: JSON.stringify({ error: args.error }),
260
+ });
261
+ await mirrorToRun(ctx, {
262
+ orgId: job.orgId,
263
+ table: "durableJobs",
264
+ legacyId: args.jobId,
265
+ eventType: "run_failed",
266
+ dataJson: JSON.stringify({
267
+ reason: "abandoned",
268
+ abandonedAtStep: args.stepIndex,
269
+ lastError: args.error,
270
+ }),
271
+ });
208
272
  return null;
209
273
  },
210
274
  });
@@ -219,17 +283,15 @@ export const markCompletedInternal = internalMutation({
219
283
  status: "completed",
220
284
  updatedAt: Date.now(),
221
285
  });
286
+ await mirrorToRun(ctx, {
287
+ orgId: job.orgId,
288
+ table: "durableJobs",
289
+ legacyId: args.jobId,
290
+ eventType: "run_completed",
291
+ });
222
292
  return null;
223
293
  },
224
294
  });
225
- // durableJobWorkflowV1 -- the pause/resume/replay engine. One loop
226
- // iteration == one workflow step == one durability boundary: `step.
227
- // runMutation` returns only after `@convex-dev/workflow` has persisted the
228
- // journal entry, so a process kill between any two iterations loses zero
229
- // progress. On resume the workflow replays already-journaled steps from
230
- // its own history (no caller-visible effect, since runStepInternal is
231
- // idempotent below job.cursor) and continues the loop from where it left
232
- // off.
233
295
  export const durableJobWorkflowV1 = workflow.define({
234
296
  args: { jobId: v.id("durableJobs") },
235
297
  returns: v.null(),
@@ -261,3 +323,188 @@ export const durableJobWorkflowV1 = workflow.define({
261
323
  return null;
262
324
  },
263
325
  });
326
+ export const getJobInternalV2 = internalQuery({
327
+ args: { orgId: v.string(), runId: v.string() },
328
+ returns: v.union(v.object({
329
+ cursor: v.number(),
330
+ totalSteps: v.number(),
331
+ maxAttemptsPerStep: v.number(),
332
+ status: v.union(v.literal("running"), v.literal("completed"), v.literal("abandoned"), v.literal("cancelled")),
333
+ }), v.null()),
334
+ handler: async (ctx, args) => {
335
+ const run = await ctx.db
336
+ .query("worldRuns")
337
+ .withIndex("by_org_run", (q) => q.eq("orgId", args.orgId).eq("runId", args.runId))
338
+ .unique();
339
+ if (!run)
340
+ return null;
341
+ const job = await readJob(ctx, args.orgId, args.runId);
342
+ return {
343
+ cursor: job.cursor,
344
+ totalSteps: job.totalSteps,
345
+ maxAttemptsPerStep: job.maxAttemptsPerStep,
346
+ status: publicStatus(job.run.status),
347
+ };
348
+ },
349
+ });
350
+ export const runStepInternalV2 = internalMutation({
351
+ args: { orgId: v.string(), runId: v.string(), stepIndex: v.number() },
352
+ returns: v.null(),
353
+ handler: async (ctx, args) => {
354
+ const job = await readJob(ctx, args.orgId, args.runId);
355
+ if (job.run.status === "cancelled")
356
+ return null;
357
+ const stepId = String(args.stepIndex);
358
+ const existing = await ctx.db
359
+ .query("worldSteps")
360
+ .withIndex("by_org_run_step", (q) => q.eq("orgId", args.orgId).eq("runId", args.runId).eq("stepId", stepId))
361
+ .unique();
362
+ if (existing?.status === "completed")
363
+ return null;
364
+ await appendEventCore(ctx, {
365
+ orgId: args.orgId,
366
+ runId: args.runId,
367
+ eventType: "step_created",
368
+ stepId,
369
+ dataJson: "{}",
370
+ });
371
+ await appendEventCore(ctx, {
372
+ orgId: args.orgId,
373
+ runId: args.runId,
374
+ eventType: "step_started",
375
+ stepId,
376
+ dataJson: "{}",
377
+ });
378
+ let lastError;
379
+ for (let attempt = 1; attempt <= job.maxAttemptsPerStep; attempt++) {
380
+ try {
381
+ await ctx.runMutation(job.stepFunctionHandle, { orgId: args.orgId, jobId: args.runId, stepIndex: args.stepIndex });
382
+ const legacy = await legacyJobOf(ctx, job.run, args.orgId);
383
+ if (legacy !== null) {
384
+ await ctx.db.patch(legacy._id, {
385
+ cursor: args.stepIndex + 1,
386
+ updatedAt: Date.now(),
387
+ });
388
+ }
389
+ await appendEventCore(ctx, {
390
+ orgId: args.orgId,
391
+ runId: args.runId,
392
+ eventType: "step_completed",
393
+ stepId,
394
+ dataJson: JSON.stringify({ attempt }),
395
+ });
396
+ return null;
397
+ }
398
+ catch (err) {
399
+ lastError = err;
400
+ }
401
+ }
402
+ throw lastError instanceof Error
403
+ ? lastError
404
+ : new Error(String(lastError));
405
+ },
406
+ });
407
+ export const markAbandonedInternalV2 = internalMutation({
408
+ args: {
409
+ orgId: v.string(),
410
+ runId: v.string(),
411
+ stepIndex: v.number(),
412
+ error: v.string(),
413
+ },
414
+ returns: v.null(),
415
+ handler: async (ctx, args) => {
416
+ const job = await readJob(ctx, args.orgId, args.runId);
417
+ if (job.run.status !== "running")
418
+ return null;
419
+ const legacy = await legacyJobOf(ctx, job.run, args.orgId);
420
+ if (legacy !== null) {
421
+ await ctx.db.patch(legacy._id, {
422
+ status: "abandoned",
423
+ abandonedAtStep: args.stepIndex,
424
+ lastError: args.error,
425
+ updatedAt: Date.now(),
426
+ });
427
+ }
428
+ const stepId = String(args.stepIndex);
429
+ await appendEventCore(ctx, {
430
+ orgId: args.orgId,
431
+ runId: args.runId,
432
+ eventType: "step_created",
433
+ stepId,
434
+ dataJson: "{}",
435
+ });
436
+ await appendEventCore(ctx, {
437
+ orgId: args.orgId,
438
+ runId: args.runId,
439
+ eventType: "step_failed",
440
+ stepId,
441
+ dataJson: JSON.stringify({ error: args.error }),
442
+ });
443
+ await appendEventCore(ctx, {
444
+ orgId: args.orgId,
445
+ runId: args.runId,
446
+ eventType: "run_failed",
447
+ dataJson: JSON.stringify({
448
+ reason: "abandoned",
449
+ abandonedAtStep: args.stepIndex,
450
+ lastError: args.error,
451
+ }),
452
+ });
453
+ return null;
454
+ },
455
+ });
456
+ export const markCompletedInternalV2 = internalMutation({
457
+ args: { orgId: v.string(), runId: v.string() },
458
+ returns: v.null(),
459
+ handler: async (ctx, args) => {
460
+ const job = await readJob(ctx, args.orgId, args.runId);
461
+ if (job.run.status !== "running")
462
+ return null;
463
+ const legacy = await legacyJobOf(ctx, job.run, args.orgId);
464
+ if (legacy !== null) {
465
+ await ctx.db.patch(legacy._id, { status: "completed", updatedAt: Date.now() });
466
+ }
467
+ await appendEventCore(ctx, {
468
+ orgId: args.orgId,
469
+ runId: args.runId,
470
+ eventType: "run_completed",
471
+ dataJson: "{}",
472
+ });
473
+ return null;
474
+ },
475
+ });
476
+ export const durableJobWorkflowV2 = workflow.define({
477
+ args: { orgId: v.string(), runId: v.string() },
478
+ returns: v.null(),
479
+ handler: async (step, args) => {
480
+ const job = await step.runQuery(internal.engine.durableJob.getJobInternalV2, {
481
+ orgId: args.orgId,
482
+ runId: args.runId,
483
+ });
484
+ if (!job || job.status !== "running")
485
+ return null;
486
+ for (let i = job.cursor; i < job.totalSteps; i++) {
487
+ try {
488
+ await step.runMutation(internal.engine.durableJob.runStepInternalV2, {
489
+ orgId: args.orgId,
490
+ runId: args.runId,
491
+ stepIndex: i,
492
+ });
493
+ }
494
+ catch (err) {
495
+ await step.runMutation(internal.engine.durableJob.markAbandonedInternalV2, {
496
+ orgId: args.orgId,
497
+ runId: args.runId,
498
+ stepIndex: i,
499
+ error: err instanceof Error ? err.message : String(err),
500
+ });
501
+ return null;
502
+ }
503
+ }
504
+ await step.runMutation(internal.engine.durableJob.markCompletedInternalV2, {
505
+ orgId: args.orgId,
506
+ runId: args.runId,
507
+ });
508
+ return null;
509
+ },
510
+ });
@@ -4,26 +4,7 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
4
4
  import { internalMutation, mutation, query } from "../_generated/server.js";
5
5
  import { components } from "../_generated/api.js";
6
6
  import { assertSameOrg, requireOrgId } from "../lib/auth.js";
7
- // engine/mandates — "intentional divergence from Eve": BOUNDED AUTHORITY.
8
- // A mandate is a LINE OF DATA scoped
9
- // orgId+agentId+scope, never a constant read from code (litmus test: a
10
- // client changes their mind => zero deployment). The rate-limiter has NO
11
- // static named config in this module -- every call to `spend` builds its
12
- // `config` inline, dynamically, from the `mandates` row loaded from the
13
- // database (@convex-dev/rate-limiter README §"Define a rate limit inline /
14
- // dynamically"). Fails-closed: no mandate row => refusal, never a silent
15
- // bypass.
16
- //
17
- // orgId discipline (component/lib/auth.ts): every PUBLIC function takes
18
- // `orgId` as an explicit ARGUMENT (auth does not propagate across a
19
- // component boundary), same as the rest of this component (agents,
20
- // wakeup, sessions). `spendInternal` also takes `orgId` as an argument, but
21
- // is an `internalMutation` -- the ONLY caller reachable from any client is
22
- // `engine/wakeup.enqueue` (an action), which threads its own
23
- // caller-supplied `orgId` argument through after validating it.
24
7
  const rateLimiter = new RateLimiter(components.rateLimiter);
25
- // Usage rollup, keyed identically to the rate-limiter key (org+agent+scope).
26
- // internal research notes: counters via sharded-counter, never a canary/alpha dependency.
27
8
  const usageCounter = new ShardedCounter(components.shardedCounter);
28
9
  const mandateDoc = v.object({
29
10
  _id: v.id("mandates"),
@@ -38,9 +19,6 @@ const mandateDoc = v.object({
38
19
  createdAt: v.number(),
39
20
  updatedAt: v.number(),
40
21
  });
41
- // set — upserts the DATA row that bounds an agent's authority for a given
42
- // scope. This is the ONLY place a cap value is ever written; there is no
43
- // constant anywhere in the engine that plays this role.
44
22
  export const set = mutation({
45
23
  args: {
46
24
  orgId: v.string(),
@@ -101,25 +79,6 @@ export const get = query({
101
79
  .unique();
102
80
  },
103
81
  });
104
- // spend — the single choke point through which every costed action an
105
- // agent takes must pass. `cost` is a WEIGHTED count (tokens, euros, calls —
106
- // whatever the mandate's scope means), never a plain call counter.
107
- //
108
- // Deny by default, twice over:
109
- // 1. No mandate row for this org+agent+scope => throw immediately. We do
110
- // NOT fall back to "unlimited"; an unconfigured mandate is a refusal,
111
- // not an escape hatch. Nothing needs to be persisted for this case, so
112
- // throwing (and rolling back the whole transaction) is safe.
113
- // 2. Mandate exists but is exhausted => an approvalRequests row MUST be
114
- // committed. Convex mutations are single transactions: throwing after
115
- // `ctx.db.insert` would roll the insert back along with everything
116
- // else, silently destroying the very approval request the guard is
117
- // supposed to leave behind. So this path returns `{ ok: false }`
118
- // instead of throwing -- the approval row survives, and the caller
119
- // (engine/wakeup) is REQUIRED to check `.ok` and treat `false` as a
120
- // hard refusal. There is no code path that lets the action proceed
121
- // past an exhausted mandate; the only way authority is restored is an
122
- // explicit human `resolveApproval("approved")`.
123
82
  export const spend = mutation({
124
83
  args: {
125
84
  orgId: v.string(),
@@ -139,14 +98,6 @@ export const spend = mutation({
139
98
  return await spendCore(ctx, orgId, args);
140
99
  },
141
100
  });
142
- // spendInternal — SAME choke point as `spend`, INTERNAL ONLY (unreachable
143
- // from any client). This is the ONE function `engine/wakeup.enqueue` (an
144
- // `action`) calls, in its OWN transaction, before it may ever call
145
- // `enqueueInternal` to put work in the pool -- see the wiring note in
146
- // component/engine/wakeup.ts. Trusting an `orgId` argument here is exactly
147
- // as safe as `engine/wakeup.enqueueInternal` trusting one: the only code
148
- // that can reach an `internal.*` function is other server code, never a
149
- // client SDK. The public `spend` above is completely untouched.
150
101
  export const spendInternal = internalMutation({
151
102
  args: {
152
103
  orgId: v.string(),
@@ -174,7 +125,6 @@ async function spendCore(ctx, orgId, args) {
174
125
  .withIndex("by_org_agent_scope", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId).eq("scope", args.scope))
175
126
  .unique();
176
127
  if (!mandate) {
177
- // fails-closed: no data => no authority. Never silently unlimited.
178
128
  throw new Error(`Mandate refused: no mandate configured for scope "${args.scope}"`);
179
129
  }
180
130
  const key = `${orgId}:${args.agentId}:${args.scope}`;
@@ -189,10 +139,6 @@ async function spendCore(ctx, orgId, args) {
189
139
  },
190
140
  });
191
141
  if (!status.ok) {
192
- // Overrun => a human approval request is created and PERSISTED
193
- // (returning instead of throwing so the transaction commits it). This
194
- // is the ONLY consequence of an overrun -- there is no code path that
195
- // spends past an exhausted mandate.
196
142
  const approvalId = await ctx.db.insert("approvalRequests", {
197
143
  orgId,
198
144
  agentId: args.agentId,
@@ -204,8 +150,6 @@ async function spendCore(ctx, orgId, args) {
204
150
  });
205
151
  return { ok: false, approvalId };
206
152
  }
207
- // Rollup via sharded-counter (internal research notes: no canary dependency for a
208
- // guardrail). One shard bucket per org+agent+scope key.
209
153
  await usageCounter.add(ctx, key, args.cost);
210
154
  return { ok: true };
211
155
  }
@@ -235,8 +179,6 @@ export const listApprovals = query({
235
179
  : rows;
236
180
  },
237
181
  });
238
- // resolveApproval — the ONLY way an overrun ever becomes usable authority
239
- // again: an explicit human decision, recorded, never inferred.
240
182
  export const resolveApproval = mutation({
241
183
  args: {
242
184
  orgId: v.string(),
@@ -257,7 +199,6 @@ export const resolveApproval = mutation({
257
199
  return null;
258
200
  },
259
201
  });
260
- // usageCount — the rollup readout (sharded-counter), for dashboards/audits.
261
202
  export const usageCount = query({
262
203
  args: { orgId: v.string(), agentId: v.id("agents"), scope: v.string() },
263
204
  returns: v.number(),