@vantageos/agent-engine 0.1.0-alpha.8 → 0.3.0-alpha.1

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 +33 -8
  2. package/dist/component/_generated/api.d.ts +14 -3
  3. package/dist/component/_generated/api.js +0 -25
  4. package/dist/component/_generated/component.d.ts +369 -73
  5. package/dist/component/_generated/component.js +0 -9
  6. package/dist/component/_generated/dataModel.js +0 -9
  7. package/dist/component/_generated/server.js +0 -69
  8. package/dist/component/convex.config.js +0 -44
  9. package/dist/component/engine/agents.d.ts +3 -3
  10. package/dist/component/engine/agents.js +0 -7
  11. package/dist/component/engine/awaitEventTimeout.d.ts +1 -1
  12. package/dist/component/engine/awaitEventTimeout.js +16 -26
  13. package/dist/component/engine/connections.d.ts +74 -0
  14. package/dist/component/engine/connections.js +254 -0
  15. package/dist/component/engine/delegation.d.ts +5 -5
  16. package/dist/component/engine/delegation.js +0 -74
  17. package/dist/component/engine/directory.js +0 -18
  18. package/dist/component/engine/durableJob.d.ts +24 -17
  19. package/dist/component/engine/durableJob.js +182 -138
  20. package/dist/component/engine/mandates.d.ts +2 -2
  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 +2 -2
  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 +130 -170
  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 +7 -9
  38. package/dist/component/engine/wakeup.js +54 -127
  39. package/dist/component/engine/workflows.d.ts +26 -8
  40. package/dist/component/engine/workflows.js +59 -64
  41. package/dist/component/engine/world.d.ts +250 -0
  42. package/dist/component/engine/world.js +689 -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 +240 -175
  47. package/dist/component/schema.js +134 -294
  48. package/package.json +21 -6
  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,46 +2,7 @@ import { v } from "convex/values";
2
2
  import { api, internal } from "../_generated/api.js";
3
3
  import { action, internalMutation, internalQuery, mutation, query, } from "../_generated/server.js";
4
4
  import { assertSameOrg, requireOrgId } from "../lib/auth.js";
5
- // engine/delegation — an agent delegating to another agent uses THE SAME
6
- // pause/wake system (awaitEvent/sendEvent) as the sandbox bridge: one
7
- // system to maintain, not two. Every delegation path below (declared
8
- // subagent AND the built-in `agent` clone tool) terminates in exactly ONE
9
- // call to `engine.wakeup.enqueue` -- the same public action this component
10
- // already ships, imported here and never reimplemented.
11
- // grep proof: this file makes zero calls to `wakeupPool.*` / `workflow.*`
12
- // directly; the only wake-capable call in this module is
13
- // `api.engine.wakeup.enqueue` at the bottom of `delegateToSubagent` and
14
- // `invokeAgentClone`.
15
- //
16
- // orgId discipline (component/lib/auth.ts): every PUBLIC function takes
17
- // `orgId` as an explicit ARGUMENT (auth does not propagate across a
18
- // component boundary), same as engine/mandates.ts / engine/wakeup.ts.
19
- // `delegateToSubagent`/`invokeAgentClone` are `action`s because they each
20
- // call `api.engine.wakeup.enqueue` (itself an action: the two-transaction
21
- // funding protocol) -- `orgId` is threaded explicitly through
22
- // every hop, never relying on any identity propagation across the boundary.
23
- // Reserved tool name for the built-in, root-only, non-recursive clone tool
24
- // (eve.dev/docs/subagents). A declared subagent may never claim this name --
25
- // that would silently shadow the built-in, which eve explicitly refuses to
26
- // do ("eve rejects the build rather than picking a winner").
27
5
  export const BUILTIN_AGENT_TOOL_NAME = "agent";
