@vantageos/agent-engine 0.1.0-alpha.5 → 0.1.0-alpha.7
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/LICENSE +110 -0
- package/README.md +154 -473
- package/dist/component/_generated/api.d.ts +31 -0
- package/dist/component/_generated/component.d.ts +346 -2
- package/dist/component/convex.config.js +18 -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 +81 -0
- package/dist/component/engine/delegation.d.ts +75 -0
- package/dist/component/engine/delegation.js +327 -0
- package/dist/component/engine/directory.d.ts +9 -0
- package/dist/component/engine/directory.js +45 -0
- package/dist/component/engine/durableJob.js +6 -6
- package/dist/component/engine/habits.d.ts +70 -0
- package/dist/component/engine/habits.js +218 -0
- package/dist/component/engine/mandates.d.ts +74 -0
- package/dist/component/engine/mandates.js +271 -0
- package/dist/component/engine/sandboxCallback.d.ts +36 -0
- package/dist/component/engine/sandboxCallback.js +259 -0
- package/dist/component/engine/sessions.d.ts +6 -2
- package/dist/component/engine/sessions.js +43 -7
- package/dist/component/engine/toolApprovals.js +1 -1
- package/dist/component/engine/vpConnection.d.ts +69 -0
- package/dist/component/engine/vpConnection.js +218 -0
- package/dist/component/engine/wakeup.d.ts +33 -0
- package/dist/component/engine/wakeup.js +228 -0
- package/dist/component/engine/workflows.d.ts +11 -0
- package/dist/component/engine/workflows.js +84 -0
- package/dist/component/schema.d.ts +365 -4
- package/dist/component/schema.js +345 -18
- package/dist/component/testFixtures/callerSteps.js +1 -1
- package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
- package/dist/component/testFixtures/delegationDemoWorkflow.js +75 -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 +17 -5
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { Crons } from "@convex-dev/crons";
|
|
3
|
+
import { internal, components } from "../_generated/api.js";
|
|
4
|
+
import { internalMutation, mutation, query } from "../_generated/server.js";
|
|
5
|
+
import { assertSameOrg, requireOrgId } from "../lib/auth.js";
|
|
6
|
+
// engine/habits — "intentional divergence from Eve": a habit is what turns
|
|
7
|
+
// a chat into an employee. A habit is a DYNAMIC cron per agent/org,
|
|
8
|
+
// registered at runtime via @convex-dev/crons — never a static
|
|
9
|
+
// `convex/crons.ts`. Creating, running, editing or disabling one deploys
|
|
10
|
+
// nothing: the `cronExpression` is DATA carried by the `habits` row, and
|
|
11
|
+
// the runtime cron is merely a reflection of it.
|
|
12
|
+
//
|
|
13
|
+
// orgId discipline (component/lib/auth.ts): every PUBLIC function takes
|
|
14
|
+
// `orgId` as an explicit ARGUMENT (auth does not propagate across a
|
|
15
|
+
// component boundary), same as engine/mandates.ts. The cron runtime
|
|
16
|
+
// target is `internal.engine.habits.run` -- the component-boundary path
|
|
17
|
+
// for this module, mirroring `internal.habits.run` at the app level.
|
|
18
|
+
const crons = new Crons(components.crons);
|
|
19
|
+
const habitDoc = v.object({
|
|
20
|
+
_id: v.id("habits"),
|
|
21
|
+
_creationTime: v.number(),
|
|
22
|
+
orgId: v.string(),
|
|
23
|
+
agentId: v.id("agents"),
|
|
24
|
+
name: v.string(),
|
|
25
|
+
cronExpression: v.string(),
|
|
26
|
+
prompt: v.string(),
|
|
27
|
+
enabled: v.boolean(),
|
|
28
|
+
cronJobId: v.optional(v.string()),
|
|
29
|
+
lastRunAt: v.optional(v.number()),
|
|
30
|
+
createdAt: v.number(),
|
|
31
|
+
updatedAt: v.number(),
|
|
32
|
+
});
|
|
33
|
+
// create — writes the habit row AND registers its runtime cron in the same
|
|
34
|
+
// transaction. @convex-dev/crons guarantees the cron "is guaranteed to
|
|
35
|
+
// exist after the mutation that creates them has run" (README) -- so the
|
|
36
|
+
// very next tick can fire it, with zero deploy.
|
|
37
|
+
export const create = mutation({
|
|
38
|
+
args: {
|
|
39
|
+
orgId: v.string(),
|
|
40
|
+
agentId: v.id("agents"),
|
|
41
|
+
name: v.string(),
|
|
42
|
+
cronExpression: v.string(),
|
|
43
|
+
prompt: v.string(),
|
|
44
|
+
},
|
|
45
|
+
returns: v.id("habits"),
|
|
46
|
+
handler: async (ctx, args) => {
|
|
47
|
+
const orgId = requireOrgId(args.orgId);
|
|
48
|
+
const agent = await ctx.db.get(args.agentId);
|
|
49
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
50
|
+
if (args.name.trim().length === 0) {
|
|
51
|
+
throw new Error("Validation: name must not be empty");
|
|
52
|
+
}
|
|
53
|
+
if (args.cronExpression.trim().length === 0) {
|
|
54
|
+
throw new Error("Validation: cronExpression must not be empty");
|
|
55
|
+
}
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const habitId = await ctx.db.insert("habits", {
|
|
58
|
+
orgId,
|
|
59
|
+
agentId: args.agentId,
|
|
60
|
+
name: args.name,
|
|
61
|
+
cronExpression: args.cronExpression,
|
|
62
|
+
prompt: args.prompt,
|
|
63
|
+
enabled: true,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
});
|
|
67
|
+
const cronJobId = await crons.register(ctx, { kind: "cron", cronspec: args.cronExpression }, internal.engine.habits.run, { habitId }, `habit:${habitId}`);
|
|
68
|
+
await ctx.db.patch(habitId, { cronJobId, updatedAt: Date.now() });
|
|
69
|
+
return habitId;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
export const get = query({
|
|
73
|
+
args: { orgId: v.string(), habitId: v.id("habits") },
|
|
74
|
+
returns: v.union(habitDoc, v.null()),
|
|
75
|
+
handler: async (ctx, args) => {
|
|
76
|
+
const orgId = requireOrgId(args.orgId);
|
|
77
|
+
const doc = await ctx.db.get(args.habitId);
|
|
78
|
+
if (!doc || doc.orgId !== orgId)
|
|
79
|
+
return null;
|
|
80
|
+
return doc;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
export const listForAgent = query({
|
|
84
|
+
args: { orgId: v.string(), agentId: v.id("agents") },
|
|
85
|
+
returns: v.array(habitDoc),
|
|
86
|
+
handler: async (ctx, args) => {
|
|
87
|
+
const orgId = requireOrgId(args.orgId);
|
|
88
|
+
return await ctx.db
|
|
89
|
+
.query("habits")
|
|
90
|
+
.withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
|
|
91
|
+
.collect();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
// updateSchedule — edits the habit AT EXECUTION TIME. Deletes the old
|
|
95
|
+
// runtime cron and registers a fresh one from the new `cronExpression`,
|
|
96
|
+
// still inside a single Convex mutation transaction. No redeploy, no
|
|
97
|
+
// downtime window: @convex-dev/crons crons are transactional (README).
|
|
98
|
+
export const updateSchedule = mutation({
|
|
99
|
+
args: {
|
|
100
|
+
orgId: v.string(),
|
|
101
|
+
habitId: v.id("habits"),
|
|
102
|
+
cronExpression: v.optional(v.string()),
|
|
103
|
+
prompt: v.optional(v.string()),
|
|
104
|
+
name: v.optional(v.string()),
|
|
105
|
+
},
|
|
106
|
+
returns: v.null(),
|
|
107
|
+
handler: async (ctx, args) => {
|
|
108
|
+
const orgId = requireOrgId(args.orgId);
|
|
109
|
+
const existing = await ctx.db.get(args.habitId);
|
|
110
|
+
assertSameOrg(orgId, existing, "Habit not found");
|
|
111
|
+
const { orgId: _orgId, habitId, cronExpression, ...rest } = args;
|
|
112
|
+
await ctx.db.patch(habitId, { ...rest, updatedAt: Date.now() });
|
|
113
|
+
if (cronExpression !== undefined && cronExpression !== existing.cronExpression) {
|
|
114
|
+
if (cronExpression.trim().length === 0) {
|
|
115
|
+
throw new Error("Validation: cronExpression must not be empty");
|
|
116
|
+
}
|
|
117
|
+
if (existing.enabled && existing.cronJobId) {
|
|
118
|
+
await crons.delete(ctx, { id: existing.cronJobId });
|
|
119
|
+
}
|
|
120
|
+
await ctx.db.patch(habitId, { cronExpression, updatedAt: Date.now() });
|
|
121
|
+
if (existing.enabled) {
|
|
122
|
+
const cronJobId = await crons.register(ctx, { kind: "cron", cronspec: cronExpression }, internal.engine.habits.run, { habitId }, `habit:${habitId}:${Date.now()}`);
|
|
123
|
+
await ctx.db.patch(habitId, { cronJobId, updatedAt: Date.now() });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
// disable — stops future runs by DELETING the runtime cron registration.
|
|
130
|
+
// This is a hard stop, not a soft "skip if disabled" flag: after `disable`
|
|
131
|
+
// resolves, there is no scheduled function left that could fire this habit.
|
|
132
|
+
export const disable = mutation({
|
|
133
|
+
args: { orgId: v.string(), habitId: v.id("habits") },
|
|
134
|
+
returns: v.null(),
|
|
135
|
+
handler: async (ctx, args) => {
|
|
136
|
+
const orgId = requireOrgId(args.orgId);
|
|
137
|
+
const existing = await ctx.db.get(args.habitId);
|
|
138
|
+
assertSameOrg(orgId, existing, "Habit not found");
|
|
139
|
+
if (existing.enabled && existing.cronJobId) {
|
|
140
|
+
await crons.delete(ctx, { id: existing.cronJobId });
|
|
141
|
+
}
|
|
142
|
+
await ctx.db.patch(args.habitId, {
|
|
143
|
+
enabled: false,
|
|
144
|
+
cronJobId: undefined,
|
|
145
|
+
updatedAt: Date.now(),
|
|
146
|
+
});
|
|
147
|
+
return null;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
// enable — re-registers the runtime cron from the habit's own
|
|
151
|
+
// `cronExpression` DATA. No redeploy: the schedule was never lost, only its
|
|
152
|
+
// runtime registration.
|
|
153
|
+
export const enable = mutation({
|
|
154
|
+
args: { orgId: v.string(), habitId: v.id("habits") },
|
|
155
|
+
returns: v.null(),
|
|
156
|
+
handler: async (ctx, args) => {
|
|
157
|
+
const orgId = requireOrgId(args.orgId);
|
|
158
|
+
const existing = await ctx.db.get(args.habitId);
|
|
159
|
+
assertSameOrg(orgId, existing, "Habit not found");
|
|
160
|
+
if (existing.enabled)
|
|
161
|
+
return null;
|
|
162
|
+
const cronJobId = await crons.register(ctx, { kind: "cron", cronspec: existing.cronExpression }, internal.engine.habits.run, { habitId: args.habitId }, `habit:${args.habitId}:${Date.now()}`);
|
|
163
|
+
await ctx.db.patch(args.habitId, {
|
|
164
|
+
enabled: true,
|
|
165
|
+
cronJobId,
|
|
166
|
+
updatedAt: Date.now(),
|
|
167
|
+
});
|
|
168
|
+
return null;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// run — the cron-invoked target (internal only, never client-facing).
|
|
172
|
+
// Writes a `habitRuns` proof row and bumps `lastRunAt`. This is what makes
|
|
173
|
+
// "a habit fires its first run without a deploy" and "a disabled habit does
|
|
174
|
+
// not fire again" PROVABLE rather than asserted on trust
|
|
175
|
+
// (component/habits.componentPackaging.test.ts). No `orgId` argument: this
|
|
176
|
+
// mutation is reachable ONLY by the runtime cron scheduler (never a
|
|
177
|
+
// client), and it derives `orgId` from the habit row itself, which is the
|
|
178
|
+
// same trust boundary `spendInternal` uses in engine/mandates.ts.
|
|
179
|
+
export const run = internalMutation({
|
|
180
|
+
args: { habitId: v.id("habits") },
|
|
181
|
+
returns: v.null(),
|
|
182
|
+
handler: async (ctx, args) => {
|
|
183
|
+
const habit = await ctx.db.get(args.habitId);
|
|
184
|
+
if (!habit || !habit.enabled) {
|
|
185
|
+
// Defence in depth: `disable` already removes the cron registration,
|
|
186
|
+
// but if a run was already in flight when disable landed, refuse to
|
|
187
|
+
// record it as having run.
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
await ctx.db.insert("habitRuns", {
|
|
192
|
+
orgId: habit.orgId,
|
|
193
|
+
habitId: args.habitId,
|
|
194
|
+
agentId: habit.agentId,
|
|
195
|
+
ranAt: now,
|
|
196
|
+
});
|
|
197
|
+
await ctx.db.patch(args.habitId, { lastRunAt: now });
|
|
198
|
+
return null;
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
export const listRuns = query({
|
|
202
|
+
args: { orgId: v.string(), habitId: v.id("habits") },
|
|
203
|
+
returns: v.array(v.object({
|
|
204
|
+
_id: v.id("habitRuns"),
|
|
205
|
+
_creationTime: v.number(),
|
|
206
|
+
orgId: v.string(),
|
|
207
|
+
habitId: v.id("habits"),
|
|
208
|
+
agentId: v.id("agents"),
|
|
209
|
+
ranAt: v.number(),
|
|
210
|
+
})),
|
|
211
|
+
handler: async (ctx, args) => {
|
|
212
|
+
const orgId = requireOrgId(args.orgId);
|
|
213
|
+
return await ctx.db
|
|
214
|
+
.query("habitRuns")
|
|
215
|
+
.withIndex("by_org_habit", (q) => q.eq("orgId", orgId).eq("habitId", args.habitId))
|
|
216
|
+
.collect();
|
|
217
|
+
},
|
|
218
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Id } from "../_generated/dataModel.js";
|
|
2
|
+
export declare const set: import("convex/server").RegisteredMutation<"public", {
|
|
3
|
+
capacity?: number | undefined;
|
|
4
|
+
orgId: string;
|
|
5
|
+
kind: "token bucket" | "fixed window";
|
|
6
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
7
|
+
scope: string;
|
|
8
|
+
rate: number;
|
|
9
|
+
period: number;
|
|
10
|
+
}, Promise<import("convex/values").GenericId<"mandates">>>;
|
|
11
|
+
export declare const get: import("convex/server").RegisteredQuery<"public", {
|
|
12
|
+
orgId: string;
|
|
13
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
14
|
+
scope: string;
|
|
15
|
+
}, Promise<{
|
|
16
|
+
_id: import("convex/values").GenericId<"mandates">;
|
|
17
|
+
_creationTime: number;
|
|
18
|
+
capacity?: number | undefined;
|
|
19
|
+
orgId: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
kind: "token bucket" | "fixed window";
|
|
23
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
24
|
+
scope: string;
|
|
25
|
+
rate: number;
|
|
26
|
+
period: number;
|
|
27
|
+
} | null>>;
|
|
28
|
+
export declare const spend: import("convex/server").RegisteredMutation<"public", {
|
|
29
|
+
orgId: string;
|
|
30
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
31
|
+
scope: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
cost: number;
|
|
34
|
+
}, Promise<{
|
|
35
|
+
ok: boolean;
|
|
36
|
+
approvalId?: Id<"approvalRequests">;
|
|
37
|
+
}>>;
|
|
38
|
+
export declare const spendInternal: import("convex/server").RegisteredMutation<"internal", {
|
|
39
|
+
orgId: string;
|
|
40
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
41
|
+
scope: string;
|
|
42
|
+
reason: string;
|
|
43
|
+
cost: number;
|
|
44
|
+
}, Promise<{
|
|
45
|
+
ok: boolean;
|
|
46
|
+
approvalId?: Id<"approvalRequests">;
|
|
47
|
+
}>>;
|
|
48
|
+
export declare const listApprovals: import("convex/server").RegisteredQuery<"public", {
|
|
49
|
+
agentId?: import("convex/values").GenericId<"agents"> | undefined;
|
|
50
|
+
orgId: string;
|
|
51
|
+
}, Promise<{
|
|
52
|
+
_id: import("convex/values").GenericId<"approvalRequests">;
|
|
53
|
+
_creationTime: number;
|
|
54
|
+
resolvedAt?: number | undefined;
|
|
55
|
+
resolvedBy?: string | undefined;
|
|
56
|
+
orgId: string;
|
|
57
|
+
status: "pending" | "approved" | "denied";
|
|
58
|
+
createdAt: number;
|
|
59
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
60
|
+
scope: string;
|
|
61
|
+
requestedCost: number;
|
|
62
|
+
reason: string;
|
|
63
|
+
}[]>>;
|
|
64
|
+
export declare const resolveApproval: import("convex/server").RegisteredMutation<"public", {
|
|
65
|
+
orgId: string;
|
|
66
|
+
resolvedBy: string;
|
|
67
|
+
decision: "approved" | "denied";
|
|
68
|
+
approvalId: import("convex/values").GenericId<"approvalRequests">;
|
|
69
|
+
}, Promise<null>>;
|
|
70
|
+
export declare const usageCount: import("convex/server").RegisteredQuery<"public", {
|
|
71
|
+
orgId: string;
|
|
72
|
+
agentId: import("convex/values").GenericId<"agents">;
|
|
73
|
+
scope: string;
|
|
74
|
+
}, Promise<number>>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { RateLimiter } from "@convex-dev/rate-limiter";
|
|
3
|
+
import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
4
|
+
import { internalMutation, mutation, query } from "../_generated/server.js";
|
|
5
|
+
import { components } from "../_generated/api.js";
|
|
6
|
+
import { assertSameOrg, requireOrgId } from "../lib/auth.js";
|
|
7
|
+
// engine/mandates — "intentional divergence from Eve": BOUNDED AUTHORITY.
|
|
8
|
+
// A mandate is a LINE OF DATA scoped
|
|
9
|
+
// orgId+agentId+scope, never a constant read from code (litmus test: a
|
|
10
|
+
// client changes their mind => zero deployment). The rate-limiter has NO
|
|
11
|
+
// static named config in this module -- every call to `spend` builds its
|
|
12
|
+
// `config` inline, dynamically, from the `mandates` row loaded from the
|
|
13
|
+
// database (@convex-dev/rate-limiter README §"Define a rate limit inline /
|
|
14
|
+
// dynamically"). Fails-closed: no mandate row => refusal, never a silent
|
|
15
|
+
// bypass.
|
|
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), same as the rest of this component (agents,
|
|
20
|
+
// wakeup, sessions). `spendInternal` also takes `orgId` as an argument, but
|
|
21
|
+
// is an `internalMutation` -- the ONLY caller reachable from any client is
|
|
22
|
+
// `engine/wakeup.enqueue` (an action), which threads its own
|
|
23
|
+
// caller-supplied `orgId` argument through after validating it.
|
|
24
|
+
const rateLimiter = new RateLimiter(components.rateLimiter);
|
|
25
|
+
// Usage rollup, keyed identically to the rate-limiter key (org+agent+scope).
|
|
26
|
+
// internal research notes: counters via sharded-counter, never a canary/alpha dependency.
|
|
27
|
+
const usageCounter = new ShardedCounter(components.shardedCounter);
|
|
28
|
+
const mandateDoc = v.object({
|
|
29
|
+
_id: v.id("mandates"),
|
|
30
|
+
_creationTime: v.number(),
|
|
31
|
+
orgId: v.string(),
|
|
32
|
+
agentId: v.id("agents"),
|
|
33
|
+
scope: v.string(),
|
|
34
|
+
kind: v.union(v.literal("token bucket"), v.literal("fixed window")),
|
|
35
|
+
rate: v.number(),
|
|
36
|
+
period: v.number(),
|
|
37
|
+
capacity: v.optional(v.number()),
|
|
38
|
+
createdAt: v.number(),
|
|
39
|
+
updatedAt: v.number(),
|
|
40
|
+
});
|
|
41
|
+
// set — upserts the DATA row that bounds an agent's authority for a given
|
|
42
|
+
// scope. This is the ONLY place a cap value is ever written; there is no
|
|
43
|
+
// constant anywhere in the engine that plays this role.
|
|
44
|
+
export const set = mutation({
|
|
45
|
+
args: {
|
|
46
|
+
orgId: v.string(),
|
|
47
|
+
agentId: v.id("agents"),
|
|
48
|
+
scope: v.string(),
|
|
49
|
+
kind: v.union(v.literal("token bucket"), v.literal("fixed window")),
|
|
50
|
+
rate: v.number(),
|
|
51
|
+
period: v.number(),
|
|
52
|
+
capacity: v.optional(v.number()),
|
|
53
|
+
},
|
|
54
|
+
returns: v.id("mandates"),
|
|
55
|
+
handler: async (ctx, args) => {
|
|
56
|
+
const orgId = requireOrgId(args.orgId);
|
|
57
|
+
const agent = await ctx.db.get(args.agentId);
|
|
58
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
59
|
+
if (args.scope.trim().length === 0) {
|
|
60
|
+
throw new Error("Validation: scope must not be empty");
|
|
61
|
+
}
|
|
62
|
+
if (args.rate <= 0 || args.period <= 0) {
|
|
63
|
+
throw new Error("Validation: rate and period must be positive");
|
|
64
|
+
}
|
|
65
|
+
const existing = await ctx.db
|
|
66
|
+
.query("mandates")
|
|
67
|
+
.withIndex("by_org_agent_scope", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId).eq("scope", args.scope))
|
|
68
|
+
.unique();
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (existing) {
|
|
71
|
+
await ctx.db.patch(existing._id, {
|
|
72
|
+
kind: args.kind,
|
|
73
|
+
rate: args.rate,
|
|
74
|
+
period: args.period,
|
|
75
|
+
capacity: args.capacity,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
});
|
|
78
|
+
return existing._id;
|
|
79
|
+
}
|
|
80
|
+
return await ctx.db.insert("mandates", {
|
|
81
|
+
orgId,
|
|
82
|
+
agentId: args.agentId,
|
|
83
|
+
scope: args.scope,
|
|
84
|
+
kind: args.kind,
|
|
85
|
+
rate: args.rate,
|
|
86
|
+
period: args.period,
|
|
87
|
+
capacity: args.capacity,
|
|
88
|
+
createdAt: now,
|
|
89
|
+
updatedAt: now,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
export const get = query({
|
|
94
|
+
args: { orgId: v.string(), agentId: v.id("agents"), scope: v.string() },
|
|
95
|
+
returns: v.union(mandateDoc, v.null()),
|
|
96
|
+
handler: async (ctx, args) => {
|
|
97
|
+
const orgId = requireOrgId(args.orgId);
|
|
98
|
+
return await ctx.db
|
|
99
|
+
.query("mandates")
|
|
100
|
+
.withIndex("by_org_agent_scope", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId).eq("scope", args.scope))
|
|
101
|
+
.unique();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
// spend — the single choke point through which every costed action an
|
|
105
|
+
// agent takes must pass. `cost` is a WEIGHTED count (tokens, euros, calls —
|
|
106
|
+
// whatever the mandate's scope means), never a plain call counter.
|
|
107
|
+
//
|
|
108
|
+
// Deny by default, twice over:
|
|
109
|
+
// 1. No mandate row for this org+agent+scope => throw immediately. We do
|
|
110
|
+
// NOT fall back to "unlimited"; an unconfigured mandate is a refusal,
|
|
111
|
+
// not an escape hatch. Nothing needs to be persisted for this case, so
|
|
112
|
+
// throwing (and rolling back the whole transaction) is safe.
|
|
113
|
+
// 2. Mandate exists but is exhausted => an approvalRequests row MUST be
|
|
114
|
+
// committed. Convex mutations are single transactions: throwing after
|
|
115
|
+
// `ctx.db.insert` would roll the insert back along with everything
|
|
116
|
+
// else, silently destroying the very approval request the guard is
|
|
117
|
+
// supposed to leave behind. So this path returns `{ ok: false }`
|
|
118
|
+
// instead of throwing -- the approval row survives, and the caller
|
|
119
|
+
// (engine/wakeup) is REQUIRED to check `.ok` and treat `false` as a
|
|
120
|
+
// hard refusal. There is no code path that lets the action proceed
|
|
121
|
+
// past an exhausted mandate; the only way authority is restored is an
|
|
122
|
+
// explicit human `resolveApproval("approved")`.
|
|
123
|
+
export const spend = mutation({
|
|
124
|
+
args: {
|
|
125
|
+
orgId: v.string(),
|
|
126
|
+
agentId: v.id("agents"),
|
|
127
|
+
scope: v.string(),
|
|
128
|
+
cost: v.number(),
|
|
129
|
+
reason: v.string(),
|
|
130
|
+
},
|
|
131
|
+
returns: v.object({
|
|
132
|
+
ok: v.boolean(),
|
|
133
|
+
approvalId: v.optional(v.id("approvalRequests")),
|
|
134
|
+
}),
|
|
135
|
+
handler: async (ctx, args) => {
|
|
136
|
+
const orgId = requireOrgId(args.orgId);
|
|
137
|
+
const agent = await ctx.db.get(args.agentId);
|
|
138
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
139
|
+
return await spendCore(ctx, orgId, args);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// spendInternal — SAME choke point as `spend`, INTERNAL ONLY (unreachable
|
|
143
|
+
// from any client). This is the ONE function `engine/wakeup.enqueue` (an
|
|
144
|
+
// `action`) calls, in its OWN transaction, before it may ever call
|
|
145
|
+
// `enqueueInternal` to put work in the pool -- see the wiring note in
|
|
146
|
+
// component/engine/wakeup.ts. Trusting an `orgId` argument here is exactly
|
|
147
|
+
// as safe as `engine/wakeup.enqueueInternal` trusting one: the only code
|
|
148
|
+
// that can reach an `internal.*` function is other server code, never a
|
|
149
|
+
// client SDK. The public `spend` above is completely untouched.
|
|
150
|
+
export const spendInternal = internalMutation({
|
|
151
|
+
args: {
|
|
152
|
+
orgId: v.string(),
|
|
153
|
+
agentId: v.id("agents"),
|
|
154
|
+
scope: v.string(),
|
|
155
|
+
cost: v.number(),
|
|
156
|
+
reason: v.string(),
|
|
157
|
+
},
|
|
158
|
+
returns: v.object({
|
|
159
|
+
ok: v.boolean(),
|
|
160
|
+
approvalId: v.optional(v.id("approvalRequests")),
|
|
161
|
+
}),
|
|
162
|
+
handler: async (ctx, args) => {
|
|
163
|
+
const agent = await ctx.db.get(args.agentId);
|
|
164
|
+
assertSameOrg(args.orgId, agent, "Agent not found");
|
|
165
|
+
return await spendCore(ctx, args.orgId, args);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
async function spendCore(ctx, orgId, args) {
|
|
169
|
+
if (args.cost <= 0) {
|
|
170
|
+
throw new Error("Validation: cost must be positive");
|
|
171
|
+
}
|
|
172
|
+
const mandate = await ctx.db
|
|
173
|
+
.query("mandates")
|
|
174
|
+
.withIndex("by_org_agent_scope", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId).eq("scope", args.scope))
|
|
175
|
+
.unique();
|
|
176
|
+
if (!mandate) {
|
|
177
|
+
// fails-closed: no data => no authority. Never silently unlimited.
|
|
178
|
+
throw new Error(`Mandate refused: no mandate configured for scope "${args.scope}"`);
|
|
179
|
+
}
|
|
180
|
+
const key = `${orgId}:${args.agentId}:${args.scope}`;
|
|
181
|
+
const status = await rateLimiter.limit(ctx, "mandateSpend", {
|
|
182
|
+
key,
|
|
183
|
+
count: args.cost,
|
|
184
|
+
config: {
|
|
185
|
+
kind: mandate.kind,
|
|
186
|
+
rate: mandate.rate,
|
|
187
|
+
period: mandate.period,
|
|
188
|
+
capacity: mandate.capacity,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
if (!status.ok) {
|
|
192
|
+
// Overrun => a human approval request is created and PERSISTED
|
|
193
|
+
// (returning instead of throwing so the transaction commits it). This
|
|
194
|
+
// is the ONLY consequence of an overrun -- there is no code path that
|
|
195
|
+
// spends past an exhausted mandate.
|
|
196
|
+
const approvalId = await ctx.db.insert("approvalRequests", {
|
|
197
|
+
orgId,
|
|
198
|
+
agentId: args.agentId,
|
|
199
|
+
scope: args.scope,
|
|
200
|
+
requestedCost: args.cost,
|
|
201
|
+
reason: args.reason,
|
|
202
|
+
status: "pending",
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
});
|
|
205
|
+
return { ok: false, approvalId };
|
|
206
|
+
}
|
|
207
|
+
// Rollup via sharded-counter (internal research notes: no canary dependency for a
|
|
208
|
+
// guardrail). One shard bucket per org+agent+scope key.
|
|
209
|
+
await usageCounter.add(ctx, key, args.cost);
|
|
210
|
+
return { ok: true };
|
|
211
|
+
}
|
|
212
|
+
export const listApprovals = query({
|
|
213
|
+
args: { orgId: v.string(), agentId: v.optional(v.id("agents")) },
|
|
214
|
+
returns: v.array(v.object({
|
|
215
|
+
_id: v.id("approvalRequests"),
|
|
216
|
+
_creationTime: v.number(),
|
|
217
|
+
orgId: v.string(),
|
|
218
|
+
agentId: v.id("agents"),
|
|
219
|
+
scope: v.string(),
|
|
220
|
+
requestedCost: v.number(),
|
|
221
|
+
reason: v.string(),
|
|
222
|
+
status: v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")),
|
|
223
|
+
createdAt: v.number(),
|
|
224
|
+
resolvedAt: v.optional(v.number()),
|
|
225
|
+
resolvedBy: v.optional(v.string()),
|
|
226
|
+
})),
|
|
227
|
+
handler: async (ctx, args) => {
|
|
228
|
+
const orgId = requireOrgId(args.orgId);
|
|
229
|
+
const rows = await ctx.db
|
|
230
|
+
.query("approvalRequests")
|
|
231
|
+
.withIndex("by_org", (q) => q.eq("orgId", orgId))
|
|
232
|
+
.collect();
|
|
233
|
+
return args.agentId
|
|
234
|
+
? rows.filter((r) => r.agentId === args.agentId)
|
|
235
|
+
: rows;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
// resolveApproval — the ONLY way an overrun ever becomes usable authority
|
|
239
|
+
// again: an explicit human decision, recorded, never inferred.
|
|
240
|
+
export const resolveApproval = mutation({
|
|
241
|
+
args: {
|
|
242
|
+
orgId: v.string(),
|
|
243
|
+
approvalId: v.id("approvalRequests"),
|
|
244
|
+
decision: v.union(v.literal("approved"), v.literal("denied")),
|
|
245
|
+
resolvedBy: v.string(),
|
|
246
|
+
},
|
|
247
|
+
returns: v.null(),
|
|
248
|
+
handler: async (ctx, args) => {
|
|
249
|
+
const orgId = requireOrgId(args.orgId);
|
|
250
|
+
const existing = await ctx.db.get(args.approvalId);
|
|
251
|
+
assertSameOrg(orgId, existing, "Approval request not found");
|
|
252
|
+
await ctx.db.patch(args.approvalId, {
|
|
253
|
+
status: args.decision,
|
|
254
|
+
resolvedAt: Date.now(),
|
|
255
|
+
resolvedBy: args.resolvedBy,
|
|
256
|
+
});
|
|
257
|
+
return null;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
// usageCount — the rollup readout (sharded-counter), for dashboards/audits.
|
|
261
|
+
export const usageCount = query({
|
|
262
|
+
args: { orgId: v.string(), agentId: v.id("agents"), scope: v.string() },
|
|
263
|
+
returns: v.number(),
|
|
264
|
+
handler: async (ctx, args) => {
|
|
265
|
+
const orgId = requireOrgId(args.orgId);
|
|
266
|
+
const agent = await ctx.db.get(args.agentId);
|
|
267
|
+
assertSameOrg(orgId, agent, "Agent not found");
|
|
268
|
+
const key = `${orgId}:${args.agentId}:${args.scope}`;
|
|
269
|
+
return await usageCounter.count(ctx, key);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const startRun: import("convex/server").RegisteredMutation<"public", {
|
|
2
|
+
orgId: string;
|
|
3
|
+
workflowId: string;
|
|
4
|
+
awaitedEventName: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
}, Promise<import("convex/values").GenericId<"sandboxRuns">>>;
|
|
7
|
+
export declare const attachSandboxId: import("convex/server").RegisteredMutation<"public", {
|
|
8
|
+
orgId: string;
|
|
9
|
+
sandboxId: string;
|
|
10
|
+
sandboxRunId: import("convex/values").GenericId<"sandboxRuns">;
|
|
11
|
+
}, Promise<null>>;
|
|
12
|
+
export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
|
|
13
|
+
orgId: string;
|
|
14
|
+
sandboxRunId: import("convex/values").GenericId<"sandboxRuns">;
|
|
15
|
+
}, Promise<{
|
|
16
|
+
status: "completed" | "pending" | "failed" | "timedOut";
|
|
17
|
+
resultSummary: string | undefined;
|
|
18
|
+
}>>;
|
|
19
|
+
export declare const deadLetterMalformed: import("convex/server").RegisteredMutation<"public", {
|
|
20
|
+
orgId?: string | undefined;
|
|
21
|
+
reason: string;
|
|
22
|
+
deliveryId: string;
|
|
23
|
+
rawBody: string;
|
|
24
|
+
}, Promise<null>>;
|
|
25
|
+
export declare const processCallback: import("convex/server").RegisteredMutation<"public", {
|
|
26
|
+
resultSummary?: string | undefined;
|
|
27
|
+
orgId: string;
|
|
28
|
+
status: "completed" | "failed";
|
|
29
|
+
sandboxRunId: string;
|
|
30
|
+
deliveryId: string;
|
|
31
|
+
}, Promise<{
|
|
32
|
+
outcome: "woke" | "duplicate" | "dead-lettered";
|
|
33
|
+
}>>;
|
|
34
|
+
export declare const timeoutInternal: import("convex/server").RegisteredMutation<"internal", {
|
|
35
|
+
sandboxRunId: import("convex/values").GenericId<"sandboxRuns">;
|
|
36
|
+
}, Promise<null>>;
|