@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.
- package/README.md +153 -50
- package/dist/component/_generated/api.d.ts +14 -27
- package/dist/component/_generated/api.js +0 -25
- package/dist/component/_generated/component.d.ts +369 -92
- package/dist/component/_generated/component.js +0 -9
- package/dist/component/_generated/dataModel.d.ts +0 -38
- package/dist/component/_generated/dataModel.js +0 -9
- package/dist/component/_generated/server.d.ts +0 -106
- package/dist/component/_generated/server.js +0 -69
- package/dist/component/convex.config.js +0 -44
- package/dist/component/engine/agents.js +0 -7
- package/dist/component/engine/awaitEventTimeout.d.ts +1 -1
- package/dist/component/engine/awaitEventTimeout.js +18 -26
- package/dist/component/engine/connections.d.ts +74 -0
- package/dist/component/engine/connections.js +254 -0
- package/dist/component/engine/delegation.d.ts +1 -1
- package/dist/component/engine/delegation.js +0 -74
- package/dist/component/engine/directory.js +0 -18
- package/dist/component/engine/durableJob.d.ts +34 -4
- package/dist/component/engine/durableJob.js +333 -86
- package/dist/component/engine/mandates.js +0 -59
- package/dist/component/engine/queue.d.ts +97 -0
- package/dist/component/engine/queue.js +201 -0
- package/dist/component/engine/resolvers.d.ts +1 -1
- package/dist/component/engine/resolvers.js +6 -18
- package/dist/component/engine/sandboxCallback.d.ts +1 -1
- package/dist/component/engine/sandboxCallback.js +2 -79
- package/dist/component/engine/sandboxConfigProvider.d.ts +9 -0
- package/dist/component/engine/sandboxConfigProvider.js +32 -0
- package/dist/component/engine/sessions.d.ts +28 -10
- package/dist/component/engine/sessions.js +153 -165
- package/dist/component/engine/streams.d.ts +47 -0
- package/dist/component/engine/streams.js +162 -0
- package/dist/component/engine/toolApprovals.d.ts +1 -1
- package/dist/component/engine/toolApprovals.js +12 -37
- package/dist/component/engine/vpConnection.js +0 -40
- package/dist/component/engine/wakeup.d.ts +5 -7
- package/dist/component/engine/wakeup.js +54 -127
- package/dist/component/engine/workflows.d.ts +42 -0
- package/dist/component/engine/workflows.js +100 -41
- package/dist/component/engine/world.d.ts +249 -0
- package/dist/component/engine/world.js +665 -0
- package/dist/component/lib/auth.js +0 -26
- package/dist/component/lib/connectionSecret.d.ts +5 -0
- package/dist/component/lib/connectionSecret.js +32 -0
- package/dist/component/schema.d.ts +227 -77
- package/dist/component/schema.js +137 -250
- package/package.json +17 -3
- package/dist/component/engine/habits.d.ts +0 -70
- package/dist/component/engine/habits.js +0 -218
- package/dist/component/testFixtures/callerSteps.d.ts +0 -20
- package/dist/component/testFixtures/callerSteps.js +0 -90
- package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +0 -18
- package/dist/component/testFixtures/delegationDemoWorkflow.js +0 -75
- package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +0 -19
- package/dist/component/testFixtures/sandboxDemoWorkflow.js +0 -69
- package/dist/component/testFixtures/vpTool.d.ts +0 -4
- package/dist/component/testFixtures/vpTool.js +0 -25
- package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +0 -19
- package/dist/component/testFixtures/wakeupDemoWorkflow.js +0 -77
- package/dist/component/testFixtures/wakeupWork.d.ts +0 -8
- 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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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
|
|
102
|
-
|
|
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
|
|
123
|
+
status,
|
|
105
124
|
cursor: job.cursor,
|
|
106
125
|
totalSteps: job.totalSteps,
|
|
107
|
-
lastError
|
|
108
|
-
abandonedAtStep
|
|
126
|
+
lastError,
|
|
127
|
+
abandonedAtStep,
|
|
109
128
|
};
|
|
110
129
|
},
|
|
111
130
|
});
|
|
112
131
|
export const cancel = mutation({
|
|
113
|
-
args: { orgId: v.string(), jobId: v.
|
|
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
|
|
118
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
await appendEventCore(ctx, {
|
|
140
|
+
orgId,
|
|
141
|
+
runId: run.runId,
|
|
142
|
+
eventType: "run_cancelled",
|
|
143
|
+
dataJson: "{}",
|
|
124
144
|
});
|
|
125
|
-
|
|
126
|
-
|
|
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(),
|