@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
@@ -0,0 +1,162 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "../_generated/server.js";
3
+ import { requireOrgId } from "../lib/auth.js";
4
+ const streamInfoResult = v.object({ tailIndex: v.number(), done: v.boolean() });
5
+ const streamChunkResult = v.object({ index: v.number(), dataBase64: v.string() });
6
+ async function getStreamDoc(ctx, orgId, runId, name) {
7
+ return await ctx.db
8
+ .query("worldStreams")
9
+ .withIndex("by_org_run_name", (q) => q.eq("orgId", orgId).eq("runId", runId).eq("name", name))
10
+ .unique();
11
+ }
12
+ export const writeChunk = mutation({
13
+ args: { orgId: v.string(), runId: v.string(), name: v.string(), dataBase64: v.string() },
14
+ returns: v.null(),
15
+ handler: async (ctx, args) => {
16
+ const orgId = requireOrgId(args.orgId);
17
+ const now = Date.now();
18
+ let stream = await getStreamDoc(ctx, orgId, args.runId, args.name);
19
+ if (stream && stream.orgId !== orgId) {
20
+ stream = null;
21
+ }
22
+ if (!stream) {
23
+ const id = await ctx.db.insert("worldStreams", {
24
+ orgId,
25
+ runId: args.runId,
26
+ name: args.name,
27
+ chunkCount: 0,
28
+ done: false,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ });
32
+ stream = await ctx.db.get(id);
33
+ }
34
+ if (!stream) {
35
+ throw new Error("Stream row failed to materialise");
36
+ }
37
+ if (stream.done) {
38
+ throw new Error(`Cannot write to a closed stream: ${args.runId}/${args.name}`);
39
+ }
40
+ const index = stream.chunkCount;
41
+ await ctx.db.insert("worldStreamChunks", {
42
+ orgId,
43
+ runId: args.runId,
44
+ name: args.name,
45
+ index,
46
+ dataBase64: args.dataBase64,
47
+ createdAt: now,
48
+ });
49
+ await ctx.db.patch(stream._id, { chunkCount: index + 1, updatedAt: now });
50
+ return null;
51
+ },
52
+ });
53
+ export const closeStream = mutation({
54
+ args: { orgId: v.string(), runId: v.string(), name: v.string() },
55
+ returns: v.null(),
56
+ handler: async (ctx, args) => {
57
+ const orgId = requireOrgId(args.orgId);
58
+ const now = Date.now();
59
+ let stream = await getStreamDoc(ctx, orgId, args.runId, args.name);
60
+ if (stream && stream.orgId !== orgId) {
61
+ stream = null;
62
+ }
63
+ if (!stream) {
64
+ const id = await ctx.db.insert("worldStreams", {
65
+ orgId,
66
+ runId: args.runId,
67
+ name: args.name,
68
+ chunkCount: 0,
69
+ done: true,
70
+ createdAt: now,
71
+ updatedAt: now,
72
+ });
73
+ stream = await ctx.db.get(id);
74
+ }
75
+ else {
76
+ await ctx.db.patch(stream._id, { done: true, updatedAt: now });
77
+ }
78
+ return null;
79
+ },
80
+ });
81
+ export const getStreamInfo = query({
82
+ args: { orgId: v.string(), runId: v.string(), name: v.string() },
83
+ returns: streamInfoResult,
84
+ handler: async (ctx, args) => {
85
+ const orgId = requireOrgId(args.orgId);
86
+ const doc = await getStreamDoc(ctx, orgId, args.runId, args.name);
87
+ if (!doc || doc.orgId !== orgId) {
88
+ return { tailIndex: -1, done: false };
89
+ }
90
+ return { tailIndex: doc.chunkCount - 1, done: doc.done };
91
+ },
92
+ });
93
+ export const listStreamNames = query({
94
+ args: { orgId: v.string(), runId: v.string() },
95
+ returns: v.array(v.string()),
96
+ handler: async (ctx, args) => {
97
+ const orgId = requireOrgId(args.orgId);
98
+ const docs = await ctx.db
99
+ .query("worldStreams")
100
+ .withIndex("by_org_run", (q) => q.eq("orgId", orgId).eq("runId", args.runId))
101
+ .collect();
102
+ return docs.filter((d) => d.orgId === orgId).map((d) => d.name);
103
+ },
104
+ });
105
+ export const listChunksPage = query({
106
+ args: {
107
+ orgId: v.string(),
108
+ runId: v.string(),
109
+ name: v.string(),
110
+ limit: v.number(),
111
+ cursor: v.union(v.string(), v.null()),
112
+ },
113
+ returns: v.object({
114
+ page: v.array(streamChunkResult),
115
+ isDone: v.boolean(),
116
+ continueCursor: v.string(),
117
+ }),
118
+ handler: async (ctx, args) => {
119
+ const orgId = requireOrgId(args.orgId);
120
+ const startIndex = args.cursor === null ? 0 : Number(args.cursor);
121
+ const docs = await ctx.db
122
+ .query("worldStreamChunks")
123
+ .withIndex("by_org_run_name_index", (q) => q
124
+ .eq("orgId", orgId)
125
+ .eq("runId", args.runId)
126
+ .eq("name", args.name)
127
+ .gte("index", startIndex))
128
+ .order("asc")
129
+ .take(args.limit + 1);
130
+ const owned = docs.filter((d) => d.orgId === orgId);
131
+ const isDone = owned.length <= args.limit;
132
+ const page = isDone ? owned : owned.slice(0, args.limit);
133
+ return {
134
+ page: page.map((c) => ({ index: c.index, dataBase64: c.dataBase64 })),
135
+ isDone,
136
+ continueCursor: isDone ? "" : String(page[page.length - 1].index + 1),
137
+ };
138
+ },
139
+ });
140
+ export const listChunksFromIndex = query({
141
+ args: {
142
+ orgId: v.string(),
143
+ runId: v.string(),
144
+ name: v.string(),
145
+ startIndex: v.number(),
146
+ limit: v.number(),
147
+ },
148
+ returns: v.array(streamChunkResult),
149
+ handler: async (ctx, args) => {
150
+ const orgId = requireOrgId(args.orgId);
151
+ const docs = await ctx.db
152
+ .query("worldStreamChunks")
153
+ .withIndex("by_org_run_name_index", (q) => q
154
+ .eq("orgId", orgId)
155
+ .eq("runId", args.runId)
156
+ .eq("name", args.name)
157
+ .gte("index", args.startIndex))
158
+ .order("asc")
159
+ .take(args.limit);
160
+ return docs.filter((d) => d.orgId === orgId).map((c) => ({ index: c.index, dataBase64: c.dataBase64 }));
161
+ },
162
+ });
@@ -2,6 +2,6 @@ export declare const respond: import("convex/server").RegisteredMutation<"public
2
2
  updatePolicy?: "always" | "once" | "never" | undefined;