28
- // ---------------------------------------------------------------------
29
- // (a.1) DECLARED SUBAGENTS -- strict isolation, build-time collision guard
30
- // ---------------------------------------------------------------------
31
- // declareSubagent — registers a declared subagent as a NAMED tool on the
32
- // parent. Isolation is by construction: this mutation writes ONLY the
33
- // parent->child link + toolName; it never copies a single field out of the
34
- // child's own `skills`/`connections`/`toolApprovals` rows onto the parent,
35
- // and never will -- a declared subagent's own aggregate is the ONLY place
36
- // its slots ever live (eve.dev/docs/subagents, verbatim: "a declared
37
- // subagent inherits nothing from the root's authored slots").
38
- //
39
- // BUILD-TIME COLLISION GUARD: `toolName` must be unique among (1) the
40
- // reserved built-in `agent` tool name, (2) every OTHER declared subagent of
41
- // this same parent, and (3) every `toolApprovals` row already registered
42
- // for this parent (a toolApprovals row is how this engine represents "a
43
- // named tool this agent already exposes"). Any collision REJECTS the
44
- // mutation -- the row is never written, there is no silent "last one wins".
45
6
  export const declareSubagent = mutation({
46
7
  args: {
47
8
  orgId: v.string(),
@@ -119,18 +80,6 @@ export const listSubagents = query({
119
80
  .collect();
120
81
  },
121
82
  });
122
- // ---------------------------------------------------------------------
123
- // (a.2) DELEGATION -- reuses `engine.wakeup.enqueue` end to end
124
- // ---------------------------------------------------------------------
125
- // delegateToSubagent — A delegates to a declared subagent B by the SAME
126
- // pause/wake mechanism the sandbox bridge uses: it enqueues the
127
- // caller-supplied `workFunctionHandle` (an action that performs B's turn)
128
- // through `api.engine.wakeup.enqueue`. That enqueue funds through
129
- // `mandates.spendInternal` (the funding choke point, untouched), puts the work
130
- // into `wakeupPool`, and when it resolves, `engine.wakeup.onWorkComplete`
131
- // calls `workflow.sendEvent` (workflowId, awaitedEventName, ...) -- waking
132
- // whatever workflow step A's own session parked on `step.awaitEvent`. There
133
- // is no second onComplete/sendEvent path anywhere in this file.
134
83
  export const delegateToSubagent = action({
135
84
  args: {
136
85
  orgId: v.string(),
@@ -164,10 +113,6 @@ export const delegateToSubagent = action({
164
113
  workflowId: args.workflowId,
165
114
  awaitedEventName: args.awaitedEventName,
166
115
  });
167
- // THE reuse point: no bespoke pause/wake code below this line -- this
168
- // is a direct call into this component's own PUBLIC `engine.wakeup.enqueue`
169
- // action. `orgId` is threaded explicitly (no identity propagation
170
- // across the component boundary).
171
116
  await ctx.runAction(api.engine.wakeup.enqueue, {
172
117
  orgId,
173
118
  agentId: subagent.childAgentId,
@@ -182,19 +127,6 @@ export const delegateToSubagent = action({
182
127
  return delegationRunId;
183
128
  },
184
129
  });
185
- // ---------------------------------------------------------------------
186
- // (a.3) BUILT-IN `agent` TOOL -- root-only clone, NOT recursive
187
- // ---------------------------------------------------------------------
188
- // invokeAgentClone — the built-in `agent` tool (eve.dev/docs/subagents): a
189
- // clone of the ROOT agent, invocable by the root itself, but the clone
190
- // CANNOT call itself again. `viaAgentTool` is the recursion guard: the
191
- // root's own first call always passes `viaAgentTool: false` (or omits it).
192
- // Anything invoking this action must set `viaAgentTool: true` on any call
193
- // made FROM INSIDE a clone's own work -- and that is exactly the value this
194
- // handler refuses. There is no code path in this engine that can reach the
195
- // pool a second time for the same clone chain: the guard runs BEFORE
196
- // `wakeup.enqueue` is ever called, so a recursive attempt never enters the
197
- // workpool at all.
198
130
  export const invokeAgentClone = action({
199
131
  args: {
200
132
  orgId: v.string(),
@@ -229,8 +161,6 @@ export const invokeAgentClone = action({
229
161
  workflowId: args.workflowId,
230
162
  awaitedEventName: args.awaitedEventName,
231
163
  });
232
- // Same reuse point as `delegateToSubagent`: one call into the SAME
233
- // public `engine.wakeup.enqueue`, nothing bespoke.
234
164
  await ctx.runAction(api.engine.wakeup.enqueue, {
235
165
  orgId,
236
166
  agentId: args.agentId,
@@ -245,10 +175,6 @@ export const invokeAgentClone = action({
245
175
  return cloneRunId;
246
176
  },
247
177
  });
248
- // ---------------------------------------------------------------------
249
- // INTERNAL helpers -- unreachable from any client, same discipline as
250
- // `engine/wakeup.enqueueInternal`.
251
- // ---------------------------------------------------------------------
252
178
  export const getSubagentInternal = internalQuery({
253
179
  args: { orgId: v.string(), subagentId: v.id("subagents") },
254
180
  returns: v.union(v.object({
@@ -1,24 +1,6 @@
1
1
  import { v } from "convex/values";
2
2
  import { query } from "../_generated/server.js";
3
3
  import { requireOrgId } from "../lib/auth.js";
4
- // engine/directory — component-boundary port of the app-level
5
- // `convex/directory.ts:listByOrg`.
6
- //
7
- // Trust-boundary divergence from the app-level prototype (see
8
- // component/lib/auth.ts): auth does NOT propagate across a component
9
- // boundary, so `orgId` is a caller-supplied ARGUMENT here (deny by default
10
- // via `requireOrgId(args.orgId)`), never read from `ctx.auth`. The consumer
11
- // app has already authenticated its user and resolved orgId on its own
12
- // side before calling in.
13
- //
14
- // Shape divergence: the app-level prototype also reports
15
- // `subagentCount`/`connectionCount` by joining the `subagents` and
16
- // `connections` tables. Those tables are NOT (yet) re-hosted inside this
17
- // component boundary (see component/schema.ts SCOPE note) -- porting a
18
- // join against tables this component does not own would violate component
19
- // isolation. `listByOrg` here therefore returns only what this component's
20
- // own `agents` table can answer; counts can be added once `subagents`/
21
- // `connections` gain their own component-level test harness.
22
4
  export const listByOrg = query({
23
5
  args: { orgId: v.string() },
24
6
  returns: v.array(v.object({
@@ -1,17 +1,18 @@
1
1
  import { WorkflowManager, type WorkflowId } from "@convex-dev/workflow";
2
+ type JobRunStatus = "running" | "completed" | "abandoned" | "cancelled";
2
3
  export declare const workflow: WorkflowManager;
3
4
  export declare const start: import("convex/server").RegisteredMutation<"public", {
4
5
  orgId: string;
6
+ kind: string;
5
7
  stepFunctionHandle: string;
6
8
  totalSteps: number;
7
9
  maxAttemptsPerStep: number;
8
- kind: string;
9
- }, Promise<import("convex/values").GenericId<"durableJobs">>>;
10
+ }, Promise<string>>;
10
11
  export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
11
12
  orgId: string;
12
- jobId: import("convex/values").GenericId<"durableJobs">;
13
+ jobId: string;
13
14
  }, Promise<{
14
- status: "running" | "completed" | "abandoned" | "cancelled";
15
+ status: JobRunStatus;
15
16
  cursor: number;
16
17
  totalSteps: number;
17
18
  lastError: string | undefined;
@@ -19,28 +20,34 @@ export declare const getStatus: import("convex/server").RegisteredQuery<"public"
19
20
  }>>;
20
21
  export declare const cancel: import("convex/server").RegisteredMutation<"public", {
21
22
  orgId: string;
22
- jobId: import("convex/values").GenericId<"durableJobs">;
23
+ jobId: string;
23
24
  }, Promise<null>>;
24
- export declare const getJobInternal: import("convex/server").RegisteredQuery<"internal", {
25
- jobId: import("convex/values").GenericId<"durableJobs">;
25
+ export declare const getJobInternalV2: import("convex/server").RegisteredQuery<"internal", {
26
+ orgId: string;
27
+ runId: string;
26
28
  }, Promise<{
27
29
  cursor: number;
28
30
  totalSteps: number;
29
31
  maxAttemptsPerStep: number;
30
- status: "running" | "completed" | "abandoned" | "cancelled";
32
+ status: JobRunStatus;
31
33
  } | null>>;
32
- export declare const runStepInternal: import("convex/server").RegisteredMutation<"internal", {
33
- jobId: import("convex/values").GenericId<"durableJobs">;
34
+ export declare const runStepInternalV2: import("convex/server").RegisteredMutation<"internal", {
35
+ orgId: string;
36
+ runId: string;
34
37
  stepIndex: number;
35
38
  }, Promise<null>>;
36
- export declare const markAbandonedInternal: import("convex/server").RegisteredMutation<"internal", {
37
- jobId: import("convex/values").GenericId<"durableJobs">;
38
- stepIndex: number;
39
+ export declare const markAbandonedInternalV2: import("convex/server").RegisteredMutation<"internal", {
40
+ orgId: string;
41
+ runId: string;
39
42
  error: string;
43
+ stepIndex: number;
40
44
  }, Promise<null>>;
41
- export declare const markCompletedInternal: import("convex/server").RegisteredMutation<"internal", {
42
- jobId: import("convex/values").GenericId<"durableJobs">;
45
+ export declare const markCompletedInternalV2: import("convex/server").RegisteredMutation<"internal", {
46
+ orgId: string;
47
+ runId: string;
43
48
  }, Promise<null>>;
44
- export declare const durableJobWorkflowV1: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workflow").WorkflowArgs<{
45
- jobId: import("convex/values").VId<import("convex/values").GenericId<"durableJobs">, "required">;
49
+ export declare const durableJobWorkflowV2: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workflow").WorkflowArgs<{
50
+ orgId: import("convex/values").VString<string, "required">;
51
+ runId: import("convex/values").VString<string, "required">;
46
52
  }>, WorkflowId>;
53
+ export {};
@@ -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, bindWorkflowToRun, loadRun, openRun, workflowOfRun, } 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, "job", 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) {
@@ -69,26 +55,29 @@ export const start = mutation({
69
55
  if (args.stepFunctionHandle.length === 0) {
70
56
  throw new Error("Validation: stepFunctionHandle must not be empty");
71
57
  }
72
- const now = Date.now();
73
- const jobId = await ctx.db.insert("durableJobs", {
58
+ const runId = await openRun(ctx, {
59
+ orgId,
60
+ kind: "job",
61
+ data: {
62
+ kind: args.kind,
63
+ stepFunctionHandle: args.stepFunctionHandle,
64
+ totalSteps: args.totalSteps,
65
+ maxAttemptsPerStep: args.maxAttemptsPerStep,
66
+ workflowVersion: CURRENT_WORKFLOW_VERSION,
67
+ },
68
+ });
69
+ const workflowId = await workflow.start(ctx, internal.engine.durableJob.durableJobWorkflowV2, { orgId, runId });
70
+ await bindWorkflowToRun(ctx, {
74
71
  orgId,
75
- kind: args.kind,
76
- stepFunctionHandle: args.stepFunctionHandle,
77
- totalSteps: args.totalSteps,
78
- maxAttemptsPerStep: args.maxAttemptsPerStep,
79
- cursor: 0,
80
- status: "running",
72
+ runId,
73
+ workflowId,
81
74
  workflowVersion: CURRENT_WORKFLOW_VERSION,
82
- createdAt: now,
83
- updatedAt: now,
84
75
  });
85
- const workflowId = await workflow.start(ctx, internal.engine.durableJob.durableJobWorkflowV1, { jobId });
86
- await ctx.db.patch(jobId, { workflowId });
87
- return jobId;
76
+ return runId;
88
77
  },
89
78
  });
90
79
  export const getStatus = query({
91
- args: { orgId: v.string(), jobId: v.id("durableJobs") },
80
+ args: { orgId: v.string(), jobId: v.string() },
92
81
  returns: v.object({
93
82
  status: v.union(v.literal("running"), v.literal("completed"), v.literal("abandoned"), v.literal("cancelled")),
94
83
  cursor: v.number(),
@@ -98,44 +87,61 @@ export const getStatus = query({
98
87
  }),
99
88
  handler: async (ctx, args) => {
100
89
  const orgId = requireOrgId(args.orgId);
101
- const job = await ctx.db.get(args.jobId);
102
- assertSameOrg(orgId, job, "Job not found");
90
+ const job = await readJob(ctx, orgId, args.jobId);
91
+ const status = publicStatus(job.run.status);
92
+ let lastError;
93
+ let abandonedAtStep;
94
+ if (job.run.status === "failed") {
95
+ const journal = await ctx.db
96
+ .query("worldEvents")
97
+ .withIndex("by_org_run_seq", (q) => q.eq("orgId", orgId).eq("runId", job.run.runId))
98
+ .collect();
99
+ const failed = [...journal].reverse().find((e) => e.eventType === "run_failed");
100
+ if (failed) {
101
+ try {
102
+ const data = JSON.parse(failed.dataJson);
103
+ if (typeof data.lastError === "string")
104
+ lastError = data.lastError;
105
+ if (typeof data.abandonedAtStep === "number") {
106
+ abandonedAtStep = data.abandonedAtStep;
107
+ }
108
+ }
109
+ catch {
110
+ }
111
+ }
112
+ }
103
113
  return {
104
- status: job.status,
114
+ status,
105
115
  cursor: job.cursor,
106
116
  totalSteps: job.totalSteps,
107
- lastError: job.lastError,
108
- abandonedAtStep: job.abandonedAtStep,
117
+ lastError,
118
+ abandonedAtStep,
109
119
  };
110
120
  },
111
121
  });
112
122
  export const cancel = mutation({
113
- args: { orgId: v.string(), jobId: v.id("durableJobs") },
123
+ args: { orgId: v.string(), jobId: v.string() },
114
124
  returns: v.null(),
115
125
  handler: async (ctx, args) => {
116
126
  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")
127
+ const run = await loadRun(ctx, orgId, args.jobId, "job", "Job not found");
128
+ if (run.status !== "running")
120
129
  return null;
121
- await ctx.db.patch(args.jobId, {
122
- status: "cancelled",
123
- updatedAt: Date.now(),
130
+ await appendEventCore(ctx, {
131
+ orgId,
132
+ runId: run.runId,
133
+ eventType: "run_cancelled",
134
+ dataJson: "{}",
124
135
  });
125
- if (job.workflowId) {
126
- await workflow.cancel(ctx, job.workflowId);
136
+ const bound = await workflowOfRun(ctx, orgId, run.runId);
137
+ if (bound !== null) {
138
+ await workflow.cancel(ctx, bound.workflowId);
127
139
  }
128
140
  return null;
129
141
  },
130
142
  });
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
- export const getJobInternal = internalQuery({
138
- args: { jobId: v.id("durableJobs") },
143
+ export const getJobInternalV2 = internalQuery({
144
+ args: { orgId: v.string(), runId: v.string() },
139
145
  returns: v.union(v.object({
140
146
  cursor: v.number(),
141
147
  totalSteps: v.number(),
@@ -143,43 +149,59 @@ export const getJobInternal = internalQuery({
143
149
  status: v.union(v.literal("running"), v.literal("completed"), v.literal("abandoned"), v.literal("cancelled")),
144
150
  }), v.null()),
145
151
  handler: async (ctx, args) => {
146
- const job = await ctx.db.get(args.jobId);
147
- if (!job)
152
+ const run = await ctx.db
153
+ .query("worldRuns")
154
+ .withIndex("by_org_run", (q) => q.eq("orgId", args.orgId).eq("runId", args.runId))
155
+ .unique();
156
+ if (!run)
148
157
  return null;
158
+ const job = await readJob(ctx, args.orgId, args.runId);
149
159
  return {
150
160
  cursor: job.cursor,
151
161
  totalSteps: job.totalSteps,
152
162
  maxAttemptsPerStep: job.maxAttemptsPerStep,
153
- status: job.status,
163
+ status: publicStatus(job.run.status),
154
164
  };
155
165
  },
156
166
  });
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
- export const runStepInternal = internalMutation({
164
- args: { jobId: v.id("durableJobs"), stepIndex: v.number() },
167
+ export const runStepInternalV2 = internalMutation({
168
+ args: { orgId: v.string(), runId: v.string(), stepIndex: v.number() },
165
169
  returns: v.null(),
166
170
  handler: async (ctx, args) => {
167
- const job = await ctx.db.get(args.jobId);
168
- if (!job)
169
- throw new Error("Job not found");
170
- if (job.status === "cancelled")
171
+ const job = await readJob(ctx, args.orgId, args.runId);
172
+ if (job.run.status === "cancelled")
171
173
  return null;
172
- if (args.stepIndex < job.cursor) {
173
- // Already-completed step replayed by a resumed workflow -- no-op.
174
+ const stepId = String(args.stepIndex);
175
+ const existing = await ctx.db
176
+ .query("worldSteps")
177
+ .withIndex("by_org_run_step", (q) => q.eq("orgId", args.orgId).eq("runId", args.runId).eq("stepId", stepId))
178
+ .unique();
179
+ if (existing?.status === "completed")
174
180
  return null;
175
- }
181
+ await appendEventCore(ctx, {
182
+ orgId: args.orgId,
183
+ runId: args.runId,
184
+ eventType: "step_created",
185
+ stepId,
186
+ dataJson: "{}",
187
+ });
188
+ await appendEventCore(ctx, {
189
+ orgId: args.orgId,
190
+ runId: args.runId,
191
+ eventType: "step_started",
192
+ stepId,
193
+ dataJson: "{}",
194
+ });
176
195
  let lastError;
177
196
  for (let attempt = 1; attempt <= job.maxAttemptsPerStep; attempt++) {
178
197
  try {
179
- await ctx.runMutation(job.stepFunctionHandle, { orgId: job.orgId, jobId: args.jobId, stepIndex: args.stepIndex });
180
- await ctx.db.patch(args.jobId, {
181
- cursor: args.stepIndex + 1,
182
- updatedAt: Date.now(),
198
+ await ctx.runMutation(job.stepFunctionHandle, { orgId: args.orgId, jobId: args.runId, stepIndex: args.stepIndex });
199
+ await appendEventCore(ctx, {
200
+ orgId: args.orgId,
201
+ runId: args.runId,
202
+ eventType: "step_completed",
203
+ stepId,
204
+ dataJson: JSON.stringify({ attempt }),
183
205
  });
184
206
  return null;
185
207
  }
@@ -192,71 +214,93 @@ export const runStepInternal = internalMutation({
192
214
  : new Error(String(lastError));
193
215
  },
194
216
  });
195
- export const markAbandonedInternal = internalMutation({
196
- args: { jobId: v.id("durableJobs"), stepIndex: v.number(), error: v.string() },
217
+ export const markAbandonedInternalV2 = internalMutation({
218
+ args: {
219
+ orgId: v.string(),
220
+ runId: v.string(),
221
+ stepIndex: v.number(),
222
+ error: v.string(),
223
+ },
197
224
  returns: v.null(),
198
225
  handler: async (ctx, args) => {
199
- const job = await ctx.db.get(args.jobId);
200
- if (!job || job.status !== "running")
226
+ const job = await readJob(ctx, args.orgId, args.runId);
227
+ if (job.run.status !== "running")
201
228
  return null;
202
- await ctx.db.patch(args.jobId, {
203
- status: "abandoned",
204
- abandonedAtStep: args.stepIndex,
205
- lastError: args.error,
206
- updatedAt: Date.now(),
229
+ const stepId = String(args.stepIndex);
230
+ await appendEventCore(ctx, {
231
+ orgId: args.orgId,
232
+ runId: args.runId,
233
+ eventType: "step_created",
234
+ stepId,
235
+ dataJson: "{}",
236
+ });
237
+ await appendEventCore(ctx, {
238
+ orgId: args.orgId,
239
+ runId: args.runId,
240
+ eventType: "step_failed",
241
+ stepId,
242
+ dataJson: JSON.stringify({ error: args.error }),
243
+ });
244
+ await appendEventCore(ctx, {
245
+ orgId: args.orgId,
246
+ runId: args.runId,
247
+ eventType: "run_failed",
248
+ dataJson: JSON.stringify({
249
+ reason: "abandoned",
250
+ abandonedAtStep: args.stepIndex,
251
+ lastError: args.error,
252
+ }),
207
253
  });
208
254
  return null;
209
255
  },
210
256
  });
211
- export const markCompletedInternal = internalMutation({
212
- args: { jobId: v.id("durableJobs") },
257
+ export const markCompletedInternalV2 = internalMutation({
258
+ args: { orgId: v.string(), runId: v.string() },
213
259
  returns: v.null(),
214
260
  handler: async (ctx, args) => {
215
- const job = await ctx.db.get(args.jobId);
216
- if (!job || job.status !== "running")
261
+ const job = await readJob(ctx, args.orgId, args.runId);
262
+ if (job.run.status !== "running")
217
263
  return null;
218
- await ctx.db.patch(args.jobId, {
219
- status: "completed",
220
- updatedAt: Date.now(),
264
+ await appendEventCore(ctx, {
265
+ orgId: args.orgId,
266
+ runId: args.runId,
267
+ eventType: "run_completed",
268
+ dataJson: "{}",
221
269
  });
222
270
  return null;
223
271
  },
224
272
  });
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
- export const durableJobWorkflowV1 = workflow.define({
234
- args: { jobId: v.id("durableJobs") },
273
+ export const durableJobWorkflowV2 = workflow.define({
274
+ args: { orgId: v.string(), runId: v.string() },
235
275
  returns: v.null(),
236
276
  handler: async (step, args) => {
237
- const job = await step.runQuery(internal.engine.durableJob.getJobInternal, {
238
- jobId: args.jobId,
277
+ const job = await step.runQuery(internal.engine.durableJob.getJobInternalV2, {
278
+ orgId: args.orgId,
279
+ runId: args.runId,
239
280
  });
240
281
  if (!job || job.status !== "running")
241
282
  return null;
242
283
  for (let i = job.cursor; i < job.totalSteps; i++) {
243
284
  try {
244
- await step.runMutation(internal.engine.durableJob.runStepInternal, {
245
- jobId: args.jobId,
285
+ await step.runMutation(internal.engine.durableJob.runStepInternalV2, {
286
+ orgId: args.orgId,
287
+ runId: args.runId,
246
288
  stepIndex: i,
247
289
  });
248
290
  }
249
291
  catch (err) {
250
- await step.runMutation(internal.engine.durableJob.markAbandonedInternal, {
251
- jobId: args.jobId,
292
+ await step.runMutation(internal.engine.durableJob.markAbandonedInternalV2, {
293
+ orgId: args.orgId,
294
+ runId: args.runId,
252
295
  stepIndex: i,
253
296
  error: err instanceof Error ? err.message : String(err),
254
297
  });
255
298
  return null;
256
299
  }
257
300
  }
258
- await step.runMutation(internal.engine.durableJob.markCompletedInternal, {
259
- jobId: args.jobId,
301
+ await step.runMutation(internal.engine.durableJob.markCompletedInternalV2, {
302
+ orgId: args.orgId,
303
+ runId: args.runId,
260
304
  });
261
305
  return null;
262
306
  },