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