@vantageos/agent-engine 0.1.0-alpha.5 → 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 +229 -40
- package/dist/component/_generated/api.d.ts +31 -0
- package/dist/component/_generated/component.d.ts +346 -2
- 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 +6 -2
- package/dist/component/engine/sessions.js +42 -5
- 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 +353 -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,262 @@
|
|
|
1
|
+
import { WorkflowManager } from "@convex-dev/workflow";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { components, internal } from "../_generated/api.js";
|
|
4
|
+
import { internalMutation, mutation, query } from "../_generated/server.js";
|
|
5
|
+
import { assertSameOrg, requireOrgId } from "../lib/auth.js";
|
|
6
|
+
// engine/sandboxCallback — C5: THE SIGNED CALLBACK GATE'S DB-SIDE LOGIC,
|
|
7
|
+
// PORTED from convex/engine/sandboxCallback.ts (repo-root prototype, I5,
|
|
8
|
+
// SYNTHESE.md §4.c-d, i0-research §8/§5) into the component boundary.
|
|
9
|
+
//
|
|
10
|
+
// NOTRE LIGNE (C5 recherche-d'abord, see component/schema.ts's C5 header
|
|
11
|
+
// comment for the measured verdict + citations):
|
|
12
|
+
// - The public HTTP route (`/sandbox/callback`) that verifies the
|
|
13
|
+
// `SANDBOX_CALLBACK_SECRET` HMAC signature over the raw body CANNOT
|
|
14
|
+
// live in this component (no official `@convex-dev/*` component ships
|
|
15
|
+
// an `http.ts`, and none reads `process.env` directly). It stays in
|
|
16
|
+
// the consumer app's `convex/http.ts`, which reads the secret, verifies
|
|
17
|
+
// the signature, and ONLY THEN calls `processCallback` /
|
|
18
|
+
// `deadLetterMalformed` below.
|
|
19
|
+
// - The "use node" action that talks to `@vercel/sandbox`
|
|
20
|
+
// (`Sandbox.create()` / `sandbox.runCommand()`) CANNOT live in this
|
|
21
|
+
// component either (Convex's own bundler hard-crashes on any "use
|
|
22
|
+
// node" file inside a child component). It stays app-level
|
|
23
|
+
// (convex/engine/sandboxBridgeActions.ts) and calls INTO `startRun` /
|
|
24
|
+
// `attachSandboxId` below (both PUBLIC `mutation`s, not
|
|
25
|
+
// `internalMutation`s, precisely so a cross-component caller can reach
|
|
26
|
+
// them — a component's `internal*` functions are unreachable from
|
|
27
|
+
// outside the component, see component/engine/mandates.ts's
|
|
28
|
+
// `spendInternal` for the same convention already established in C3).
|
|
29
|
+
//
|
|
30
|
+
// Everything below assumes the signature already checked out (verified by
|
|
31
|
+
// the caller, upstream of this module) — this module's job is dedup-by-
|
|
32
|
+
// delivery-id + the actual wake, both inside ONE mutation transaction so a
|
|
33
|
+
// replayed delivery can never cause a second `sendEvent`. `orgId` is an
|
|
34
|
+
// explicit ARGUMENT on every public function (component boundaries do not
|
|
35
|
+
// propagate `ctx.auth`, see component/lib/auth.ts).
|
|
36
|
+
//
|
|
37
|
+
// Same WorkflowManager component as engine/wakeup.ts / engine/workflows.ts.
|
|
38
|
+
const workflow = new WorkflowManager(components.workflow);
|
|
39
|
+
const wakeStatus = v.union(v.literal("completed"), v.literal("failed"));
|
|
40
|
+
// startRun — the DB-side half of launching a sandbox run (called by the
|
|
41
|
+
// app-level "use node" action, convex/engine/sandboxBridgeActions.ts,
|
|
42
|
+
// AFTER `engine.mandates.spend` has funded it, same two-transaction
|
|
43
|
+
// protocol as `engine.wakeup.enqueue`). Returns the row id so the caller
|
|
44
|
+
// can inject it into the sandbox's own env (SANDBOX_RUN_ID) before
|
|
45
|
+
// `Sandbox.create()`, and schedules the self-built awaitEvent timeout
|
|
46
|
+
// (workflow#177 pattern, same discipline as engine/awaitEventTimeout.ts)
|
|
47
|
+
// so a sandbox that never calls back — killed by the platform's own
|
|
48
|
+
// ceiling, crashed, or just lost — still wakes the workflow, in FAILURE,
|
|
49
|
+
// rather than leaving it asleep forever.
|
|
50
|
+
export const startRun = mutation({
|
|
51
|
+
args: {
|
|
52
|
+
orgId: v.string(),
|
|
53
|
+
workflowId: v.string(),
|
|
54
|
+
awaitedEventName: v.string(),
|
|
55
|
+
timeoutMs: v.number(),
|
|
56
|
+
},
|
|
57
|
+
returns: v.id("sandboxRuns"),
|
|
58
|
+
handler: async (ctx, args) => {
|
|
59
|
+
const orgId = requireOrgId(args.orgId);
|
|
60
|
+
if (args.workflowId.length === 0) {
|
|
61
|
+
throw new Error("Validation: workflowId must not be empty");
|
|
62
|
+
}
|
|
63
|
+
if (args.awaitedEventName.length === 0) {
|
|
64
|
+
throw new Error("Validation: awaitedEventName must not be empty");
|
|
65
|
+
}
|
|
66
|
+
if (args.timeoutMs <= 0) {
|
|
67
|
+
throw new Error("Validation: timeoutMs must be > 0");
|
|
68
|
+
}
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const runId = await ctx.db.insert("sandboxRuns", {
|
|
71
|
+
orgId,
|
|
72
|
+
workflowId: args.workflowId,
|
|
73
|
+
awaitedEventName: args.awaitedEventName,
|
|
74
|
+
sandboxId: "", // patched by attachSandboxId once Sandbox.create() resolves
|
|
75
|
+
status: "pending",
|
|
76
|
+
createdAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
});
|
|
79
|
+
await ctx.scheduler.runAfter(args.timeoutMs, internal.engine.sandboxCallback.timeoutInternal, {
|
|
80
|
+
sandboxRunId: runId,
|
|
81
|
+
});
|
|
82
|
+
return runId;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
// attachSandboxId — patches the Vercel-assigned sandbox id onto the row
|
|
86
|
+
// once `Sandbox.create()` returns it. Separate step because the row must
|
|
87
|
+
// exist BEFORE the sandbox is created (its id is one of the sandbox's own
|
|
88
|
+
// env vars, so the sandbox can name itself in its callback payload).
|
|
89
|
+
export const attachSandboxId = mutation({
|
|
90
|
+
args: { orgId: v.string(), sandboxRunId: v.id("sandboxRuns"), sandboxId: v.string() },
|
|
91
|
+
returns: v.null(),
|
|
92
|
+
handler: async (ctx, args) => {
|
|
93
|
+
const orgId = requireOrgId(args.orgId);
|
|
94
|
+
const run = await ctx.db.get(args.sandboxRunId);
|
|
95
|
+
assertSameOrg(orgId, run, "Sandbox run not found");
|
|
96
|
+
if (args.sandboxId.length === 0) {
|
|
97
|
+
throw new Error("Validation: sandboxId must not be empty");
|
|
98
|
+
}
|
|
99
|
+
await ctx.db.patch(args.sandboxRunId, {
|
|
100
|
+
sandboxId: args.sandboxId,
|
|
101
|
+
updatedAt: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// getStatus — client-facing read, same org-isolation discipline as every
|
|
107
|
+
// other status query in this engine.
|
|
108
|
+
export const getStatus = query({
|
|
109
|
+
args: { orgId: v.string(), sandboxRunId: v.id("sandboxRuns") },
|
|
110
|
+
returns: v.object({
|
|
111
|
+
status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed"), v.literal("timedOut")),
|
|
112
|
+
resultSummary: v.optional(v.string()),
|
|
113
|
+
}),
|
|
114
|
+
handler: async (ctx, args) => {
|
|
115
|
+
const orgId = requireOrgId(args.orgId);
|
|
116
|
+
const row = await ctx.db.get(args.sandboxRunId);
|
|
117
|
+
assertSameOrg(orgId, row, "Sandbox run not found");
|
|
118
|
+
return { status: row.status, resultSummary: row.resultSummary };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// deadLetterMalformed — reached from the consumer app's `convex/http.ts`
|
|
122
|
+
// when a SIGNATURE-VALID request's body is not valid JSON or is missing
|
|
123
|
+
// required fields. The signature already proved the caller holds the
|
|
124
|
+
// shared secret, so this is not a security refusal (that already happened
|
|
125
|
+
// upstream, in the app's own HTTP route, before this ever runs) — it is a
|
|
126
|
+
// shape problem worth a human's attention, recorded rather than dropped.
|
|
127
|
+
// `orgId` is optional: the payload may not have parsed far enough to know
|
|
128
|
+
// which org this delivery even claims to belong to.
|
|
129
|
+
export const deadLetterMalformed = mutation({
|
|
130
|
+
args: {
|
|
131
|
+
orgId: v.optional(v.string()),
|
|
132
|
+
deliveryId: v.string(),
|
|
133
|
+
reason: v.string(),
|
|
134
|
+
rawBody: v.string(),
|
|
135
|
+
},
|
|
136
|
+
returns: v.null(),
|
|
137
|
+
handler: async (ctx, args) => {
|
|
138
|
+
await ctx.db.insert("sandboxCallbackDeadLetters", {
|
|
139
|
+
orgId: args.orgId,
|
|
140
|
+
deliveryId: args.deliveryId,
|
|
141
|
+
reason: args.reason,
|
|
142
|
+
rawBody: args.rawBody,
|
|
143
|
+
createdAt: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// processCallback — THE WAKE, reached ONLY from the consumer app's
|
|
149
|
+
// `convex/http.ts` AFTER signature verification. `deliveryId` dedup is the
|
|
150
|
+
// FIRST read in this transaction, and the "woke" delivery row is inserted
|
|
151
|
+
// in the SAME transaction as the `sendEvent` call — Convex mutations are
|
|
152
|
+
// single transactions, so there is no window in which two concurrent
|
|
153
|
+
// replays of the same `deliveryId` could both observe "not yet delivered"
|
|
154
|
+
// and both wake the workflow. A delivery whose signature verified but
|
|
155
|
+
// whose `sandboxRunId` is unknown, cross-org, or already resolved is
|
|
156
|
+
// DEAD-LETTERED rather than silently dropped or blindly retried.
|
|
157
|
+
export const processCallback = mutation({
|
|
158
|
+
args: {
|
|
159
|
+
deliveryId: v.string(),
|
|
160
|
+
orgId: v.string(),
|
|
161
|
+
sandboxRunId: v.string(),
|
|
162
|
+
status: wakeStatus,
|
|
163
|
+
resultSummary: v.optional(v.string()),
|
|
164
|
+
},
|
|
165
|
+
returns: v.object({
|
|
166
|
+
outcome: v.union(v.literal("woke"), v.literal("duplicate"), v.literal("dead-lettered")),
|
|
167
|
+
}),
|
|
168
|
+
handler: async (ctx, args) => {
|
|
169
|
+
const orgId = requireOrgId(args.orgId);
|
|
170
|
+
if (args.deliveryId.length === 0) {
|
|
171
|
+
throw new Error("Validation: deliveryId must not be empty");
|
|
172
|
+
}
|
|
173
|
+
// Dedup FIRST, inside this same transaction.
|
|
174
|
+
const existingDelivery = await ctx.db
|
|
175
|
+
.query("sandboxCallbackDeliveries")
|
|
176
|
+
.withIndex("by_org_delivery", (q) => q.eq("orgId", orgId).eq("deliveryId", args.deliveryId))
|
|
177
|
+
.unique();
|
|
178
|
+
if (existingDelivery) {
|
|
179
|
+
return { outcome: "duplicate" };
|
|
180
|
+
}
|
|
181
|
+
const runId = args.sandboxRunId;
|
|
182
|
+
const run = await ctx.db.get(runId);
|
|
183
|
+
if (!run || run.orgId !== orgId) {
|
|
184
|
+
await ctx.db.insert("sandboxCallbackDeadLetters", {
|
|
185
|
+
orgId,
|
|
186
|
+
deliveryId: args.deliveryId,
|
|
187
|
+
reason: `Unknown or cross-org sandboxRunId "${args.sandboxRunId}"`,
|
|
188
|
+
rawBody: JSON.stringify(args),
|
|
189
|
+
createdAt: Date.now(),
|
|
190
|
+
});
|
|
191
|
+
return { outcome: "dead-lettered" };
|
|
192
|
+
}
|
|
193
|
+
if (run.status !== "pending") {
|
|
194
|
+
// Already resolved (completed/failed/timedOut) — a signature-valid
|
|
195
|
+
// callback for a run that is no longer pending is not a legitimate
|
|
196
|
+
// second wake; record it for human inspection instead of firing
|
|
197
|
+
// sendEvent again or throwing an unreadable error.
|
|
198
|
+
await ctx.db.insert("sandboxCallbackDeadLetters", {
|
|
199
|
+
orgId,
|
|
200
|
+
deliveryId: args.deliveryId,
|
|
201
|
+
reason: `sandboxRun ${run._id} already resolved with status "${run.status}"`,
|
|
202
|
+
rawBody: JSON.stringify(args),
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
return { outcome: "dead-lettered" };
|
|
206
|
+
}
|
|
207
|
+
await ctx.db.insert("sandboxCallbackDeliveries", {
|
|
208
|
+
orgId,
|
|
209
|
+
sandboxRunId: run._id,
|
|
210
|
+
deliveryId: args.deliveryId,
|
|
211
|
+
outcome: "woke",
|
|
212
|
+
receivedAt: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
await ctx.db.patch(run._id, {
|
|
215
|
+
status: args.status,
|
|
216
|
+
resultSummary: args.resultSummary,
|
|
217
|
+
updatedAt: Date.now(),
|
|
218
|
+
});
|
|
219
|
+
await workflow.sendEvent(ctx, {
|
|
220
|
+
workflowId: run.workflowId,
|
|
221
|
+
name: run.awaitedEventName,
|
|
222
|
+
validator: v.object({ status: wakeStatus, resultSummary: v.optional(v.string()) }),
|
|
223
|
+
value: { status: args.status, resultSummary: args.resultSummary },
|
|
224
|
+
});
|
|
225
|
+
return { outcome: "woke" };
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
// timeoutInternal — the self-built awaitEvent timeout (workflow#177
|
|
229
|
+
// pattern, same discipline as engine/awaitEventTimeout.ts), scoped to a
|
|
230
|
+
// sandbox run rather than a session. INTERNAL: reached only via
|
|
231
|
+
// `ctx.scheduler.runAfter` from `startRun` above, inside this SAME
|
|
232
|
+
// component — never a cross-component call, so `internalMutation` is
|
|
233
|
+
// correct here (unlike `startRun`/`attachSandboxId`/`processCallback`/
|
|
234
|
+
// `deadLetterMalformed`, which must be reachable from outside). Fires ONLY
|
|
235
|
+
// if the run is still "pending" when the deadline arrives (idempotent
|
|
236
|
+
// against a callback that lands in the same tick); wakes the workflow in a
|
|
237
|
+
// CLEAN FAILURE, not a silent hang.
|
|
238
|
+
export const timeoutInternal = internalMutation({
|
|
239
|
+
args: { sandboxRunId: v.id("sandboxRuns") },
|
|
240
|
+
returns: v.null(),
|
|
241
|
+
handler: async (ctx, args) => {
|
|
242
|
+
const run = await ctx.db.get(args.sandboxRunId);
|
|
243
|
+
if (!run || run.status !== "pending") {
|
|
244
|
+
return null; // already resolved by a real callback, or row gone
|
|
245
|
+
}
|
|
246
|
+
await ctx.db.patch(run._id, {
|
|
247
|
+
status: "timedOut",
|
|
248
|
+
resultSummary: "Sandbox run timed out before any signed callback arrived",
|
|
249
|
+
updatedAt: Date.now(),
|
|
250
|
+
});
|
|
251
|
+
await workflow.sendEvent(ctx, {
|
|
252
|
+
workflowId: run.workflowId,
|
|
253
|
+
name: run.awaitedEventName,
|
|
254
|
+
validator: v.object({ status: wakeStatus, resultSummary: v.optional(v.string()) }),
|
|
255
|
+
value: {
|
|
256
|
+
status: "failed",
|
|
257
|
+
resultSummary: "Sandbox run timed out before any signed callback arrived",
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return null;
|
|
261
|
+
},
|
|
262
|
+
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type { Id } from "../_generated/dataModel.js";
|
|
1
2
|
export declare const start: import("convex/server").RegisteredMutation<"public", {
|
|
2
3
|
orgId: string;
|
|
3
4
|
createdBy: string;
|
|
4
5
|
agentId: import("convex/values").GenericId<"agents">;
|
|
5
|
-
}, Promise<
|
|
6
|
+
}, Promise<{
|
|
7
|
+
sessionId: Id<"sessions">;
|
|
8
|
+
workflowId: string;
|
|
9
|
+
}>>;
|
|
6
10
|
export declare const sendTurn: import("convex/server").RegisteredMutation<"public", {
|
|
7
11
|
orgId: string;
|
|
8
12
|
sessionId: import("convex/values").GenericId<"sessions">;
|
|
@@ -39,7 +43,7 @@ export declare const listEvents: import("convex/server").RegisteredQuery<"public
|
|
|
39
43
|
}, Promise<{
|
|
40
44
|
_id: import("convex/values").GenericId<"sessionEvents">;
|
|
41
45
|
_creationTime: number;
|
|
42
|
-
name: "session.started" | "turn.started" | "turn.reasoning" | "turn.toolCall.requested" | "turn.toolCall.completed" | "turn.toolCall.denied" | "turn.completed" | "tool.approval.requested" | "tool.approval.resolved";
|
|
46
|
+
name: "session.started" | "turn.started" | "turn.reasoning" | "turn.toolCall.requested" | "turn.toolCall.completed" | "turn.toolCall.denied" | "turn.completed" | "tool.approval.requested" | "tool.approval.resolved" | "sandbox.timeout";
|
|
43
47
|
payload: string | undefined;
|
|
44
48
|
createdAt: number;
|
|
45
49
|
}[]>>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import { api } from "../_generated/api.js";
|
|
2
|
+
import { api, internal } from "../_generated/api.js";
|
|
3
3
|
import { mutation, query } from "../_generated/server.js";
|
|
4
4
|
import { assertSameOrg, requireOrgId } from "../lib/auth.js";
|
|
5
|
+
import { workflow } from "./workflows.js";
|
|
5
6
|
// engine/sessions — THE FIL DE CONVERSATION contract (T2-QUINQUIES). This
|
|
6
7
|
// is what unblocks EveVantage V4: open a session, send a turn, stream
|
|
7
8
|
// reasoning/tool calls, gate on tool approval, replay the thread on
|
|
@@ -23,11 +24,39 @@ async function insertEvent(ctx, args) {
|
|
|
23
24
|
createdAt: Date.now(),
|
|
24
25
|
});
|
|
25
26
|
}
|
|
27
|
+
const CURRENT_SESSION_WORKFLOW_VERSION = "v1";
|
|
26
28
|
// start — opens a session (session.started boundary), rereading the
|
|
27
|
-
// manifest immediately so a caller can act on it before the first turn
|
|
29
|
+
// manifest immediately so a caller can act on it before the first turn,
|
|
30
|
+
// then STARTS the durable, versioned session workflow
|
|
31
|
+
// (engine/workflows.ts:sessionWorkflowV1) that pilots this session from
|
|
32
|
+
// here on (C1.5 -- retires the T2-QUINQUIES "sessions are just an event
|
|
33
|
+
// table" divergence).
|
|
34
|
+
//
|
|
35
|
+
// RETURN CONTRACT ("notre ligne", C1.5): `start` now returns
|
|
36
|
+
// `{ sessionId, workflowId }` instead of a bare `sessionId`. A bare ID
|
|
37
|
+
// return was the pre-C1.5 shape when no workflow existed to reference;
|
|
38
|
+
// once a session IS piloted by a real, durable workflow run, silently
|
|
39
|
+
// dropping that run's id would make it unobservable/uncancellable to
|
|
40
|
+
// every caller except one that re-reads the session row. Returning both
|
|
41
|
+
// fields explicitly, rather than only patching `workflowId` onto the row
|
|
42
|
+
// and making callers re-fetch, keeps `start`'s result self-sufficient for
|
|
43
|
+
// the common "immediately act on the new session" case while the session
|
|
44
|
+
// row remains the durable source of truth for reconnects/replays.
|
|
28
45
|
export const start = mutation({
|
|
29
46
|
args: { orgId: v.string(), agentId: v.id("agents"), createdBy: v.string() },
|
|
30
|
-
returns: v.id("sessions"),
|
|
47
|
+
returns: v.object({ sessionId: v.id("sessions"), workflowId: v.string() }),
|
|
48
|
+
// EXPLICIT return-type annotation ("notre ligne" -- Convex pattern for
|
|
49
|
+
// any handler that references `components`/a WorkflowManager built on
|
|
50
|
+
// `components.workflow`): `workflow.start` closes over `components`,
|
|
51
|
+
// which closes back over this component's own generated `api` --
|
|
52
|
+
// `sessions.start` -> `workflow.start` -> `components` -> `sessions` is
|
|
53
|
+
// a type-level cycle. Without an explicit handler return type, TS
|
|
54
|
+
// widens the inferred return to `any` while resolving that cycle, and
|
|
55
|
+
// `.d.ts` EMISSION (declaration:true, tsconfig.build.json) fails --
|
|
56
|
+
// TS2502/TS2615/TS7022 -- even though `tsc --noEmit` at the repo root
|
|
57
|
+
// stays green (noEmit skips the exact codepath that breaks). Measured
|
|
58
|
+
// on PR #13 (Eta gate): `npm run build` is the build that SHIPS; a
|
|
59
|
+
// green `tsc --noEmit` is not sufficient proof by itself.
|
|
31
60
|
handler: async (ctx, args) => {
|
|
32
61
|
const orgId = requireOrgId(args.orgId);
|
|
33
62
|
if (args.createdBy.trim().length === 0) {
|
|
@@ -49,7 +78,12 @@ export const start = mutation({
|
|
|
49
78
|
orgId,
|
|
50
79
|
sessionId,
|
|
51
80
|
});
|
|
52
|
-
|
|
81
|
+
const workflowId = await workflow.start(ctx, internal.engine.workflows.sessionWorkflowV1, { sessionId });
|
|
82
|
+
await ctx.db.patch(sessionId, {
|
|
83
|
+
workflowId,
|
|
84
|
+
workflowVersion: CURRENT_SESSION_WORKFLOW_VERSION,
|
|
85
|
+
});
|
|
86
|
+
return { sessionId, workflowId };
|
|
53
87
|
},
|
|
54
88
|
});
|
|
55
89
|
// sendTurn — turn.started boundary. Rereads the manifest (eve parity: dynamic
|
|
@@ -237,7 +271,10 @@ export const listEvents = query({
|
|
|
237
271
|
returns: v.array(v.object({
|
|
238
272
|
_id: v.id("sessionEvents"),
|
|
239
273
|
_creationTime: v.number(),
|
|
240
|
-
name: v.union(v.literal("session.started"), v.literal("turn.started"), v.literal("turn.reasoning"), v.literal("turn.toolCall.requested"), v.literal("turn.toolCall.completed"), v.literal("turn.toolCall.denied"), v.literal("turn.completed"), v.literal("tool.approval.requested"), v.literal("tool.approval.resolved")
|
|
274
|
+
name: v.union(v.literal("session.started"), v.literal("turn.started"), v.literal("turn.reasoning"), v.literal("turn.toolCall.requested"), v.literal("turn.toolCall.completed"), v.literal("turn.toolCall.denied"), v.literal("turn.completed"), v.literal("tool.approval.requested"), v.literal("tool.approval.resolved"),
|
|
275
|
+
// C2 — blank event auto-inserted by
|
|
276
|
+
// `engine/awaitEventTimeout.fire` (workflow#177).
|
|
277
|
+
v.literal("sandbox.timeout")),
|
|
241
278
|
payload: v.optional(v.string()),
|
|
242
279
|
createdAt: v.number(),
|
|
243
280
|
})),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export declare const defaultToggles: {
|
|
2
|
+
readonly memoryEnabled: true;
|
|
3
|
+
readonly tasksEnabled: true;
|
|
4
|
+
readonly messagesEnabled: true;
|
|
5
|
+
readonly notesEnabled: true;
|
|
6
|
+
};
|
|
7
|
+
export declare const get: import("convex/server").RegisteredQuery<"public", {
|
|
8
|
+
orgId: string;
|
|
9
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
10
|
+
}, Promise<{
|
|
11
|
+
_id: import("convex/values").GenericId<"vpConnections">;
|
|
12
|
+
_creationTime: number;
|
|
13
|
+
orgId: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
17
|
+
memoryEnabled: boolean;
|
|
18
|
+
tasksEnabled: boolean;
|
|
19
|
+
messagesEnabled: boolean;
|
|
20
|
+
notesEnabled: boolean;
|
|
21
|
+
} | null>>;
|
|
22
|
+
export declare const getToggles: import("convex/server").RegisteredQuery<"public", {
|
|
23
|
+
orgId: string;
|
|
24
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
25
|
+
}, Promise<{
|
|
26
|
+
readonly memoryEnabled: true;
|
|
27
|
+
readonly tasksEnabled: true;
|
|
28
|
+
readonly messagesEnabled: true;
|
|
29
|
+
readonly notesEnabled: true;
|
|
30
|
+
} | {
|
|
31
|
+
memoryEnabled: boolean;
|
|
32
|
+
tasksEnabled: boolean;
|
|
33
|
+
messagesEnabled: boolean;
|
|
34
|
+
notesEnabled: boolean;
|
|
35
|
+
}>>;
|
|
36
|
+
export declare const setToggles: import("convex/server").RegisteredMutation<"public", {
|
|
37
|
+
memoryEnabled?: boolean | undefined;
|
|
38
|
+
tasksEnabled?: boolean | undefined;
|
|
39
|
+
messagesEnabled?: boolean | undefined;
|
|
40
|
+
notesEnabled?: boolean | undefined;
|
|
41
|
+
orgId: string;
|
|
42
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
43
|
+
}, Promise<import("convex/values").GenericId<"vpConnections">>>;
|
|
44
|
+
export declare const invokeVpTool: import("convex/server").RegisteredAction<"public", {
|
|
45
|
+
orgId: string;
|
|
46
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
47
|
+
tool: "memory" | "tasks" | "messages" | "notes";
|
|
48
|
+
argsJson: string;
|
|
49
|
+
vpFunctionHandle: string;
|
|
50
|
+
}, Promise<null>>;
|
|
51
|
+
export declare const getTogglesInternal: import("convex/server").RegisteredQuery<"internal", {
|
|
52
|
+
orgId: string;
|
|
53
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
54
|
+
}, Promise<{
|
|
55
|
+
readonly memoryEnabled: true;
|
|
56
|
+
readonly tasksEnabled: true;
|
|
57
|
+
readonly messagesEnabled: true;
|
|
58
|
+
readonly notesEnabled: true;
|
|
59
|
+
} | {
|
|
60
|
+
memoryEnabled: boolean;
|
|
61
|
+
tasksEnabled: boolean;
|
|
62
|
+
messagesEnabled: boolean;
|
|
63
|
+
notesEnabled: boolean;
|
|
64
|
+
} | null>>;
|
|
65
|
+
export declare const recordInvocationInternal: import("convex/server").RegisteredMutation<"internal", {
|
|
66
|
+
orgId: string;
|
|
67
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
68
|
+
tool: "memory" | "tasks" | "messages" | "notes";
|
|
69
|
+
}, Promise<null>>;
|
|
@@ -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
|
+
});
|