@vantageos/agent-engine 0.1.0-alpha.4 → 0.1.0-alpha.6
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 +285 -46
- package/dist/component/_generated/api.d.ts +31 -0
- package/dist/component/_generated/component.d.ts +365 -3
- package/dist/component/convex.config.js +19 -0
- package/dist/component/engine/agents.d.ts +10 -0
- package/dist/component/engine/agents.js +21 -0
- package/dist/component/engine/awaitEventTimeout.d.ts +13 -0
- package/dist/component/engine/awaitEventTimeout.js +82 -0
- package/dist/component/engine/delegation.d.ts +75 -0
- package/dist/component/engine/delegation.js +329 -0
- package/dist/component/engine/directory.d.ts +9 -0
- package/dist/component/engine/directory.js +45 -0
- package/dist/component/engine/habits.d.ts +70 -0
- package/dist/component/engine/habits.js +219 -0
- package/dist/component/engine/mandates.d.ts +74 -0
- package/dist/component/engine/mandates.js +272 -0
- package/dist/component/engine/sandboxCallback.d.ts +36 -0
- package/dist/component/engine/sandboxCallback.js +262 -0
- package/dist/component/engine/sessions.d.ts +7 -2
- package/dist/component/engine/sessions.js +75 -15
- package/dist/component/engine/toolApprovals.d.ts +2 -1
- package/dist/component/engine/toolApprovals.js +64 -26
- package/dist/component/engine/vpConnection.d.ts +69 -0
- package/dist/component/engine/vpConnection.js +221 -0
- package/dist/component/engine/wakeup.d.ts +33 -0
- package/dist/component/engine/wakeup.js +230 -0
- package/dist/component/engine/workflows.d.ts +11 -0
- package/dist/component/engine/workflows.js +87 -0
- package/dist/component/schema.d.ts +365 -4
- package/dist/component/schema.js +360 -1
- package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
- package/dist/component/testFixtures/delegationDemoWorkflow.js +76 -0
- package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +19 -0
- package/dist/component/testFixtures/sandboxDemoWorkflow.js +69 -0
- package/dist/component/testFixtures/vpTool.d.ts +4 -0
- package/dist/component/testFixtures/vpTool.js +25 -0
- package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +19 -0
- package/dist/component/testFixtures/wakeupDemoWorkflow.js +77 -0
- package/dist/component/testFixtures/wakeupWork.d.ts +8 -0
- package/dist/component/testFixtures/wakeupWork.js +21 -0
- package/package.json +2 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { action, internalMutation, internalQuery, mutation, query, } from "../_generated/server.js";
|
|
3
|
+
import { assertSameOrg, requireOrgId } from "../lib/auth.js";
|
|
4
|
+
import { internal } from "../_generated/api.js";
|
|
5
|
+
// engine/vpConnection — C6 (I6.b), mvp-evevantage.md §2.2 "Travail
|
|
6
|
+
// d'équipe". Ported from convex/engine/vpConnection.ts (repo-root
|
|
7
|
+
// prototype) into the component boundary (C6). The four VantagePeers tools
|
|
8
|
+
// (memory, tasks, messages, notes) are wired as a CONNECTION of the agent
|
|
9
|
+
// aggregate -- notre ligne, same slot family as `connections` (MCP/OpenAPI).
|
|
10
|
+
// Four toggles, ON by default.
|
|
11
|
+
//
|
|
12
|
+
// DIRECT ACCESS, NO NETWORK IN THE HOT PATH: `invokeVpTool` dispatches via a
|
|
13
|
+
// caller-supplied Convex function handle (`createFunctionHandle`), the same
|
|
14
|
+
// discipline `engine/wakeup.ts` and `engine/durableJob.ts` already use for
|
|
15
|
+
// `workFunctionHandle` / `stepFunctionHandle` -- an in-process function
|
|
16
|
+
// reference resolved by `ctx.runMutation`, never an HTTP request. grep proof
|
|
17
|
+
// (cited in the PR/README): this file contains zero occurrences of the
|
|
18
|
+
// literal fetch call.
|
|
19
|
+
//
|
|
20
|
+
// C6 orgId discipline (component/lib/auth.ts): every PUBLIC function takes
|
|
21
|
+
// `orgId` as an explicit ARGUMENT (auth does not propagate across a
|
|
22
|
+
// component boundary). `orgId` is passed EXPLICITLY to the target function
|
|
23
|
+
// handle (never trusting the target's own `ctx.auth`) -- this is the
|
|
24
|
+
// isolation guarantee the org-scoped write depends on: the target function
|
|
25
|
+
// is expected to write scoped to the `orgId` it was HANDED, not to derive
|
|
26
|
+
// one itself.
|
|
27
|
+
const vpTool = v.union(v.literal("memory"), v.literal("tasks"), v.literal("messages"), v.literal("notes"));
|
|
28
|
+
// defaultToggles — mvp §2.2: "quatre interrupteurs cochés par défaut". A
|
|
29
|
+
// missing `vpConnections` row means "use these defaults", so every agent
|
|
30
|
+
// starts with all four VP tools available with zero extra setup.
|
|
31
|
+
export const defaultToggles = {
|
|
32
|
+
memoryEnabled: true,
|
|
33
|
+
tasksEnabled: true,
|
|
34
|
+
messagesEnabled: true,
|
|
35
|
+
notesEnabled: true,
|
|
36
|
+
};
|
|
37
|
+
const vpConnectionDoc = v.object({
|
|
38
|
+
_id: v.id("vpConnections"),
|
|
39
|
+
_creationTime: v.number(),
|
|
40
|
+
orgId: v.string(),
|
|
41
|
+
agentId: v.id("agents"),
|
|
42
|
+
memoryEnabled: v.boolean(),
|
|
43
|
+
tasksEnabled: v.boolean(),
|
|
44
|
+
messagesEnabled: v.boolean(),
|
|
45
|
+
notesEnabled: v.boolean(),
|
|
46
|
+
createdAt: v.number(),
|
|
47
|
+
updatedAt: v.number(),
|
|
48
|
+
});
|
|
49
|
+
// get — the raw connection row, if a human ever touched the toggles.
|
|
50
|
+
export const get = query({
|
|
51
|
+
args: { orgId: v.string(), agentId: v.id("agents") },
|
|
52
|
+
returns: v.union(vpConnectionDoc, v.null()),
|
|
53
|
+
handler: async (ctx, args) => {
|
|
54
|
+
const orgId = requireOrgId(args.orgId);
|
|
55
|
+
const agent = await ctx.db.get(args.agentId);
|
|
56
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
57
|
+
return await ctx.db
|
|
58
|
+
.query("vpConnections")
|
|
59
|
+
.withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
|
|
60
|
+
.unique();
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
// getToggles — resolves the effective toggle state (row if present, else
|
|
64
|
+
// the checked-by-default set) for a given agent.
|
|
65
|
+
export const getToggles = query({
|
|
66
|
+
args: { orgId: v.string(), agentId: v.id("agents") },
|
|
67
|
+
returns: v.object({
|
|
68
|
+
memoryEnabled: v.boolean(),
|
|
69
|
+
tasksEnabled: v.boolean(),
|
|
70
|
+
messagesEnabled: v.boolean(),
|
|
71
|
+
notesEnabled: v.boolean(),
|
|
72
|
+
}),
|
|
73
|
+
handler: async (ctx, args) => {
|
|
74
|
+
const orgId = requireOrgId(args.orgId);
|
|
75
|
+
const agent = await ctx.db.get(args.agentId);
|
|
76
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
77
|
+
const row = await ctx.db
|
|
78
|
+
.query("vpConnections")
|
|
79
|
+
.withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
|
|
80
|
+
.unique();
|
|
81
|
+
return row
|
|
82
|
+
? {
|
|
83
|
+
memoryEnabled: row.memoryEnabled,
|
|
84
|
+
tasksEnabled: row.tasksEnabled,
|
|
85
|
+
messagesEnabled: row.messagesEnabled,
|
|
86
|
+
notesEnabled: row.notesEnabled,
|
|
87
|
+
}
|
|
88
|
+
: defaultToggles;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
// setToggles — upserts the four switches (an explicit human choice to turn
|
|
92
|
+
// one off; still ON by default until touched).
|
|
93
|
+
export const setToggles = mutation({
|
|
94
|
+
args: {
|
|
95
|
+
orgId: v.string(),
|
|
96
|
+
agentId: v.id("agents"),
|
|
97
|
+
memoryEnabled: v.optional(v.boolean()),
|
|
98
|
+
tasksEnabled: v.optional(v.boolean()),
|
|
99
|
+
messagesEnabled: v.optional(v.boolean()),
|
|
100
|
+
notesEnabled: v.optional(v.boolean()),
|
|
101
|
+
},
|
|
102
|
+
returns: v.id("vpConnections"),
|
|
103
|
+
handler: async (ctx, args) => {
|
|
104
|
+
const orgId = requireOrgId(args.orgId);
|
|
105
|
+
const agent = await ctx.db.get(args.agentId);
|
|
106
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
107
|
+
const existing = await ctx.db
|
|
108
|
+
.query("vpConnections")
|
|
109
|
+
.withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
|
|
110
|
+
.unique();
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const merged = {
|
|
113
|
+
memoryEnabled: args.memoryEnabled ?? existing?.memoryEnabled ?? defaultToggles.memoryEnabled,
|
|
114
|
+
tasksEnabled: args.tasksEnabled ?? existing?.tasksEnabled ?? defaultToggles.tasksEnabled,
|
|
115
|
+
messagesEnabled: args.messagesEnabled ?? existing?.messagesEnabled ?? defaultToggles.messagesEnabled,
|
|
116
|
+
notesEnabled: args.notesEnabled ?? existing?.notesEnabled ?? defaultToggles.notesEnabled,
|
|
117
|
+
};
|
|
118
|
+
if (existing) {
|
|
119
|
+
await ctx.db.patch(existing._id, { ...merged, updatedAt: now });
|
|
120
|
+
return existing._id;
|
|
121
|
+
}
|
|
122
|
+
return await ctx.db.insert("vpConnections", {
|
|
123
|
+
orgId,
|
|
124
|
+
agentId: args.agentId,
|
|
125
|
+
...merged,
|
|
126
|
+
createdAt: now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
function toggleFor(toggles, tool) {
|
|
132
|
+
return tool === "memory"
|
|
133
|
+
? toggles.memoryEnabled
|
|
134
|
+
: tool === "tasks"
|
|
135
|
+
? toggles.tasksEnabled
|
|
136
|
+
: tool === "messages"
|
|
137
|
+
? toggles.messagesEnabled
|
|
138
|
+
: toggles.notesEnabled;
|
|
139
|
+
}
|
|
140
|
+
// invokeVpTool — the direct-access dispatch. `vpFunctionHandle` is an
|
|
141
|
+
// opaque handle string obtained via `createFunctionHandle` against a
|
|
142
|
+
// mutation the VantagePeers tenant exposes (same-app or cross-app handle,
|
|
143
|
+
// per Convex's own `createFunctionHandle` contract -- never a URL, never an
|
|
144
|
+
// HTTP fetch call). The engine passes `orgId` EXPLICITLY as an argument
|
|
145
|
+
// (never trusting the target's own `ctx.auth`, since this may cross an
|
|
146
|
+
// app/component boundary) -- this is the isolation guarantee the org-scoped
|
|
147
|
+
// write depends on: the target function is expected to write scoped to the
|
|
148
|
+
// `orgId` it was HANDED, not to derive one itself.
|
|
149
|
+
export const invokeVpTool = action({
|
|
150
|
+
args: {
|
|
151
|
+
orgId: v.string(),
|
|
152
|
+
agentId: v.id("agents"),
|
|
153
|
+
tool: vpTool,
|
|
154
|
+
vpFunctionHandle: v.string(),
|
|
155
|
+
argsJson: v.string(),
|
|
156
|
+
},
|
|
157
|
+
returns: v.null(),
|
|
158
|
+
handler: async (ctx, args) => {
|
|
159
|
+
const orgId = requireOrgId(args.orgId);
|
|
160
|
+
const toggles = await ctx.runQuery(internal.engine.vpConnection.getTogglesInternal, {
|
|
161
|
+
orgId,
|
|
162
|
+
agentId: args.agentId,
|
|
163
|
+
});
|
|
164
|
+
if (!toggles) {
|
|
165
|
+
throw new Error("Agent not found");
|
|
166
|
+
}
|
|
167
|
+
if (!toggleFor(toggles, args.tool)) {
|
|
168
|
+
throw new Error(`VantagePeers tool "${args.tool}" is disabled for this agent`);
|
|
169
|
+
}
|
|
170
|
+
// In-process dispatch by function handle -- NOT an HTTP fetch call, NOT
|
|
171
|
+
// an HTTP hop. `orgId` is threaded explicitly so the write lands in the
|
|
172
|
+
// CALLING agent's org tenant regardless of which org's identity (if
|
|
173
|
+
// any) the target function's own `ctx.auth` would otherwise resolve.
|
|
174
|
+
await ctx.runMutation(args.vpFunctionHandle, { orgId, argsJson: args.argsJson });
|
|
175
|
+
await ctx.runMutation(internal.engine.vpConnection.recordInvocationInternal, {
|
|
176
|
+
orgId,
|
|
177
|
+
agentId: args.agentId,
|
|
178
|
+
tool: args.tool,
|
|
179
|
+
});
|
|
180
|
+
return null;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
export const getTogglesInternal = internalQuery({
|
|
184
|
+
args: { orgId: v.string(), agentId: v.id("agents") },
|
|
185
|
+
returns: v.union(v.object({
|
|
186
|
+
memoryEnabled: v.boolean(),
|
|
187
|
+
tasksEnabled: v.boolean(),
|
|
188
|
+
messagesEnabled: v.boolean(),
|
|
189
|
+
notesEnabled: v.boolean(),
|
|
190
|
+
}), v.null()),
|
|
191
|
+
handler: async (ctx, args) => {
|
|
192
|
+
const agent = await ctx.db.get(args.agentId);
|
|
193
|
+
if (!agent || agent.orgId !== args.orgId)
|
|
194
|
+
return null;
|
|
195
|
+
const row = await ctx.db
|
|
196
|
+
.query("vpConnections")
|
|
197
|
+
.withIndex("by_org_agent", (q) => q.eq("orgId", args.orgId).eq("agentId", args.agentId))
|
|
198
|
+
.unique();
|
|
199
|
+
return row
|
|
200
|
+
? {
|
|
201
|
+
memoryEnabled: row.memoryEnabled,
|
|
202
|
+
tasksEnabled: row.tasksEnabled,
|
|
203
|
+
messagesEnabled: row.messagesEnabled,
|
|
204
|
+
notesEnabled: row.notesEnabled,
|
|
205
|
+
}
|
|
206
|
+
: defaultToggles;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
export const recordInvocationInternal = internalMutation({
|
|
210
|
+
args: { orgId: v.string(), agentId: v.id("agents"), tool: vpTool },
|
|
211
|
+
returns: v.null(),
|
|
212
|
+
handler: async (ctx, args) => {
|
|
213
|
+
await ctx.db.insert("vpToolInvocations", {
|
|
214
|
+
orgId: args.orgId,
|
|
215
|
+
agentId: args.agentId,
|
|
216
|
+
tool: args.tool,
|
|
217
|
+
createdAt: Date.now(),
|
|
218
|
+
});
|
|
219
|
+
return null;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Workpool } from "@convex-dev/workpool";
|
|
2
|
+
import type { Id } from "../_generated/dataModel.js";
|
|
3
|
+
export declare const wakeupPool: Workpool;
|
|
4
|
+
export declare const enqueue: import("convex/server").RegisteredAction<"public", {
|
|
5
|
+
payload?: string | undefined;
|
|
6
|
+
orgId: string;
|
|
7
|
+
workflowId: string;
|
|
8
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
9
|
+
awaitedEventName: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
cost: number;
|
|
13
|
+
workFunctionHandle: string;
|
|
14
|
+
}, Promise<Id<"wakeableWork">>>;
|
|
15
|
+
export declare const enqueueInternal: import("convex/server").RegisteredMutation<"internal", {
|
|
16
|
+
payload?: string | undefined;
|
|
17
|
+
orgId: string;
|
|
18
|
+
workflowId: string;
|
|
19
|
+
awaitedEventName: string;
|
|
20
|
+
workFunctionHandle: string;
|
|
21
|
+
}, Promise<Id<"wakeableWork">>>;
|
|
22
|
+
export declare const cancel: import("convex/server").RegisteredMutation<"public", {
|
|
23
|
+
orgId: string;
|
|
24
|
+
wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
|
|
25
|
+
}, Promise<null>>;
|
|
26
|
+
export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
|
|
27
|
+
orgId: string;
|
|
28
|
+
wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
|
|
29
|
+
}, Promise<{
|
|
30
|
+
status: "completed" | "cancelled" | "pending" | "failed";
|
|
31
|
+
resultSummary: string | undefined;
|
|
32
|
+
}>>;
|
|
33
|
+
export declare const onWorkComplete: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workpool").OnCompleteArgs, null>;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Workpool } from "@convex-dev/workpool";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { WorkflowManager } from "@convex-dev/workflow";
|
|
4
|
+
import { components, internal } from "../_generated/api.js";
|
|
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 (I2, SYNTHESE.md §5: "Adam
|
|
8
|
+
// ne demande pas toutes les minutes s'il y a du travail -- il est réveillé
|
|
9
|
+
// quand du travail arrive"). PORTED from convex/engine/wakeup.ts (repo-root
|
|
10
|
+
// prototype) into the component boundary (C2). Mechanism: `enqueue` puts
|
|
11
|
+
// caller-supplied work (an action, referenced by an opaque handle string
|
|
12
|
+
// from `createFunctionHandle` in the CALLER's own app, same
|
|
13
|
+
// component-isolation discipline as `durableJob.stepFunctionHandle`) into a
|
|
14
|
+
// dedicated `@convex-dev/workpool` (`components.wakeupPool`, declared in
|
|
15
|
+
// component/convex.config.ts). That pool's `onComplete` callback -- which
|
|
16
|
+
// fires in its OWN transaction once the work resolves (I0 §2: "onComplete
|
|
17
|
+
// en transaction séparée, pas de pause native", workpool issue #133) --
|
|
18
|
+
// calls `workflow.sendEvent(...)` against the CALLER-supplied
|
|
19
|
+
// `workflowId`. Any workflow step parked on
|
|
20
|
+
// `step.awaitEvent({ name: awaitedEventName })` resumes the INSTANT that
|
|
21
|
+
// event lands -- no interval, no cron, no re-query loop. This fires for
|
|
22
|
+
// success, failure, AND cancellation alike: see `onWorkComplete` below -- a
|
|
23
|
+
// sleeping agent whose work silently died is worse than a polling one.
|
|
24
|
+
//
|
|
25
|
+
// Decoupled from `sessions`/`durableJobs` by design: any workflow (a
|
|
26
|
+
// session, a durable job, or a bespoke one) can be the wake target by
|
|
27
|
+
// handing us its own `workflowId` -- this table/module never reads either
|
|
28
|
+
// of those tables.
|
|
29
|
+
//
|
|
30
|
+
// ---------------------------------------------------------------------
|
|
31
|
+
// C2 MIGRATION NOTES ("notre ligne")
|
|
32
|
+
// ---------------------------------------------------------------------
|
|
33
|
+
//
|
|
34
|
+
// (A) orgId is an EXPLICIT ARGUMENT on every public function, never derived
|
|
35
|
+
// from `ctx.auth` -- auth does NOT propagate across a component boundary
|
|
36
|
+
// (see component/lib/auth.ts). The consumer app has already authenticated
|
|
37
|
+
// its caller and resolved orgId on its OWN side before calling in.
|
|
38
|
+
//
|
|
39
|
+
// (B) C3: MANDATE FUNDING CHOKE-POINT IS NOW WIRED. `enqueue` is an
|
|
40
|
+
// `action` because funding-then-starting is a TWO-TRANSACTION protocol a
|
|
41
|
+
// single mutation cannot express: `mandates.spendInternal` must be able to
|
|
42
|
+
// COMMIT an `approvalRequests` row on an overrun WITHOUT that commit being
|
|
43
|
+
// rolled back by a subsequent throw in the same transaction (see the
|
|
44
|
+
// transactional argument in `component/engine/mandates.ts:spendCore`,
|
|
45
|
+
// proven by the MUST_BLOCK probe in `component/engine/mandates.test.ts`).
|
|
46
|
+
// The action:
|
|
47
|
+
// 1. validates `orgId` (the ARGUMENT the caller supplies -- see note A;
|
|
48
|
+
// this component never reads `ctx.auth`),
|
|
49
|
+
// 2. runs `mandates.spendInternal` in its OWN transaction
|
|
50
|
+
// (`ctx.runMutation`),
|
|
51
|
+
// 3. throws immediately if `!result.ok` -- refuses end-to-end. The
|
|
52
|
+
// approval-request row from step 2 has ALREADY committed, because it
|
|
53
|
+
// lives in a transaction that already returned before this throw runs.
|
|
54
|
+
// 4. only on `result.ok === true` does it call `enqueueInternal` in a
|
|
55
|
+
// SECOND transaction to actually put the work in the pool.
|
|
56
|
+
// There is no code path from "client wants work started" to "work enters
|
|
57
|
+
// the pool" that does not pass through step 2 -- this is the "argument you
|
|
58
|
+
// cannot fabricate" form (Pi/Sigma, T4 lesson): the only way to make
|
|
59
|
+
// `enqueueInternal` run at all is to be `enqueue` itself, and `enqueue`
|
|
60
|
+
// always calls `mandates.spendInternal` first. Mirrors the app-level
|
|
61
|
+
// pre-C2 prototype (`convex/engine/wakeup.ts`) exactly, adapted to the
|
|
62
|
+
// orgId-argument discipline of this component boundary (`spendInternal`
|
|
63
|
+
// takes `orgId` as an argument rather than deriving it from `ctx.auth`).
|
|
64
|
+
export const wakeupPool = new Workpool(components.wakeupPool, {
|
|
65
|
+
maxParallelism: 10,
|
|
66
|
+
});
|
|
67
|
+
// Same WorkflowManager COMPONENT as engine/workflows.ts (components.workflow)
|
|
68
|
+
// -- a fresh WorkflowManager instance bound to the same component is safe
|
|
69
|
+
// to construct per-module; the manager itself holds no local state, it
|
|
70
|
+
// only forwards to the component.
|
|
71
|
+
const workflow = new WorkflowManager(components.workflow);
|
|
72
|
+
// ---------------------------------------------------------------------
|
|
73
|
+
// PUBLIC CONTRACT
|
|
74
|
+
// ---------------------------------------------------------------------
|
|
75
|
+
// enqueue — the ONLY client-facing entry point (C3: now an `action`, see
|
|
76
|
+
// note B above). Funds the work through `mandates.spendInternal` in its
|
|
77
|
+
// own transaction FIRST; only calls `enqueueInternal` (a second,
|
|
78
|
+
// independent transaction) once that funding succeeded.
|
|
79
|
+
export const enqueue = action({
|
|
80
|
+
args: {
|
|
81
|
+
orgId: v.string(),
|
|
82
|
+
agentId: v.id("agents"),
|
|
83
|
+
scope: v.string(),
|
|
84
|
+
cost: v.number(),
|
|
85
|
+
reason: v.string(),
|
|
86
|
+
workflowId: v.string(),
|
|
87
|
+
awaitedEventName: v.string(),
|
|
88
|
+
workFunctionHandle: v.string(),
|
|
89
|
+
payload: v.optional(v.string()),
|
|
90
|
+
},
|
|
91
|
+
returns: v.id("wakeableWork"),
|
|
92
|
+
handler: async (ctx, args) => {
|
|
93
|
+
// orgId is a caller-supplied ARGUMENT, validated here (not derived from
|
|
94
|
+
// ctx.auth -- component boundaries do not propagate auth, see note A
|
|
95
|
+
// and component/lib/auth.ts). Validated BEFORE the funding call so a
|
|
96
|
+
// missing orgId is refused with OUR message, not a downstream one.
|
|
97
|
+
const orgId = requireOrgId(args.orgId);
|
|
98
|
+
const result = await ctx.runMutation(internal.engine.mandates.spendInternal, {
|
|
99
|
+
orgId,
|
|
100
|
+
agentId: args.agentId,
|
|
101
|
+
scope: args.scope,
|
|
102
|
+
cost: args.cost,
|
|
103
|
+
reason: args.reason,
|
|
104
|
+
});
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
// REFUSED end-to-end: the approval request already committed inside
|
|
107
|
+
// `mandates.spendInternal`'s own transaction (step above); no
|
|
108
|
+
// further transaction runs, so the work never enters the pool.
|
|
109
|
+
throw new Error(`Mandate refused: agent ${args.agentId} has no remaining authority ` +
|
|
110
|
+
`for scope "${args.scope}" (cost ${args.cost}). Approval request ` +
|
|
111
|
+
`${result.approvalId} was created and is pending human review.`);
|
|
112
|
+
}
|
|
113
|
+
return await ctx.runMutation(internal.engine.wakeup.enqueueInternal, {
|
|
114
|
+
orgId,
|
|
115
|
+
workflowId: args.workflowId,
|
|
116
|
+
awaitedEventName: args.awaitedEventName,
|
|
117
|
+
workFunctionHandle: args.workFunctionHandle,
|
|
118
|
+
payload: args.payload,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
// enqueueInternal -- INTERNAL ONLY variant, kept for the C3 two-transaction
|
|
123
|
+
// protocol (a future funding `action` calls this in its SECOND transaction
|
|
124
|
+
// once `mandates.spend` returned `{ ok: true }`, same split as the
|
|
125
|
+
// app-level prototype). Unreachable from any client: Convex excludes
|
|
126
|
+
// `internal.*` functions from the API surface shipped to clients.
|
|
127
|
+
export const enqueueInternal = internalMutation({
|
|
128
|
+
args: {
|
|
129
|
+
orgId: v.string(),
|
|
130
|
+
workflowId: v.string(),
|
|
131
|
+
awaitedEventName: v.string(),
|
|
132
|
+
workFunctionHandle: v.string(),
|
|
133
|
+
payload: v.optional(v.string()),
|
|
134
|
+
},
|
|
135
|
+
returns: v.id("wakeableWork"),
|
|
136
|
+
handler: async (ctx, args) => {
|
|
137
|
+
const orgId = requireOrgId(args.orgId);
|
|
138
|
+
if (args.workflowId.length === 0) {
|
|
139
|
+
throw new Error("Validation: workflowId must not be empty");
|
|
140
|
+
}
|
|
141
|
+
if (args.awaitedEventName.length === 0) {
|
|
142
|
+
throw new Error("Validation: awaitedEventName must not be empty");
|
|
143
|
+
}
|
|
144
|
+
if (args.workFunctionHandle.length === 0) {
|
|
145
|
+
throw new Error("Validation: workFunctionHandle must not be empty");
|
|
146
|
+
}
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const wakeableWorkId = await ctx.db.insert("wakeableWork", {
|
|
149
|
+
orgId,
|
|
150
|
+
workflowId: args.workflowId,
|
|
151
|
+
awaitedEventName: args.awaitedEventName,
|
|
152
|
+
workId: "",
|
|
153
|
+
status: "pending",
|
|
154
|
+
createdAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
});
|
|
157
|
+
const workId = await wakeupPool.enqueueAction(ctx, args.workFunctionHandle, { orgId, payload: args.payload }, { onComplete: internal.engine.wakeup.onWorkComplete, context: { wakeableWorkId } });
|
|
158
|
+
await ctx.db.patch(wakeableWorkId, { workId });
|
|
159
|
+
return wakeableWorkId;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
// Best-effort cancel -- lets a caller pull work back out of the pool before
|
|
163
|
+
// it starts; the wake still fires (workpool reports `kind: "canceled"` to
|
|
164
|
+
// `onComplete`, see below) so the awaiting workflow is never left hanging.
|
|
165
|
+
export const cancel = mutation({
|
|
166
|
+
args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
|
|
167
|
+
returns: v.null(),
|
|
168
|
+
handler: async (ctx, args) => {
|
|
169
|
+
const orgId = requireOrgId(args.orgId);
|
|
170
|
+
const row = await ctx.db.get(args.wakeableWorkId);
|
|
171
|
+
assertSameOrg(orgId, row, "Wakeable work not found");
|
|
172
|
+
if (row.status !== "pending")
|
|
173
|
+
return null;
|
|
174
|
+
await wakeupPool.cancel(ctx, row.workId);
|
|
175
|
+
return null;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
export const getStatus = query({
|
|
179
|
+
args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
|
|
180
|
+
returns: v.object({
|
|
181
|
+
status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
|
|
182
|
+
resultSummary: v.optional(v.string()),
|
|
183
|
+
}),
|
|
184
|
+
handler: async (ctx, args) => {
|
|
185
|
+
const orgId = requireOrgId(args.orgId);
|
|
186
|
+
const row = await ctx.db.get(args.wakeableWorkId);
|
|
187
|
+
assertSameOrg(orgId, row, "Wakeable work not found");
|
|
188
|
+
return { status: row.status, resultSummary: row.resultSummary };
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
// ---------------------------------------------------------------------
|
|
192
|
+
// INTERNAL — the wake itself. Invoked ONLY by the workpool's own
|
|
193
|
+
// completion machinery (defineOnComplete), never by a client and never on
|
|
194
|
+
// a timer.
|
|
195
|
+
// ---------------------------------------------------------------------
|
|
196
|
+
// THE WAKE. This is the entire mechanism: onComplete -> sendEvent. It is a
|
|
197
|
+
// mutation the workpool component calls exactly once per work item, in its
|
|
198
|
+
// own transaction, at the moment the work resolves -- whichever way it
|
|
199
|
+
// resolves. All three `result.kind`s below reach `sendEvent`: a workflow
|
|
200
|
+
// asleep because its work vanished (failed/cancelled, no wake) would be
|
|
201
|
+
// strictly worse than one that polls.
|
|
202
|
+
const onWorkCompleteContext = v.object({ wakeableWorkId: v.id("wakeableWork") });
|
|
203
|
+
export const onWorkComplete = wakeupPool.defineOnComplete({
|
|
204
|
+
context: onWorkCompleteContext,
|
|
205
|
+
handler: async (ctx, { context, result }) => {
|
|
206
|
+
const row = await ctx.db.get(context.wakeableWorkId);
|
|
207
|
+
if (!row)
|
|
208
|
+
return; // work outlived its tracking row -- nothing to wake
|
|
209
|
+
const status = result.kind === "success"
|
|
210
|
+
? "completed"
|
|
211
|
+
: result.kind === "failed"
|
|
212
|
+
? "failed"
|
|
213
|
+
: "cancelled";
|
|
214
|
+
const resultSummary = result.kind === "failed" ? result.error : undefined;
|
|
215
|
+
await ctx.db.patch(row._id, {
|
|
216
|
+
status,
|
|
217
|
+
resultSummary,
|
|
218
|
+
updatedAt: Date.now(),
|
|
219
|
+
});
|
|
220
|
+
await workflow.sendEvent(ctx, {
|
|
221
|
+
workflowId: row.workflowId,
|
|
222
|
+
name: row.awaitedEventName,
|
|
223
|
+
validator: v.object({
|
|
224
|
+
status: v.union(v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
|
|
225
|
+
resultSummary: v.optional(v.string()),
|
|
226
|
+
}),
|
|
227
|
+
value: { status, resultSummary },
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { WorkflowManager } from "@convex-dev/workflow";
|
|
2
|
+
export declare const resolveManifestInternal: import("convex/server").RegisteredQuery<"internal", {
|
|
3
|
+
sessionId: import("convex/values").GenericId<"sessions">;
|
|
4
|
+
}, Promise<null>>;
|
|
5
|
+
export declare const recordTurnStartedInternal: import("convex/server").RegisteredMutation<"internal", {
|
|
6
|
+
sessionId: import("convex/values").GenericId<"sessions">;
|
|
7
|
+
}, Promise<null>>;
|
|
8
|
+
export declare const workflow: WorkflowManager;
|
|
9
|
+
export declare const sessionWorkflowV1: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workflow").WorkflowArgs<{
|
|
10
|
+
sessionId: import("convex/values").VId<import("convex/values").GenericId<"sessions">, "required">;
|
|
11
|
+
}>, import("@convex-dev/workflow").WorkflowId>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { WorkflowManager } from "@convex-dev/workflow";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { components, internal } from "../_generated/api.js";
|
|
4
|
+
import { internalMutation, internalQuery } from "../_generated/server.js";
|
|
5
|
+
// C1.5 — porting the durable, VERSIONED session workflow into the
|
|
6
|
+
// component boundary (ported from the repo-root prototype,
|
|
7
|
+
// convex/engine/workflows.ts). This is what makes `sessions.start` open a
|
|
8
|
+
// session PILOTED by `@convex-dev/workflow` instead of the T2-QUINQUIES
|
|
9
|
+
// "sessions are just an event table" divergence (component/schema.ts,
|
|
10
|
+
// sessions table comment retired alongside this file).
|
|
11
|
+
//
|
|
12
|
+
// Trust model at this boundary: the workflow engine runs with NO end-user
|
|
13
|
+
// identity and NO client-supplied orgId (component isolation, same
|
|
14
|
+
// discipline as component/engine/durableJob.ts and component/lib/auth.ts).
|
|
15
|
+
// Every internal step below reads orgId from the `sessions` row itself --
|
|
16
|
+
// never from an argument a workflow step could be handed by a caller.
|
|
17
|
+
// Internal (server-to-server only) variants of the resolver/session
|
|
18
|
+
// mutations, used by the workflow step handlers below. The workflow engine
|
|
19
|
+
// runs with no end-user identity, so these read/write scoped strictly by
|
|
20
|
+
// the orgId already recorded on the session row -- never trusting a
|
|
21
|
+
// client-supplied orgId.
|
|
22
|
+
export const resolveManifestInternal = internalQuery({
|
|
23
|
+
args: { sessionId: v.id("sessions") },
|
|
24
|
+
returns: v.null(),
|
|
25
|
+
handler: async (ctx, args) => {
|
|
26
|
+
const session = await ctx.db.get(args.sessionId);
|
|
27
|
+
if (!session)
|
|
28
|
+
throw new Error("Session not found");
|
|
29
|
+
const agent = await ctx.db.get(session.agentId);
|
|
30
|
+
if (!agent || agent.orgId !== session.orgId) {
|
|
31
|
+
throw new Error("Agent not found");
|
|
32
|
+
}
|
|
33
|
+
// Resolution result is consumed by the caller via the public
|
|
34
|
+
// engine/resolvers:resolveManifest query; this internal step exists to
|
|
35
|
+
// prove (and test) that the reread happens at each boundary.
|
|
36
|
+
return null;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
export const recordTurnStartedInternal = internalMutation({
|
|
40
|
+
args: { sessionId: v.id("sessions") },
|
|
41
|
+
returns: v.null(),
|
|
42
|
+
handler: async (ctx, args) => {
|
|
43
|
+
const session = await ctx.db.get(args.sessionId);
|
|
44
|
+
if (!session)
|
|
45
|
+
throw new Error("Session not found");
|
|
46
|
+
await ctx.db.insert("sessionEvents", {
|
|
47
|
+
orgId: session.orgId,
|
|
48
|
+
sessionId: args.sessionId,
|
|
49
|
+
name: "turn.started",
|
|
50
|
+
payload: undefined,
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
});
|
|
53
|
+
return null;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
// Versioned workflow discipline (workflow#35, I0 §7.1): "if you change much
|
|
57
|
+
// of anything in a workflow while there are ongoing workflows, they'll
|
|
58
|
+
// fail." We never edit a workflow function in place once sessions
|
|
59
|
+
// reference it -- a breaking change ships as `sessionWorkflowV2`, and the
|
|
60
|
+
// deploy is preceded by draining v1 sessions (0 rows with
|
|
61
|
+
// sessions.workflowVersion == "v1" and status == "running"). Same
|
|
62
|
+
// discipline as `engine/durableJob.ts:durableJobWorkflowV1`.
|
|
63
|
+
export const workflow = new WorkflowManager(components.workflow);
|
|
64
|
+
// sessionWorkflowV1 -- the durable session loop. At `session.started` and
|
|
65
|
+
// at every `turn.started` boundary it rereads the agent aggregate (never
|
|
66
|
+
// cached, never compiled) via `resolveManifestInternal`. Tool approvals
|
|
67
|
+
// pause the workflow via `step.awaitEvent`, guarded by the self-built
|
|
68
|
+
// timeout (workflow#177) once that surface is ported into this component.
|
|
69
|
+
export const sessionWorkflowV1 = workflow.define({
|
|
70
|
+
args: { sessionId: v.id("sessions") },
|
|
71
|
+
returns: v.null(),
|
|
72
|
+
handler: async (step, args) => {
|
|
73
|
+
// session.started boundary: read the aggregate once at start.
|
|
74
|
+
await step.runQuery(internal.engine.workflows.resolveManifestInternal, {
|
|
75
|
+
sessionId: args.sessionId,
|
|
76
|
+
});
|
|
77
|
+
// turn.started boundary: re-read on every turn so an agent/skill edit
|
|
78
|
+
// made mid-session is visible at the very next turn, with zero deploy.
|
|
79
|
+
await step.runMutation(internal.engine.workflows.recordTurnStartedInternal, {
|
|
80
|
+
sessionId: args.sessionId,
|
|
81
|
+
});
|
|
82
|
+
await step.runQuery(internal.engine.workflows.resolveManifestInternal, {
|
|
83
|
+
sessionId: args.sessionId,
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
},
|
|
87
|
+
});
|