@opengeni/core 0.4.5 → 0.4.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/dist/index.d.ts +179 -30
- package/dist/index.js +1324 -477
- package/dist/index.js.map +1 -1
- package/package.json +18 -18
- package/src/access/index.ts +132 -57
- package/src/billing/limits.ts +76 -34
- package/src/dependencies.ts +76 -14
- package/src/domain/capabilities.ts +388 -182
- package/src/domain/environments.ts +58 -38
- package/src/domain/packs.ts +49 -16
- package/src/domain/resources.ts +71 -28
- package/src/domain/scheduled-tasks.ts +107 -41
- package/src/domain/sessions.ts +514 -258
- package/src/domain/workspace-members.ts +6 -2
- package/src/index.ts +1 -0
- package/src/rigs/index.ts +540 -0
- package/src/sandbox/fleet.ts +97 -18
- package/src/sandbox/routing.ts +6 -1
- package/src/sandbox-types.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
|
|
9
9
|
"directory": "packages/core"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
11
15
|
"type": "module",
|
|
12
16
|
"sideEffects": false,
|
|
13
17
|
"main": "./dist/index.js",
|
|
@@ -19,33 +23,29 @@
|
|
|
19
23
|
"import": "./dist/index.js"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
|
-
"files": [
|
|
23
|
-
"dist",
|
|
24
|
-
"src"
|
|
25
|
-
],
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=18"
|
|
28
|
-
},
|
|
29
26
|
"publishConfig": {
|
|
30
27
|
"access": "public",
|
|
31
28
|
"provenance": true
|
|
32
29
|
},
|
|
33
30
|
"scripts": {
|
|
34
|
-
"typecheck": "
|
|
31
|
+
"typecheck": "tsgo --noEmit",
|
|
35
32
|
"build": "tsup",
|
|
36
33
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
37
34
|
},
|
|
38
35
|
"dependencies": {
|
|
39
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
|
-
"@opengeni/codex": "^0.2.
|
|
41
|
-
"@opengeni/config": "^0.
|
|
42
|
-
"@opengeni/contracts": "^0.
|
|
43
|
-
"@opengeni/db": "^0.
|
|
44
|
-
"@opengeni/documents": "^0.2.
|
|
45
|
-
"@opengeni/events": "^0.
|
|
46
|
-
"@opengeni/observability": "^0.
|
|
47
|
-
"@opengeni/runtime": "^0.
|
|
48
|
-
"@opengeni/storage": "^0.2.
|
|
37
|
+
"@opengeni/codex": "^0.2.2",
|
|
38
|
+
"@opengeni/config": "^0.5.0",
|
|
39
|
+
"@opengeni/contracts": "^0.10.0",
|
|
40
|
+
"@opengeni/db": "^0.7.0",
|
|
41
|
+
"@opengeni/documents": "^0.2.9",
|
|
42
|
+
"@opengeni/events": "^0.3.0",
|
|
43
|
+
"@opengeni/observability": "^0.3.0",
|
|
44
|
+
"@opengeni/runtime": "^0.7.0",
|
|
45
|
+
"@opengeni/storage": "^0.2.9",
|
|
49
46
|
"hono": "^4.12.18"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/access/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Settings } from "@opengeni/config";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
verifyDelegatedAccessToken,
|
|
4
|
+
type AccessContext,
|
|
5
|
+
type AccessGrant,
|
|
6
|
+
type Permission,
|
|
7
|
+
} from "@opengeni/contracts";
|
|
3
8
|
import {
|
|
4
9
|
bootstrapWorkspace,
|
|
5
10
|
ensureManagedAccessForUser,
|
|
@@ -28,10 +33,16 @@ export async function requireAccessContext(c: Context, deps: AccessDeps): Promis
|
|
|
28
33
|
return context;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
export async function requireAccessGrant(
|
|
36
|
+
export async function requireAccessGrant(
|
|
37
|
+
c: Context,
|
|
38
|
+
deps: AccessDeps,
|
|
39
|
+
workspaceId: string,
|
|
40
|
+
permission?: Permission,
|
|
41
|
+
): Promise<AccessGrant> {
|
|
32
42
|
const context = await requireAccessContext(c, deps);
|
|
33
|
-
const grant =
|
|
34
|
-
|
|
43
|
+
const grant =
|
|
44
|
+
context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
|
|
45
|
+
(await getWorkspaceGrant(deps.db, context.subjectId, workspaceId));
|
|
35
46
|
if (!grant) {
|
|
36
47
|
const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
|
|
37
48
|
if (!workspace) {
|
|
@@ -47,12 +58,30 @@ export async function requireAccessGrant(c: Context, deps: AccessDeps, workspace
|
|
|
47
58
|
|
|
48
59
|
export function requirePermission(grant: AccessGrant, permission: Permission): void {
|
|
49
60
|
if (!hasPermission(grant.permissions, permission)) {
|
|
61
|
+
if (permission === "variable-sets:use") {
|
|
62
|
+
throw new HTTPException(403, {
|
|
63
|
+
message: "missing permission: variable-sets:use (deprecated alias: environments:use)",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (permission === "variable-sets:manage") {
|
|
67
|
+
throw new HTTPException(403, {
|
|
68
|
+
message: "missing permission: variable-sets:manage (deprecated alias: environments:manage)",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
50
71
|
throw new HTTPException(403, { message: `missing permission: ${permission}` });
|
|
51
72
|
}
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
export function hasPermission(permissions: Permission[], permission: Permission): boolean {
|
|
55
|
-
|
|
76
|
+
const aliases: Partial<Record<Permission, Permission[]>> = {
|
|
77
|
+
"variable-sets:use": ["environments:use" as Permission],
|
|
78
|
+
"variable-sets:manage": ["environments:manage" as Permission],
|
|
79
|
+
};
|
|
80
|
+
return (
|
|
81
|
+
permissions.includes(permission) ||
|
|
82
|
+
(aliases[permission]?.some((alias) => permissions.includes(alias)) ?? false) ||
|
|
83
|
+
permissions.includes("workspace:admin")
|
|
84
|
+
);
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
|
|
@@ -69,16 +98,20 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
69
98
|
});
|
|
70
99
|
}
|
|
71
100
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
if (deps.settings.productAccessMode === "configured") {
|
|
102
|
+
const delegated = await delegatedAccessContext(c, deps, "configured");
|
|
103
|
+
if (delegated) {
|
|
104
|
+
return delegated;
|
|
105
|
+
}
|
|
106
|
+
const apiKey = await apiKeyAccessContext(c, deps, "configured");
|
|
107
|
+
if (apiKey) {
|
|
108
|
+
return apiKey;
|
|
109
|
+
}
|
|
110
|
+
if (deps.settings.delegationSecret) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return await bootstrapWorkspace(deps.db, {
|
|
114
|
+
accountExternalSource: "opengeni:configured",
|
|
82
115
|
accountExternalId: "default",
|
|
83
116
|
accountName: "Configured",
|
|
84
117
|
workspaceExternalSource: "opengeni:configured",
|
|
@@ -95,31 +128,9 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
95
128
|
if (delegated) {
|
|
96
129
|
return delegated;
|
|
97
130
|
}
|
|
98
|
-
const apiKey = await
|
|
131
|
+
const apiKey = await apiKeyAccessContext(c, deps, "managed");
|
|
99
132
|
if (apiKey) {
|
|
100
|
-
|
|
101
|
-
? apiKey.permissions.filter((permission) => permission === "billing:read" || permission === "billing:manage")
|
|
102
|
-
: apiKey.permissions;
|
|
103
|
-
return {
|
|
104
|
-
mode: "managed",
|
|
105
|
-
subjectId: `api_key:${apiKey.id}`,
|
|
106
|
-
subjectLabel: apiKey.name,
|
|
107
|
-
accountGrants: [{
|
|
108
|
-
accountId: apiKey.accountId,
|
|
109
|
-
subjectId: `api_key:${apiKey.id}`,
|
|
110
|
-
subjectLabel: apiKey.name,
|
|
111
|
-
permissions: accountPermissions,
|
|
112
|
-
}],
|
|
113
|
-
workspaceGrants: apiKey.workspaceId ? [{
|
|
114
|
-
workspaceId: apiKey.workspaceId,
|
|
115
|
-
accountId: apiKey.accountId,
|
|
116
|
-
subjectId: `api_key:${apiKey.id}`,
|
|
117
|
-
subjectLabel: apiKey.name,
|
|
118
|
-
permissions: apiKey.permissions,
|
|
119
|
-
}] : [],
|
|
120
|
-
defaultAccountId: apiKey.accountId,
|
|
121
|
-
defaultWorkspaceId: apiKey.workspaceId,
|
|
122
|
-
} satisfies AccessContext;
|
|
133
|
+
return apiKey;
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
@@ -137,7 +148,59 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
|
|
|
137
148
|
return null;
|
|
138
149
|
}
|
|
139
150
|
|
|
140
|
-
async function
|
|
151
|
+
async function apiKeyAccessContext(
|
|
152
|
+
c: Context,
|
|
153
|
+
deps: AccessDeps,
|
|
154
|
+
mode: "configured" | "managed",
|
|
155
|
+
): Promise<AccessContext | null> {
|
|
156
|
+
const bearer = bearerToken(c);
|
|
157
|
+
if (!bearer) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const apiKey = await findActiveApiKeyByHash(deps.db, await sha256Hex(bearer));
|
|
161
|
+
if (!apiKey) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const subjectId = `api_key:${apiKey.id}`;
|
|
165
|
+
const accountPermissions = apiKey.workspaceId
|
|
166
|
+
? apiKey.permissions.filter(
|
|
167
|
+
(permission) => permission === "billing:read" || permission === "billing:manage",
|
|
168
|
+
)
|
|
169
|
+
: apiKey.permissions;
|
|
170
|
+
return {
|
|
171
|
+
mode,
|
|
172
|
+
subjectId,
|
|
173
|
+
subjectLabel: apiKey.name,
|
|
174
|
+
accountGrants: [
|
|
175
|
+
{
|
|
176
|
+
accountId: apiKey.accountId,
|
|
177
|
+
subjectId,
|
|
178
|
+
subjectLabel: apiKey.name,
|
|
179
|
+
permissions: accountPermissions,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
workspaceGrants: apiKey.workspaceId
|
|
183
|
+
? [
|
|
184
|
+
{
|
|
185
|
+
workspaceId: apiKey.workspaceId,
|
|
186
|
+
accountId: apiKey.accountId,
|
|
187
|
+
subjectId,
|
|
188
|
+
subjectLabel: apiKey.name,
|
|
189
|
+
permissions: apiKey.permissions,
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
: [],
|
|
193
|
+
defaultAccountId: apiKey.accountId,
|
|
194
|
+
defaultWorkspaceId: apiKey.workspaceId,
|
|
195
|
+
} satisfies AccessContext;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function delegatedAccessContext(
|
|
199
|
+
c: Context,
|
|
200
|
+
deps: AccessDeps,
|
|
201
|
+
mode: "configured" | "managed",
|
|
202
|
+
token = bearerToken(c),
|
|
203
|
+
): Promise<AccessContext | null> {
|
|
141
204
|
if (!token || !deps.settings.delegationSecret) {
|
|
142
205
|
return null;
|
|
143
206
|
}
|
|
@@ -149,22 +212,32 @@ async function delegatedAccessContext(c: Context, deps: AccessDeps, mode: "confi
|
|
|
149
212
|
mode,
|
|
150
213
|
subjectId: payload.subjectId,
|
|
151
214
|
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
152
|
-
accountGrants: [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
accountGrants: [
|
|
216
|
+
{
|
|
217
|
+
accountId: payload.accountId,
|
|
218
|
+
subjectId: payload.subjectId,
|
|
219
|
+
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
220
|
+
permissions: payload.permissions,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
workspaceGrants: [
|
|
224
|
+
{
|
|
225
|
+
workspaceId: payload.workspaceId,
|
|
226
|
+
accountId: payload.accountId,
|
|
227
|
+
subjectId: payload.subjectId,
|
|
228
|
+
...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
|
|
229
|
+
permissions: payload.permissions,
|
|
230
|
+
// sessionId is worker-asserted (HMAC-signed token claim), not agent
|
|
231
|
+
// controlled; it scopes session-bound MCP tools such as goal management.
|
|
232
|
+
metadata: {
|
|
233
|
+
delegated: true,
|
|
234
|
+
...(payload.sessionId ? { sessionId: payload.sessionId } : {}),
|
|
235
|
+
// Caller identity: the turn that minted this token. Tools classify the
|
|
236
|
+
// CALLER from this instead of re-reading the live active pointer.
|
|
237
|
+
...(payload.turnId ? { turnId: payload.turnId } : {}),
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
168
241
|
defaultAccountId: payload.accountId,
|
|
169
242
|
defaultWorkspaceId: payload.workspaceId,
|
|
170
243
|
};
|
|
@@ -182,5 +255,7 @@ function bearerToken(c: Context): string | null {
|
|
|
182
255
|
|
|
183
256
|
async function sha256Hex(value: string): Promise<string> {
|
|
184
257
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
185
|
-
return Array.from(new Uint8Array(digest))
|
|
258
|
+
return Array.from(new Uint8Array(digest))
|
|
259
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
260
|
+
.join("");
|
|
186
261
|
}
|
package/src/billing/limits.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { HTTPException } from "hono/http-exception";
|
|
13
13
|
import type { ApiRouteDeps } from "../dependencies";
|
|
14
14
|
|
|
15
|
+
export type LimitDependencies = Pick<ApiRouteDeps, "db" | "settings">;
|
|
16
|
+
|
|
15
17
|
export type LimitCheckInput = {
|
|
16
18
|
accountId: string;
|
|
17
19
|
workspaceId?: string;
|
|
@@ -26,20 +28,30 @@ export type LimitCheckInput = {
|
|
|
26
28
|
model?: string | null;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
export async function requireLimit(deps:
|
|
31
|
+
export async function requireLimit(deps: LimitDependencies, input: LimitCheckInput): Promise<void> {
|
|
30
32
|
const decision = await checkLimit(deps, input);
|
|
31
33
|
if (decision.allowed) {
|
|
32
34
|
return;
|
|
33
35
|
}
|
|
34
|
-
throw new HTTPException(decision.code === "insufficient_credits" ? 402 : 429, {
|
|
36
|
+
throw new HTTPException(decision.code === "insufficient_credits" ? 402 : 429, {
|
|
37
|
+
message: decision.message,
|
|
38
|
+
});
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
export async function checkLimit(
|
|
41
|
+
export async function checkLimit(
|
|
42
|
+
deps: LimitDependencies,
|
|
43
|
+
input: LimitCheckInput,
|
|
44
|
+
): Promise<LimitDecision> {
|
|
38
45
|
// Resolve the canonical codex-billed predicate ONCE. Returns false for any
|
|
39
46
|
// action that carries no model (infra caps) or any codex/<slug> model without
|
|
40
47
|
// an active credential — so the bypass never triggers on the prefix alone.
|
|
41
48
|
const codexBilled = input.workspaceId
|
|
42
|
-
? await isCodexBilledTurn({
|
|
49
|
+
? await isCodexBilledTurn({
|
|
50
|
+
db: deps.db,
|
|
51
|
+
settings: deps.settings,
|
|
52
|
+
workspaceId: input.workspaceId,
|
|
53
|
+
model: input.model,
|
|
54
|
+
})
|
|
43
55
|
: false;
|
|
44
56
|
const creditDecision = await checkCreditBalance(deps, input, codexBilled);
|
|
45
57
|
if (!creditDecision.allowed) {
|
|
@@ -51,7 +63,11 @@ export async function checkLimit(deps: ApiRouteDeps, input: LimitCheckInput): Pr
|
|
|
51
63
|
return await checkStaticCaps(deps, input, codexBilled);
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
async function checkCreditBalance(
|
|
66
|
+
async function checkCreditBalance(
|
|
67
|
+
deps: LimitDependencies,
|
|
68
|
+
input: LimitCheckInput,
|
|
69
|
+
codexBilled: boolean,
|
|
70
|
+
): Promise<LimitDecision> {
|
|
55
71
|
if (codexBilled) {
|
|
56
72
|
return { allowed: true }; // paid by the user's ChatGPT/Codex plan — zero OpenGeni credits
|
|
57
73
|
}
|
|
@@ -65,7 +81,11 @@ async function checkCreditBalance(deps: ApiRouteDeps, input: LimitCheckInput, co
|
|
|
65
81
|
return { allowed: false, code: "insufficient_credits", message: "insufficient OpenGeni credits" };
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
async function checkStaticCaps(
|
|
84
|
+
async function checkStaticCaps(
|
|
85
|
+
deps: LimitDependencies,
|
|
86
|
+
input: LimitCheckInput,
|
|
87
|
+
codexBilled: boolean,
|
|
88
|
+
): Promise<LimitDecision> {
|
|
69
89
|
const limits = configuredStaticUsageLimits(deps.settings);
|
|
70
90
|
if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !codexBilled) {
|
|
71
91
|
const used = await sumUsageQuantity(deps.db, {
|
|
@@ -74,7 +94,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
74
94
|
since: startOfUtcMonth(),
|
|
75
95
|
});
|
|
76
96
|
if (used >= limits.maxMonthlyCostMicrosPerAccount) {
|
|
77
|
-
return blocked(
|
|
97
|
+
return blocked(
|
|
98
|
+
"max_monthly_cost_micros_per_account",
|
|
99
|
+
`monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`,
|
|
100
|
+
);
|
|
78
101
|
}
|
|
79
102
|
}
|
|
80
103
|
switch (input.action) {
|
|
@@ -85,7 +108,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
85
108
|
const count = await countWorkspacesForAccount(deps.db, input.accountId);
|
|
86
109
|
return count < limits.maxWorkspacesPerAccount
|
|
87
110
|
? { allowed: true }
|
|
88
|
-
: blocked(
|
|
111
|
+
: blocked(
|
|
112
|
+
"max_workspaces_per_account",
|
|
113
|
+
`workspace limit reached (${limits.maxWorkspacesPerAccount})`,
|
|
114
|
+
);
|
|
89
115
|
}
|
|
90
116
|
case "api_key:create": {
|
|
91
117
|
if (!limits.maxApiKeysPerWorkspace || !input.workspaceId) {
|
|
@@ -94,7 +120,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
94
120
|
const count = await countActiveApiKeysForWorkspace(deps.db, input.workspaceId);
|
|
95
121
|
return count < limits.maxApiKeysPerWorkspace
|
|
96
122
|
? { allowed: true }
|
|
97
|
-
: blocked(
|
|
123
|
+
: blocked(
|
|
124
|
+
"max_api_keys_per_workspace",
|
|
125
|
+
`API key limit reached (${limits.maxApiKeysPerWorkspace})`,
|
|
126
|
+
);
|
|
98
127
|
}
|
|
99
128
|
case "schedule:create": {
|
|
100
129
|
if (!limits.maxSchedulesPerWorkspace || !input.workspaceId) {
|
|
@@ -103,7 +132,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
103
132
|
const count = await countScheduledTasksForWorkspace(deps.db, input.workspaceId);
|
|
104
133
|
return count < limits.maxSchedulesPerWorkspace
|
|
105
134
|
? { allowed: true }
|
|
106
|
-
: blocked(
|
|
135
|
+
: blocked(
|
|
136
|
+
"max_schedules_per_workspace",
|
|
137
|
+
`scheduled task limit reached (${limits.maxSchedulesPerWorkspace})`,
|
|
138
|
+
);
|
|
107
139
|
}
|
|
108
140
|
case "file:upload": {
|
|
109
141
|
if (!limits.maxFileUploadBytes || !input.quantity) {
|
|
@@ -111,7 +143,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
111
143
|
}
|
|
112
144
|
return input.quantity <= limits.maxFileUploadBytes
|
|
113
145
|
? { allowed: true }
|
|
114
|
-
: blocked(
|
|
146
|
+
: blocked(
|
|
147
|
+
"max_file_upload_bytes",
|
|
148
|
+
`file upload exceeds static limit of ${limits.maxFileUploadBytes} bytes`,
|
|
149
|
+
);
|
|
115
150
|
}
|
|
116
151
|
case "agent_run:create": {
|
|
117
152
|
if (!limits.maxMonthlyAgentRunsPerWorkspace || !input.workspaceId) {
|
|
@@ -125,7 +160,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
125
160
|
const requested = input.quantity ?? 0;
|
|
126
161
|
return used + requested <= limits.maxMonthlyAgentRunsPerWorkspace
|
|
127
162
|
? { allowed: true }
|
|
128
|
-
: blocked(
|
|
163
|
+
: blocked(
|
|
164
|
+
"max_monthly_agent_runs_per_workspace",
|
|
165
|
+
`monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`,
|
|
166
|
+
);
|
|
129
167
|
}
|
|
130
168
|
case "tokens:consume": {
|
|
131
169
|
if (codexBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
|
|
@@ -139,7 +177,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
139
177
|
const requested = input.quantity ?? 0;
|
|
140
178
|
return used + requested <= limits.maxMonthlyTokensPerWorkspace
|
|
141
179
|
? { allowed: true }
|
|
142
|
-
: blocked(
|
|
180
|
+
: blocked(
|
|
181
|
+
"max_monthly_tokens_per_workspace",
|
|
182
|
+
`monthly token limit reached (${limits.maxMonthlyTokensPerWorkspace})`,
|
|
183
|
+
);
|
|
143
184
|
}
|
|
144
185
|
case "document:index": {
|
|
145
186
|
if (!limits.maxDocumentIndexedChunksPerWorkspace || !input.workspaceId) {
|
|
@@ -153,26 +194,28 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
|
|
|
153
194
|
const requested = input.quantity ?? 0;
|
|
154
195
|
return used + requested <= limits.maxDocumentIndexedChunksPerWorkspace
|
|
155
196
|
? { allowed: true }
|
|
156
|
-
: blocked(
|
|
197
|
+
: blocked(
|
|
198
|
+
"max_document_indexed_chunks_per_workspace",
|
|
199
|
+
`monthly document indexing limit reached (${limits.maxDocumentIndexedChunksPerWorkspace} chunks)`,
|
|
200
|
+
);
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
203
|
}
|
|
160
204
|
|
|
161
|
-
export async function recordWorkspaceUsage(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
|
167
|
-
| "file.uploaded"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}): Promise<void> {
|
|
205
|
+
export async function recordWorkspaceUsage(
|
|
206
|
+
deps: LimitDependencies,
|
|
207
|
+
input: {
|
|
208
|
+
accountId: string;
|
|
209
|
+
workspaceId: string;
|
|
210
|
+
subjectId?: string | null;
|
|
211
|
+
eventType: "agent_run.created" | "file.uploaded" | "document.indexed" | "scheduled_task.fired";
|
|
212
|
+
quantity: number;
|
|
213
|
+
unit: string;
|
|
214
|
+
sourceResourceType: string;
|
|
215
|
+
sourceResourceId: string;
|
|
216
|
+
idempotencyKey: string;
|
|
217
|
+
},
|
|
218
|
+
): Promise<void> {
|
|
176
219
|
await recordUsageEvent(deps.db, {
|
|
177
220
|
accountId: input.accountId,
|
|
178
221
|
workspaceId: input.workspaceId,
|
|
@@ -186,15 +229,14 @@ export async function recordWorkspaceUsage(deps: ApiRouteDeps, input: {
|
|
|
186
229
|
});
|
|
187
230
|
}
|
|
188
231
|
|
|
189
|
-
function usesCreditLimits(deps:
|
|
232
|
+
function usesCreditLimits(deps: LimitDependencies): boolean {
|
|
190
233
|
return deps.settings.billingMode === "stripe" || deps.settings.usageLimitsMode === "managed";
|
|
191
234
|
}
|
|
192
235
|
|
|
193
236
|
function isCostlyAction(action: LimitAction): boolean {
|
|
194
|
-
return
|
|
195
|
-
|| action === "tokens:consume"
|
|
196
|
-
|
|
197
|
-
|| action === "document:index";
|
|
237
|
+
return (
|
|
238
|
+
action === "agent_run:create" || action === "tokens:consume" || action === "document:index"
|
|
239
|
+
);
|
|
198
240
|
}
|
|
199
241
|
|
|
200
242
|
function blocked(code: string, message: string): LimitDecision {
|
package/src/dependencies.ts
CHANGED
|
@@ -9,26 +9,71 @@ import type { ManagedAuth } from "./managed-auth-type";
|
|
|
9
9
|
import type { ApiSandboxClient, ResumeBoxByIdInput, ResumedSandboxSession } from "./sandbox-types";
|
|
10
10
|
|
|
11
11
|
export type SessionWorkflowClient = {
|
|
12
|
-
signalUserMessage: (input: {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
signalUserMessage: (input: {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
eventId: string;
|
|
15
|
+
workflowId: string;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
wakeSessionWorkflow: (input: {
|
|
18
|
+
accountId: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
workflowId: string;
|
|
22
|
+
wakeRevision: number;
|
|
23
|
+
controlEventId?: string;
|
|
24
|
+
}) => Promise<void>;
|
|
25
|
+
// Dedicated, revision-carrying nudge for a durable Codex capacity waiter.
|
|
26
|
+
// Optional for embedded/back-compat clients: callers may fall back to the
|
|
27
|
+
// generic queueChanged wake because Postgres wakeRevision is authoritative.
|
|
28
|
+
signalCodexCapacity?: (input: {
|
|
29
|
+
accountId: string;
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
workflowId: string;
|
|
33
|
+
wakeRevision: number;
|
|
34
|
+
workflowWakeRevision: number;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
signalApprovalDecision: (input: {
|
|
37
|
+
accountId: string;
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
eventId: string;
|
|
41
|
+
workflowId: string;
|
|
42
|
+
workflowWakeRevision: number;
|
|
43
|
+
}) => Promise<void>;
|
|
44
|
+
// A durable Pause/Steer control must reach the workflow even when its previous
|
|
45
|
+
// run returned idle. signalWithStart either delivers to the live workflow or
|
|
46
|
+
// starts the session workflow with the control already buffered.
|
|
47
|
+
signalSessionControl: (input: {
|
|
48
|
+
accountId: string;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
eventId: string;
|
|
52
|
+
workflowId: string;
|
|
53
|
+
workflowWakeRevision: number;
|
|
54
|
+
}) => Promise<void>;
|
|
24
55
|
syncScheduledTask: (input: { task: ScheduledTask }) => Promise<void>;
|
|
25
56
|
deleteScheduledTaskSchedule: (input: { temporalScheduleId: string }) => Promise<void>;
|
|
26
|
-
triggerScheduledTask: (input: {
|
|
57
|
+
triggerScheduledTask: (input: {
|
|
58
|
+
task: ScheduledTask;
|
|
59
|
+
agentRunUsageIdempotencyKey?: string;
|
|
60
|
+
triggerWorkflowId?: string;
|
|
61
|
+
}) => Promise<void>;
|
|
62
|
+
startRigVerification: (input: {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
changeId?: string;
|
|
65
|
+
versionId?: string;
|
|
66
|
+
workflowId?: string;
|
|
67
|
+
}) => Promise<void>;
|
|
27
68
|
check?: () => Promise<void>;
|
|
28
69
|
};
|
|
29
70
|
|
|
30
71
|
export type DocumentIndexClient = {
|
|
31
|
-
indexDocument: (input: {
|
|
72
|
+
indexDocument: (input: {
|
|
73
|
+
accountId: string;
|
|
74
|
+
workspaceId: string;
|
|
75
|
+
documentId: string;
|
|
76
|
+
}) => Promise<Document | void>;
|
|
32
77
|
};
|
|
33
78
|
|
|
34
79
|
export type AppDependencies = {
|
|
@@ -36,6 +81,8 @@ export type AppDependencies = {
|
|
|
36
81
|
db: Database;
|
|
37
82
|
bus: EventBus;
|
|
38
83
|
workflowClient: SessionWorkflowClient;
|
|
84
|
+
/** Optional provider override for deterministic API/object-storage tests. */
|
|
85
|
+
objectStorage?: ObjectStorageDependency;
|
|
39
86
|
documentIndexer?: DocumentIndexClient;
|
|
40
87
|
documentServices?: DocumentServices;
|
|
41
88
|
observability?: Observability;
|
|
@@ -70,3 +117,18 @@ export type ApiRouteDeps = AppDependencies & {
|
|
|
70
117
|
// resumeBoxById (it throws SandboxResumeError when sandboxBackend=none).
|
|
71
118
|
resumeBoxById: (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession>;
|
|
72
119
|
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The exact dependency slice used by `acceptSessionUserMessage`.
|
|
123
|
+
*
|
|
124
|
+
* Keeping this narrower than `ApiRouteDeps` lets control-plane callers reuse
|
|
125
|
+
* the canonical admission path without constructing unrelated HTTP, document,
|
|
126
|
+
* or sandbox services. The public API still passes its `ApiRouteDeps` superset.
|
|
127
|
+
*/
|
|
128
|
+
export type AcceptSessionUserMessageDependencies = Pick<
|
|
129
|
+
AppDependencies,
|
|
130
|
+
"settings" | "db" | "bus"
|
|
131
|
+
> & {
|
|
132
|
+
workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow" | "signalSessionControl">;
|
|
133
|
+
objectStorage: ObjectStorageDependency;
|
|
134
|
+
};
|