@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.
- package/README.md +33 -8
- package/dist/component/_generated/api.d.ts +14 -3
- package/dist/component/_generated/api.js +0 -25
- package/dist/component/_generated/component.d.ts +369 -73
- package/dist/component/_generated/component.js +0 -9
- package/dist/component/_generated/dataModel.js +0 -9
- package/dist/component/_generated/server.js +0 -69
- package/dist/component/convex.config.js +0 -44
- package/dist/component/engine/agents.d.ts +3 -3
- package/dist/component/engine/agents.js +0 -7
- package/dist/component/engine/awaitEventTimeout.d.ts +1 -1
- package/dist/component/engine/awaitEventTimeout.js +16 -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 +5 -5
- package/dist/component/engine/delegation.js +0 -74
- package/dist/component/engine/directory.js +0 -18
- package/dist/component/engine/durableJob.d.ts +24 -17
- package/dist/component/engine/durableJob.js +182 -138
- package/dist/component/engine/mandates.d.ts +2 -2
- 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 +2 -2
- 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 +130 -170
- 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 +7 -9
- package/dist/component/engine/wakeup.js +54 -127
- package/dist/component/engine/workflows.d.ts +26 -8
- package/dist/component/engine/workflows.js +59 -64
- package/dist/component/engine/world.d.ts +250 -0
- package/dist/component/engine/world.js +689 -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 +240 -175
- package/dist/component/schema.js +134 -294
- package/package.json +21 -6
- 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
|
@@ -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:
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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
|
|
39
|
-
|
|
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",
|
|
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
|
|
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
|
|
45
|
+
await insertEvent(ctx, {
|
|
67
46
|
orgId,
|
|
68
|
-
|
|
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
|
-
|
|
79
|
-
// silent "turn.toolCall.completed". See module doc above.
|
|
80
|
-
await ctx.db.insert("sessionEvents", {
|
|
56
|
+
await insertEvent(ctx, {
|
|
81
57
|
orgId,
|
|
82
|
-
|
|
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<
|
|
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<
|
|
20
|
+
}, Promise<string>>;
|
|
22
21
|
export declare const cancel: import("convex/server").RegisteredMutation<"public", {
|
|
23
22
|
orgId: string;
|
|
24
|
-
|
|
23
|
+
messageId: string;
|
|
25
24
|
}, Promise<null>>;
|
|
26
25
|
export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
|
|
27
26
|
orgId: string;
|
|
28
|
-
|
|
27
|
+
messageId: string;
|
|
29
28
|
}, Promise<{
|
|
30
|
-
status: "
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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.
|
|
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
|
|
146
|
-
const wakeableWorkId = await ctx.db.insert("wakeableWork", {
|
|
73
|
+
const { messageId } = await enqueueMessage(ctx, {
|
|
147
74
|
orgId,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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(),
|
|
113
|
+
args: { orgId: v.string(), messageId: v.string() },
|
|
178
114
|
returns: v.object({
|
|
179
|
-
status: v.union(v.literal("pending"), v.literal("
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
205
|
-
if (!
|
|
206
|
-
return;
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
220
|
-
name:
|
|
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()),
|