3
3
  orgId: string;
4
4
  toolName: string;
5
- sessionId: import("convex/values").GenericId<"sessions">;
5
+ sessionId: string;
6
6
  decision: "approved" | "denied";
7
7
  }, Promise<null>>;
@@ -1,33 +1,12 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation } from "../_generated/server.js";
3
- import { assertSameOrg, requireOrgId } from "../lib/auth.js";
4
- // engine/toolApprovals — respond to a pending approval request
5
- // (`tool.approval.requested`, raised by `sessions.requestToolCall`).
6
- //
7
- // TWO DISTINCT AXES (VP k17ea6x3tht8xa3rxm139t23gx8ajkf3, defaut 2 --
8
- // these were conflated pre-alpha.5, which is the defect this fixes):
9
- //
10
- // 1. `decision` -- the RESPONSE to THIS ONE pending approval request,
11
- // for THIS turn only. Vocabulary aligned with Eve's `ApprovalStatus`
12
- // (packages/eve/src/public/definitions/approval.ts: "approved" |
13
- // "denied" | ...) -- eve parity, cited verbatim, not "intentional divergence from Eve".
14
- // `"denied"` means: this turn's tool call is refused, full stop. It
15
- // does NOT change the agent's standing policy for the tool, and it
16
- // must NOT be interpreted as "tool skipped, turn completed anyway" --
17
- // it appends a `turn.toolCall.denied` sessionEvent instead of letting
18
- // the caller silently proceed to `completeToolCall`.
19
- //
20
- // 2. `updatePolicy` -- optional, persists a NEW standing policy
21
- // (always | once | never, eve parity: eve/tools/approval helpers) for
22
- // this (agent, tool) pair going forward, upserted on
23
- // `by_org_agent_tool`. Independent of `decision`: a human can deny
24
- // this turn's call AND leave the policy untouched (ask again next
25
- // time), or approve this turn AND simultaneously flip the policy to
26
- // "always"/"never" so future calls don't pause.
3
+ import { requireOrgId } from "../lib/auth.js";
4
+ import { insertEvent, loadSessionRun } from "./sessions.js";
5
+ import { agentIdOfRun } from "./world.js";
27
6
  export const respond = mutation({
28
7
  args: {
29
8
  orgId: v.string(),
30
- sessionId: v.id("sessions"),
9
+ sessionId: v.string(),
31
10
  toolName: v.string(),
32
11
  decision: v.union(v.literal("approved"), v.literal("denied")),
33
12
  updatePolicy: v.optional(v.union(v.literal("always"), v.literal("once"), v.literal("never"))),
@@ -35,15 +14,15 @@ export const respond = mutation({
35
14
  returns: v.null(),
36
15
  handler: async (ctx, args) => {
37
16
  const orgId = requireOrgId(args.orgId);
38
- const session = await ctx.db.get(args.sessionId);
39
- assertSameOrg(orgId, session, "Session not found");
17
+ const run = await loadSessionRun(ctx, orgId, args.sessionId);
18
+ const agentId = agentIdOfRun(ctx, run);
40
19
  const now = Date.now();
41
20
  if (args.updatePolicy !== undefined) {
42
21
  const existing = await ctx.db
43
22
  .query("toolApprovals")
44
23
  .withIndex("by_org_agent_tool", (q) => q
45
24
  .eq("orgId", orgId)
46
- .eq("agentId", session.agentId)
25
+ .eq("agentId", agentId)
47
26
  .eq("toolName", args.toolName))
48
27
  .unique();
49
28
  if (existing) {
@@ -55,7 +34,7 @@ export const respond = mutation({
55
34
  else {
56
35
  await ctx.db.insert("toolApprovals", {
57
36
  orgId,
58
- agentId: session.agentId,
37
+ agentId,
59
38
  toolName: args.toolName,
60
39
  approval: args.updatePolicy,
61
40
  createdAt: now,
@@ -63,26 +42,22 @@ export const respond = mutation({
63
42
  });
64
43
  }
65
44
  }
66
- await ctx.db.insert("sessionEvents", {
45
+ await insertEvent(ctx, {
67
46
  orgId,
68
- sessionId: args.sessionId,
47
+ run,
69
48
  name: "tool.approval.resolved",
70
49
  payload: JSON.stringify({
71
50
  toolName: args.toolName,
72
51
  decision: args.decision,
73
52
  updatedPolicy: args.updatePolicy ?? null,
74
53
  }),
75
- createdAt: now,
76
54
  });
77
55
  if (args.decision === "denied") {
78
- // Explicit refusal state for THIS turn's tool call -- never a
79
- // silent "turn.toolCall.completed". See module doc above.
80
- await ctx.db.insert("sessionEvents", {
56
+ await insertEvent(ctx, {
81
57
  orgId,
82
- sessionId: args.sessionId,
58
+ run,
83
59
  name: "turn.toolCall.denied",
84
60
  payload: JSON.stringify({ toolName: args.toolName }),
85
- createdAt: now,
86
61
  });
87
62
  }
88
63
  return null;
@@ -2,29 +2,7 @@ import { v } from "convex/values";
2
2
  import { action, internalMutation, internalQuery, mutation, query, } from "../_generated/server.js";
3
3
  import { assertSameOrg, requireOrgId } from "../lib/auth.js";
4
4
  import { internal } from "../_generated/api.js";
5
- // engine/vpConnection — teamwork. The four VantagePeers tools
6
- // (memory, tasks, messages, notes) are wired as a CONNECTION of the agent
7
- // aggregate -- intentional divergence from Eve, same slot family as `connections` (MCP/OpenAPI).
8
- // Four toggles, ON by default.
9
- //
10
- // DIRECT ACCESS, NO NETWORK IN THE HOT PATH: `invokeVpTool` dispatches via a
11
- // caller-supplied Convex function handle (`createFunctionHandle`), the same
12
- // discipline `engine/wakeup.ts` and `engine/durableJob.ts` already use for
13
- // `workFunctionHandle` / `stepFunctionHandle` -- an in-process function
14
- // reference resolved by `ctx.runMutation`, never an HTTP request: this file
15
- // contains zero occurrences of the literal fetch call.
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). `orgId` is passed EXPLICITLY to the target function
20
- // handle (never trusting the target's own `ctx.auth`) -- this is the
21
- // isolation guarantee the org-scoped write depends on: the target function
22
- // is expected to write scoped to the `orgId` it was HANDED, not to derive
23
- // one itself.
24
5
  const vpTool = v.union(v.literal("memory"), v.literal("tasks"), v.literal("messages"), v.literal("notes"));
25
- // defaultToggles — four switches, all ticked by default. A
26
- // missing `vpConnections` row means "use these defaults", so every agent
27
- // starts with all four VP tools available with zero extra setup.
28
6
  export const defaultToggles = {
29
7
  memoryEnabled: true,
30
8
  tasksEnabled: true,
@@ -43,7 +21,6 @@ const vpConnectionDoc = v.object({
43
21
  createdAt: v.number(),
44
22
  updatedAt: v.number(),
45
23
  });
46
- // get — the raw connection row, if a human ever touched the toggles.
47
24
  export const get = query({
48
25
  args: { orgId: v.string(), agentId: v.id("agents") },
49
26
  returns: v.union(vpConnectionDoc, v.null()),
@@ -57,8 +34,6 @@ export const get = query({
57
34
  .unique();
58
35
  },
59
36
  });
60
- // getToggles — resolves the effective toggle state (row if present, else
61
- // the checked-by-default set) for a given agent.
62
37
  export const getToggles = query({
63
38
  args: { orgId: v.string(), agentId: v.id("agents") },
64
39
  returns: v.object({
@@ -85,8 +60,6 @@ export const getToggles = query({
85
60
  : defaultToggles;
86
61
  },
87
62
  });
88
- // setToggles — upserts the four switches (an explicit human choice to turn
89
- // one off; still ON by default until touched).
90
63
  export const setToggles = mutation({
91
64
  args: {
92
65
  orgId: v.string(),
@@ -134,15 +107,6 @@ function toggleFor(toggles, tool) {
134
107
  ? toggles.messagesEnabled
135
108
  : toggles.notesEnabled;
136
109
  }
137
- // invokeVpTool — the direct-access dispatch. `vpFunctionHandle` is an
138
- // opaque handle string obtained via `createFunctionHandle` against a
139
- // mutation the VantagePeers tenant exposes (same-app or cross-app handle,
140
- // per Convex's own `createFunctionHandle` contract -- never a URL, never an
141
- // HTTP fetch call). The engine passes `orgId` EXPLICITLY as an argument
142
- // (never trusting the target's own `ctx.auth`, since this may cross an
143
- // app/component boundary) -- this is the isolation guarantee the org-scoped
144
- // write depends on: the target function is expected to write scoped to the
145
- // `orgId` it was HANDED, not to derive one itself.
146
110
  export const invokeVpTool = action({
147
111
  args: {
148
112
  orgId: v.string(),
@@ -164,10 +128,6 @@ export const invokeVpTool = action({
164
128
  if (!toggleFor(toggles, args.tool)) {
165
129
  throw new Error(`VantagePeers tool "${args.tool}" is disabled for this agent`);
166
130
  }
167
- // In-process dispatch by function handle -- NOT an HTTP fetch call, NOT
168
- // an HTTP hop. `orgId` is threaded explicitly so the write lands in the
169
- // CALLING agent's org tenant regardless of which org's identity (if
170
- // any) the target function's own `ctx.auth` would otherwise resolve.
171
131
  await ctx.runMutation(args.vpFunctionHandle, { orgId, argsJson: args.argsJson });
172
132
  await ctx.runMutation(internal.engine.vpConnection.recordInvocationInternal, {
173
133
  orgId,
@@ -1,33 +1,31 @@
1
1
  import { Workpool } from "@convex-dev/workpool";
2
- import type { Id } from "../_generated/dataModel.js";
3
2
  export declare const wakeupPool: Workpool;
4
3
  export declare const enqueue: import("convex/server").RegisteredAction<"public", {
5
4
  payload?: string | undefined;
6
5
  orgId: string;
7
- workflowId: string;
8
6
  agentId: import("convex/values").GenericId<"agents">;
9
7
  awaitedEventName: string;
10
8
  scope: string;
11
9
  reason: string;
10
+ workflowId: string;
12
11
  cost: number;
13
12
  workFunctionHandle: string;
14
- }, Promise<Id<"wakeableWork">>>;
13
+ }, Promise<string>>;
15
14
  export declare const enqueueInternal: import("convex/server").RegisteredMutation<"internal", {
16
15
  payload?: string | undefined;
17
16
  orgId: string;
18
- workflowId: string;
19
17
  awaitedEventName: string;
18
+ workflowId: string;
20
19
  workFunctionHandle: string;
21
- }, Promise<Id<"wakeableWork">>>;
20
+ }, Promise<string>>;
22
21
  export declare const cancel: import("convex/server").RegisteredMutation<"public", {
23
22
  orgId: string;
24
- wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
23
+ messageId: string;
25
24
  }, Promise<null>>;
26
25
  export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
27
26
  orgId: string;
28
- wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
27
+ messageId: string;
29
28
  }, Promise<{
30
- status: "completed" | "cancelled" | "pending" | "failed";
31
- resultSummary: string | undefined;
29
+ status: "failed" | "pending" | "delivered";
32
30
  }>>;
33
31
  export declare const onWorkComplete: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workpool").OnCompleteArgs, null>;
@@ -3,77 +3,17 @@ import { v } from "convex/values";
3
3
  import { WorkflowManager } from "@convex-dev/workflow";
4
4
  import { components, internal } from "../_generated/api.js";
5
5
  import { action, internalMutation, mutation, query } from "../_generated/server.js";
6
- import { assertSameOrg, requireOrgId } from "../lib/auth.js";
7
- // engine/wakeup — THE GENERIC WAKE-UP PRIMITIVE. An agent does not ask every
8
- // minute whether work has arrived: it is woken the instant the work resolves.
9
- // Mechanism: `enqueue` puts caller-supplied work (an action, referenced by
10
- // an opaque handle string
11
- // from `createFunctionHandle` in the CALLER's own app, same
12
- // component-isolation discipline as `durableJob.stepFunctionHandle`) into a
13
- // dedicated `@convex-dev/workpool` (`components.wakeupPool`, declared in
14
- // component/convex.config.ts). That pool's `onComplete` callback -- which
15
- // fires in its OWN transaction once the work resolves (workpool issue #133:
16
- // onComplete runs in a separate transaction; there is no native pause) --
17
- // calls `workflow.sendEvent(...)` against the CALLER-supplied
18
- // `workflowId`. Any workflow step parked on
19
- // `step.awaitEvent({ name: awaitedEventName })` resumes the INSTANT that
20
- // event lands -- no interval, no cron, no re-query loop. This fires for
21
- // success, failure, AND cancellation alike: see `onWorkComplete` below -- a
22
- // sleeping agent whose work silently died is worse than a polling one.
23
- //
24
- // Decoupled from `sessions`/`durableJobs` by design: any workflow (a
25
- // session, a durable job, or a bespoke one) can be the wake target by
26
- // handing us its own `workflowId` -- this table/module never reads either
27
- // of those tables.
28
- //
29
- // ---------------------------------------------------------------------
30
- // "intentional divergence from Eve"
31
- // ---------------------------------------------------------------------
32
- //
33
- // (A) orgId is an EXPLICIT ARGUMENT on every public function, never derived
34
- // from `ctx.auth` -- auth does NOT propagate across a component boundary
35
- // (see component/lib/auth.ts). The consumer app has already authenticated
36
- // its caller and resolved orgId on its OWN side before calling in.
37
- //
38
- // (B) MANDATE FUNDING CHOKE-POINT. `enqueue` is an
39
- // `action` because funding-then-starting is a TWO-TRANSACTION protocol a
40
- // single mutation cannot express: `mandates.spendInternal` must be able to
41
- // COMMIT an `approvalRequests` row on an overrun WITHOUT that commit being
42
- // rolled back by a subsequent throw in the same transaction (see the
43
- // transactional argument in `component/engine/mandates.ts:spendCore`,
44
- // proven by the MUST_BLOCK probe in `component/engine/mandates.test.ts`).
45
- // The action:
46
- // 1. validates `orgId` (the ARGUMENT the caller supplies -- see note A;
47
- // this component never reads `ctx.auth`),
48
- // 2. runs `mandates.spendInternal` in its OWN transaction
49
- // (`ctx.runMutation`),
50
- // 3. throws immediately if `!result.ok` -- refuses end-to-end. The
51
- // approval-request row from step 2 has ALREADY committed, because it
52
- // lives in a transaction that already returned before this throw runs.
53
- // 4. only on `result.ok === true` does it call `enqueueInternal` in a
54
- // SECOND transaction to actually put the work in the pool.
55
- // There is no code path from "client wants work started" to "work enters
56
- // the pool" that does not pass through step 2 -- this is an "argument you
57
- // cannot fabricate" pattern: the only way to make
58
- // `enqueueInternal` run at all is to be `enqueue` itself, and `enqueue`
59
- // always calls `mandates.spendInternal` first. `spendInternal` takes
60
- // `orgId` as an argument rather than deriving it from `ctx.auth`, per the
61
- // boundary discipline in note A.
6
+ import { requireOrgId } from "../lib/auth.js";
7
+ import { enqueueMessage, getMessageById, setMessagePayload, settleMessageById } from "./queue.js";
8
+ const WAKEUP_QUEUE_NAME = "engine_wakeup";
9
+ function newWakeupIdempotencyKey() {
10
+ return crypto.randomUUID();
11
+ }
12
+ const QUEUE_ERR_NOT_FOUND = "Wakeable work not found";
62
13
  export const wakeupPool = new Workpool(components.wakeupPool, {
63
14
  maxParallelism: 10,
64
15
  });
65
- // Same WorkflowManager COMPONENT as engine/workflows.ts (components.workflow)
66
- // -- a fresh WorkflowManager instance bound to the same component is safe
67
- // to construct per-module; the manager itself holds no local state, it
68
- // only forwards to the component.
69
16
  const workflow = new WorkflowManager(components.workflow);
70
- // ---------------------------------------------------------------------
71
- // PUBLIC CONTRACT
72
- // ---------------------------------------------------------------------
73
- // enqueue — the ONLY client-facing entry point (an `action`, see note B
74
- // above). Funds the work through `mandates.spendInternal` in its
75
- // own transaction FIRST; only calls `enqueueInternal` (a second,
76
- // independent transaction) once that funding succeeded.
77
17
  export const enqueue = action({
78
18
  args: {
79
19
  orgId: v.string(),
@@ -86,12 +26,8 @@ export const enqueue = action({
86
26
  workFunctionHandle: v.string(),
87
27
  payload: v.optional(v.string()),
88
28
  },
89
- returns: v.id("wakeableWork"),
29
+ returns: v.string(),
90
30
  handler: async (ctx, args) => {
91
- // orgId is a caller-supplied ARGUMENT, validated here (not derived from
92
- // ctx.auth -- component boundaries do not propagate auth, see note A
93
- // and component/lib/auth.ts). Validated BEFORE the funding call so a
94
- // missing orgId is refused with OUR message, not a downstream one.
95
31
  const orgId = requireOrgId(args.orgId);
96
32
  const result = await ctx.runMutation(internal.engine.mandates.spendInternal, {
97
33
  orgId,
@@ -101,9 +37,6 @@ export const enqueue = action({
101
37
  reason: args.reason,
102
38
  });
103
39
  if (!result.ok) {
104
- // REFUSED end-to-end: the approval request already committed inside
105
- // `mandates.spendInternal`'s own transaction (step above); no
106
- // further transaction runs, so the work never enters the pool.
107
40
  throw new Error(`Mandate refused: agent ${args.agentId} has no remaining authority ` +
108
41
  `for scope "${args.scope}" (cost ${args.cost}). Approval request ` +
109
42
  `${result.approvalId} was created and is pending human review.`);
@@ -117,11 +50,6 @@ export const enqueue = action({
117
50
  });
118
51
  },
119
52
  });
120
- // enqueueInternal -- INTERNAL ONLY variant, kept for the two-transaction
121
- // funding protocol (`enqueue` calls this in its SECOND transaction
122
- // once `mandates.spendInternal` returned `{ ok: true }`, same split as the
123
- // app-level prototype). Unreachable from any client: Convex excludes
124
- // `internal.*` functions from the API surface shipped to clients.
125
53
  export const enqueueInternal = internalMutation({
126
54
  args: {
127
55
  orgId: v.string(),
@@ -130,7 +58,7 @@ export const enqueueInternal = internalMutation({
130
58
  workFunctionHandle: v.string(),
131
59
  payload: v.optional(v.string()),
132
60
  },
133
- returns: v.id("wakeableWork"),
61
+ returns: v.string(),
134
62
  handler: async (ctx, args) => {
135
63
  const orgId = requireOrgId(args.orgId);
136
64
  if (args.workflowId.length === 0) {
@@ -142,82 +70,81 @@ export const enqueueInternal = internalMutation({
142
70
  if (args.workFunctionHandle.length === 0) {
143
71
  throw new Error("Validation: workFunctionHandle must not be empty");
144
72
  }
145
- const now = Date.now();
146
- const wakeableWorkId = await ctx.db.insert("wakeableWork", {
73
+ const { messageId } = await enqueueMessage(ctx, {
147
74
  orgId,
148
- workflowId: args.workflowId,
149
- awaitedEventName: args.awaitedEventName,
150
- workId: "",
151
- status: "pending",
152
- createdAt: now,
153
- updatedAt: now,
75
+ queueName: WAKEUP_QUEUE_NAME,
76
+ payloadJson: JSON.stringify({
77
+ workflowId: args.workflowId,
78
+ awaitedEventName: args.awaitedEventName,
79
+ workId: "",
80
+ }),
81
+ idempotencyKey: newWakeupIdempotencyKey(),
82
+ });
83
+ const workId = await wakeupPool.enqueueAction(ctx, args.workFunctionHandle, { orgId, payload: args.payload }, { onComplete: internal.engine.wakeup.onWorkComplete, context: { orgId, messageId } });
84
+ await setMessagePayload(ctx, {
85
+ orgId,
86
+ messageId,
87
+ payloadJson: JSON.stringify({
88
+ workflowId: args.workflowId,
89
+ awaitedEventName: args.awaitedEventName,
90
+ workId,
91
+ }),
154
92
  });
155
- const workId = await wakeupPool.enqueueAction(ctx, args.workFunctionHandle, { orgId, payload: args.payload }, { onComplete: internal.engine.wakeup.onWorkComplete, context: { wakeableWorkId } });
156
- await ctx.db.patch(wakeableWorkId, { workId });
157
- return wakeableWorkId;
93
+ return messageId;
158
94
  },
159
95
  });
160
- // Best-effort cancel -- lets a caller pull work back out of the pool before
161
- // it starts; the wake still fires (workpool reports `kind: "canceled"` to
162
- // `onComplete`, see below) so the awaiting workflow is never left hanging.
163
96
  export const cancel = mutation({
164
- args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
97
+ args: { orgId: v.string(), messageId: v.string() },
165
98
  returns: v.null(),
166
99
  handler: async (ctx, args) => {
167
100
  const orgId = requireOrgId(args.orgId);
168
- const row = await ctx.db.get(args.wakeableWorkId);
169
- assertSameOrg(orgId, row, "Wakeable work not found");
170
- if (row.status !== "pending")
101
+ const msg = await getMessageById(ctx, { orgId, messageId: args.messageId });
102
+ if (!msg)
103
+ throw new Error(QUEUE_ERR_NOT_FOUND);
104
+ if (msg.status !== "pending")
171
105
  return null;
172
- await wakeupPool.cancel(ctx, row.workId);
106
+ const { workId } = JSON.parse(msg.payloadJson);
107
+ await wakeupPool.cancel(ctx, workId);
108
+ await settleMessageById(ctx, { orgId, messageId: args.messageId, status: "failed" });
173
109
  return null;
174
110
  },
175
111
  });
176
112
  export const getStatus = query({
177
- args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
113
+ args: { orgId: v.string(), messageId: v.string() },
178
114
  returns: v.object({
179
- status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
180
- resultSummary: v.optional(v.string()),
115
+ status: v.union(v.literal("pending"), v.literal("delivered"), v.literal("failed")),
181
116
  }),
182
117
  handler: async (ctx, args) => {
183
118
  const orgId = requireOrgId(args.orgId);
184
- const row = await ctx.db.get(args.wakeableWorkId);
185
- assertSameOrg(orgId, row, "Wakeable work not found");
186
- return { status: row.status, resultSummary: row.resultSummary };
119
+ const msg = await getMessageById(ctx, { orgId, messageId: args.messageId });
120
+ if (!msg)
121
+ throw new Error(QUEUE_ERR_NOT_FOUND);
122
+ const status = msg.status === "delivered" ? "delivered" : msg.status === "failed" ? "failed" : "pending";
123
+ return { status };
187
124
  },
188
125
  });
189
- // ---------------------------------------------------------------------
190
- // INTERNAL — the wake itself. Invoked ONLY by the workpool's own
191
- // completion machinery (defineOnComplete), never by a client and never on
192
- // a timer.
193
- // ---------------------------------------------------------------------
194
- // THE WAKE. This is the entire mechanism: onComplete -> sendEvent. It is a
195
- // mutation the workpool component calls exactly once per work item, in its
196
- // own transaction, at the moment the work resolves -- whichever way it
197
- // resolves. All three `result.kind`s below reach `sendEvent`: a workflow
198
- // asleep because its work vanished (failed/cancelled, no wake) would be
199
- // strictly worse than one that polls.
200
- const onWorkCompleteContext = v.object({ wakeableWorkId: v.id("wakeableWork") });
126
+ const onWorkCompleteContext = v.object({ orgId: v.string(), messageId: v.string() });
201
127
  export const onWorkComplete = wakeupPool.defineOnComplete({
202
128
  context: onWorkCompleteContext,
203
129
  handler: async (ctx, { context, result }) => {
204
- const row = await ctx.db.get(context.wakeableWorkId);
205
- if (!row)
206
- return; // work outlived its tracking row -- nothing to wake
130
+ const msg = await getMessageById(ctx, context);
131
+ if (!msg)
132
+ return;
133
+ const { workflowId, awaitedEventName } = JSON.parse(msg.payloadJson);
207
134
  const status = result.kind === "success"
208
135
  ? "completed"
209
136
  : result.kind === "failed"
210
137
  ? "failed"
211
138
  : "cancelled";
212
139
  const resultSummary = result.kind === "failed" ? result.error : undefined;
213
- await ctx.db.patch(row._id, {
214
- status,
215
- resultSummary,
216
- updatedAt: Date.now(),
140
+ await settleMessageById(ctx, {
141
+ orgId: context.orgId,
142
+ messageId: context.messageId,
143
+ status: status === "completed" ? "delivered" : "failed",
217
144
  });
218
145
  await workflow.sendEvent(ctx, {
219
- workflowId: row.workflowId,
220
- name: row.awaitedEventName,
146
+ workflowId: workflowId,
147
+ name: awaitedEventName,
221
148
  validator: v.object({
222
149
  status: v.union(v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
223
150
  resultSummary: v.optional(v.string()),