@opengeni/core 0.2.0
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 +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type {
|
|
3
|
+
AccessGrant,
|
|
4
|
+
ScheduledTask,
|
|
5
|
+
ScheduledTaskAgentConfig,
|
|
6
|
+
CreateScheduledTaskRequest as CreateScheduledTaskPayload,
|
|
7
|
+
UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
|
|
8
|
+
} from "@opengeni/contracts";
|
|
9
|
+
import {
|
|
10
|
+
createScheduledTask,
|
|
11
|
+
deleteScheduledTask,
|
|
12
|
+
getScheduledTask,
|
|
13
|
+
updateScheduledTask,
|
|
14
|
+
type Database,
|
|
15
|
+
type UpdateScheduledTaskInput,
|
|
16
|
+
} from "@opengeni/db";
|
|
17
|
+
import { HTTPException } from "hono/http-exception";
|
|
18
|
+
import { requirePermission } from "../access";
|
|
19
|
+
import type { SessionWorkflowClient } from "../dependencies";
|
|
20
|
+
import type { ObjectStorageDependency } from "../dependencies";
|
|
21
|
+
import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
|
|
22
|
+
import { validateEnvironmentAttachment } from "./environments";
|
|
23
|
+
import { assertConfiguredModel } from "./sessions";
|
|
24
|
+
import {
|
|
25
|
+
normalizeResources,
|
|
26
|
+
validateFileResources,
|
|
27
|
+
validateGitHubRepositorySelection,
|
|
28
|
+
validateToolRefs,
|
|
29
|
+
withDefaultEnabledCapabilityMcpTools,
|
|
30
|
+
} from "./resources";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a raw scheduled-task payload explicitly set agentConfig.tools.
|
|
34
|
+
* Zod's `.default([])` erases the distinction between "absent" and
|
|
35
|
+
* "explicitly empty", so callers detect it on the raw payload — the same
|
|
36
|
+
* contract sessions use: absent tools mean "give me the workspace defaults
|
|
37
|
+
* (enabled capability MCP servers)", an explicit list (even empty) is taken
|
|
38
|
+
* verbatim.
|
|
39
|
+
*/
|
|
40
|
+
export function scheduledTaskToolsProvided(rawPayload: unknown): boolean {
|
|
41
|
+
if (!rawPayload || typeof rawPayload !== "object") {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const agentConfig = (rawPayload as { agentConfig?: unknown }).agentConfig;
|
|
45
|
+
return Boolean(
|
|
46
|
+
agentConfig
|
|
47
|
+
&& typeof agentConfig === "object"
|
|
48
|
+
&& Object.prototype.hasOwnProperty.call(agentConfig, "tools"),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function createValidatedScheduledTask(input: {
|
|
53
|
+
settings: Settings;
|
|
54
|
+
db: Database;
|
|
55
|
+
objectStorage: ObjectStorageDependency;
|
|
56
|
+
grant: AccessGrant;
|
|
57
|
+
payload: CreateScheduledTaskPayload;
|
|
58
|
+
// Whether the caller explicitly set agentConfig.tools (see
|
|
59
|
+
// scheduledTaskToolsProvided). Absent tools get the workspace's enabled
|
|
60
|
+
// capability MCP servers, mirroring session creation.
|
|
61
|
+
toolsProvided?: boolean;
|
|
62
|
+
// Set for pack-installation-inherited attachments that were already
|
|
63
|
+
// authorized with environments:use when the pack was enabled.
|
|
64
|
+
environmentPreauthorized?: boolean;
|
|
65
|
+
}): Promise<ScheduledTask> {
|
|
66
|
+
const agentConfig = await validateScheduledTaskAgentConfig({ ...input, workspaceId: input.grant.workspaceId });
|
|
67
|
+
const id = crypto.randomUUID();
|
|
68
|
+
validateScheduledTaskSchedule(input.payload.schedule);
|
|
69
|
+
if (input.payload.environmentId) {
|
|
70
|
+
await validateEnvironmentAttachment(
|
|
71
|
+
{ settings: input.settings, db: input.db },
|
|
72
|
+
input.grant,
|
|
73
|
+
input.grant.workspaceId,
|
|
74
|
+
input.payload.environmentId,
|
|
75
|
+
{ preauthorized: input.environmentPreauthorized ?? false },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return await createScheduledTask(input.db, {
|
|
79
|
+
id,
|
|
80
|
+
accountId: input.grant.accountId,
|
|
81
|
+
workspaceId: input.grant.workspaceId,
|
|
82
|
+
name: trimmedScheduledTaskName(input.payload.name),
|
|
83
|
+
status: input.payload.status,
|
|
84
|
+
schedule: input.payload.schedule,
|
|
85
|
+
temporalScheduleId: scheduledTaskTemporalScheduleId(id),
|
|
86
|
+
runMode: input.payload.runMode,
|
|
87
|
+
overlapPolicy: input.payload.overlapPolicy,
|
|
88
|
+
agentConfig,
|
|
89
|
+
environmentId: input.payload.environmentId ?? null,
|
|
90
|
+
metadata: input.payload.metadata,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function validatedScheduledTaskUpdate(input: {
|
|
95
|
+
settings: Settings;
|
|
96
|
+
db: Database;
|
|
97
|
+
objectStorage: ObjectStorageDependency;
|
|
98
|
+
grant: AccessGrant;
|
|
99
|
+
existing: ScheduledTask;
|
|
100
|
+
payload: UpdateScheduledTaskPayload;
|
|
101
|
+
/** See createValidatedScheduledTask; only consulted when agentConfig is updated. */
|
|
102
|
+
toolsProvided?: boolean;
|
|
103
|
+
}): Promise<UpdateScheduledTaskInput> {
|
|
104
|
+
const update: UpdateScheduledTaskInput = {};
|
|
105
|
+
if (input.payload.name !== undefined) {
|
|
106
|
+
update.name = trimmedScheduledTaskName(input.payload.name);
|
|
107
|
+
}
|
|
108
|
+
if (input.payload.status !== undefined) {
|
|
109
|
+
update.status = input.payload.status;
|
|
110
|
+
}
|
|
111
|
+
if (input.payload.schedule !== undefined) {
|
|
112
|
+
validateScheduledTaskSchedule(input.payload.schedule);
|
|
113
|
+
update.schedule = input.payload.schedule;
|
|
114
|
+
}
|
|
115
|
+
if (input.payload.runMode !== undefined) {
|
|
116
|
+
update.runMode = input.payload.runMode;
|
|
117
|
+
}
|
|
118
|
+
if (input.payload.overlapPolicy !== undefined) {
|
|
119
|
+
update.overlapPolicy = input.payload.overlapPolicy;
|
|
120
|
+
}
|
|
121
|
+
if (input.payload.metadata !== undefined) {
|
|
122
|
+
update.metadata = input.payload.metadata;
|
|
123
|
+
}
|
|
124
|
+
if (input.payload.environmentId !== undefined) {
|
|
125
|
+
const nextEnvironmentId = input.payload.environmentId;
|
|
126
|
+
if ((input.existing.environmentId ?? null) !== (nextEnvironmentId ?? null)
|
|
127
|
+
&& input.existing.runMode === "reusable_session"
|
|
128
|
+
&& input.existing.reusableSessionId) {
|
|
129
|
+
throw new HTTPException(409, { message: "cannot change environment of a task with a live reusable session; recreate the task" });
|
|
130
|
+
}
|
|
131
|
+
if (nextEnvironmentId === null) {
|
|
132
|
+
if (input.existing.environmentId !== null) {
|
|
133
|
+
// Detaching is also an attachment change: it strips the secrets a
|
|
134
|
+
// task's instructions were designed around.
|
|
135
|
+
requirePermission(input.grant, "environments:use");
|
|
136
|
+
}
|
|
137
|
+
update.environmentId = null;
|
|
138
|
+
} else {
|
|
139
|
+
await validateEnvironmentAttachment(
|
|
140
|
+
{ settings: input.settings, db: input.db },
|
|
141
|
+
input.grant,
|
|
142
|
+
input.existing.workspaceId,
|
|
143
|
+
nextEnvironmentId,
|
|
144
|
+
);
|
|
145
|
+
update.environmentId = nextEnvironmentId;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (input.payload.agentConfig !== undefined) {
|
|
149
|
+
// Editing the instructions of a task that injects workspace secrets is
|
|
150
|
+
// equivalent to attaching those secrets to new instructions, so it
|
|
151
|
+
// requires environments:use even though plain task edits do not.
|
|
152
|
+
const willHaveEnvironment = input.payload.environmentId !== undefined
|
|
153
|
+
? input.payload.environmentId !== null
|
|
154
|
+
: Boolean(input.existing.environmentId);
|
|
155
|
+
if (willHaveEnvironment) {
|
|
156
|
+
requirePermission(input.grant, "environments:use");
|
|
157
|
+
}
|
|
158
|
+
update.agentConfig = await validateScheduledTaskAgentConfig({
|
|
159
|
+
settings: input.settings,
|
|
160
|
+
db: input.db,
|
|
161
|
+
objectStorage: input.objectStorage,
|
|
162
|
+
workspaceId: input.existing.workspaceId,
|
|
163
|
+
payload: { agentConfig: input.payload.agentConfig },
|
|
164
|
+
...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return update;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function requireScheduledTaskForApi(db: Database, workspaceId: string, taskId: string): Promise<ScheduledTask> {
|
|
171
|
+
const task = await getScheduledTask(db, workspaceId, taskId);
|
|
172
|
+
if (!task) {
|
|
173
|
+
throw new HTTPException(404, { message: "scheduled task not found" });
|
|
174
|
+
}
|
|
175
|
+
return task;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function restoreScheduledTask(db: Database, task: ScheduledTask): Promise<ScheduledTask> {
|
|
179
|
+
return await updateScheduledTask(db, task.workspaceId, task.id, {
|
|
180
|
+
name: task.name,
|
|
181
|
+
status: task.status,
|
|
182
|
+
schedule: task.schedule,
|
|
183
|
+
runMode: task.runMode,
|
|
184
|
+
overlapPolicy: task.overlapPolicy,
|
|
185
|
+
agentConfig: task.agentConfig,
|
|
186
|
+
reusableSessionId: task.reusableSessionId,
|
|
187
|
+
environmentId: task.environmentId,
|
|
188
|
+
metadata: task.metadata,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function syncCreatedScheduledTask(input: {
|
|
193
|
+
db: Database;
|
|
194
|
+
workflowClient: SessionWorkflowClient;
|
|
195
|
+
task: ScheduledTask;
|
|
196
|
+
}): Promise<void> {
|
|
197
|
+
try {
|
|
198
|
+
await input.workflowClient.syncScheduledTask({ task: input.task });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id).catch(() => undefined);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function syncUpdatedScheduledTask(input: {
|
|
206
|
+
db: Database;
|
|
207
|
+
workflowClient: SessionWorkflowClient;
|
|
208
|
+
previous: ScheduledTask;
|
|
209
|
+
task: ScheduledTask;
|
|
210
|
+
}): Promise<void> {
|
|
211
|
+
try {
|
|
212
|
+
await input.workflowClient.syncScheduledTask({ task: input.task });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await restoreScheduledTask(input.db, input.previous).catch(() => undefined);
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function scheduledTaskTemporalScheduleId(taskId: string): string {
|
|
220
|
+
return `scheduled-task-${taskId}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Stable token that identifies a single logical manual trigger. A client that
|
|
225
|
+
* retries a `/trigger` POST (network blip, lambda re-invocation) passes the
|
|
226
|
+
* SAME token so the retry is idempotent — one usage charge, one workflow run.
|
|
227
|
+
* When the client supplies nothing we mint one UUID PER REQUEST and reuse it
|
|
228
|
+
* for both the idempotency key and the workflowId, so a single request stays
|
|
229
|
+
* internally consistent while two genuinely-distinct manual triggers (no token,
|
|
230
|
+
* fired a second apart) still each get their own run. The token is sanitized to
|
|
231
|
+
* the Temporal workflow-id-safe charset so a client value cannot smuggle a
|
|
232
|
+
* collision into a different task's id space.
|
|
233
|
+
*/
|
|
234
|
+
export function scheduledTaskTriggerToken(clientTriggerId?: string | null): string {
|
|
235
|
+
const trimmed = (clientTriggerId ?? "").trim();
|
|
236
|
+
if (!trimmed) {
|
|
237
|
+
return crypto.randomUUID();
|
|
238
|
+
}
|
|
239
|
+
const safe = trimmed.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 128);
|
|
240
|
+
// A value that sanitizes to empty (only disallowed chars) is unusable as a
|
|
241
|
+
// stable id; fall back to a fresh token rather than collapse to a constant.
|
|
242
|
+
return safe.length > 0 ? safe : crypto.randomUUID();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Deterministic Temporal workflow id for a manual trigger. Derived purely from
|
|
247
|
+
* the task id and the stable trigger token, so a retry with the same token maps
|
|
248
|
+
* to the same id and `workflowIdReusePolicy: "REJECT_DUPLICATE"` collapses the
|
|
249
|
+
* second start into a no-op instead of spawning a second run.
|
|
250
|
+
*/
|
|
251
|
+
export function manualScheduledTaskTriggerWorkflowId(taskId: string, triggerToken: string): string {
|
|
252
|
+
return `scheduled-task-${taskId}-manual-${triggerToken}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Deterministic usage idempotency key for a manual trigger's agent_run.created
|
|
257
|
+
* charge. Shares the stable trigger token with the workflow id so the charge
|
|
258
|
+
* and the run dedupe together under retry.
|
|
259
|
+
*/
|
|
260
|
+
export function manualScheduledTaskTriggerUsageKey(workspaceId: string, taskId: string, triggerToken: string): string {
|
|
261
|
+
return `agent_run.created:scheduled-trigger:${workspaceId}:${taskId}:${triggerToken}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function validateScheduledTaskAgentConfig(input: {
|
|
265
|
+
settings: Settings;
|
|
266
|
+
db: Database;
|
|
267
|
+
objectStorage: ObjectStorageDependency;
|
|
268
|
+
payload: { agentConfig: ScheduledTaskAgentConfig };
|
|
269
|
+
workspaceId: string;
|
|
270
|
+
toolsProvided?: boolean;
|
|
271
|
+
}): Promise<ScheduledTaskAgentConfig> {
|
|
272
|
+
// Reject a curated-out model before touching the DB: a scheduled task is a
|
|
273
|
+
// session the worker runs later, so it must pass the same allow-list as the
|
|
274
|
+
// session choke points (a `scheduled_tasks:manage` holder could otherwise set
|
|
275
|
+
// a model the host does not expose). An omitted model inherits the host
|
|
276
|
+
// default downstream, which is always configured.
|
|
277
|
+
assertConfiguredModel(input.settings, input.payload.agentConfig.model);
|
|
278
|
+
const resources = normalizeResources(input.payload.agentConfig.resources ?? []);
|
|
279
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(input.db, input.workspaceId, input.settings);
|
|
280
|
+
const requestedTools = validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
|
|
281
|
+
// A task whose creator did not choose tools gets the workspace's enabled
|
|
282
|
+
// capability MCP servers, exactly like a session created without a tools
|
|
283
|
+
// key. Scheduled runs are sessions too; "no MCP servers at all" was a trap
|
|
284
|
+
// every pack/template instantiation path kept falling into (a maintenance
|
|
285
|
+
// task that cannot reach its workspace's notebook MCP cannot do its job).
|
|
286
|
+
const tools = (input.toolsProvided ?? true)
|
|
287
|
+
? requestedTools
|
|
288
|
+
: withDefaultEnabledCapabilityMcpTools(requestedTools, input.settings, runtimeSettings);
|
|
289
|
+
const prompt = input.payload.agentConfig.prompt.trim();
|
|
290
|
+
if (!prompt) {
|
|
291
|
+
throw new HTTPException(422, { message: "scheduled task prompt is required" });
|
|
292
|
+
}
|
|
293
|
+
await validateGitHubRepositorySelection(input.db, input.workspaceId, resources);
|
|
294
|
+
if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
|
|
295
|
+
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
296
|
+
}
|
|
297
|
+
await validateFileResources(input.db, input.workspaceId, resources);
|
|
298
|
+
return {
|
|
299
|
+
...input.payload.agentConfig,
|
|
300
|
+
prompt,
|
|
301
|
+
resources,
|
|
302
|
+
tools,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateScheduledTaskSchedule(schedule: ScheduledTask["schedule"]): void {
|
|
307
|
+
if (schedule.type !== "interval" || !schedule.startAt || !schedule.endAt) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (new Date(schedule.startAt).getTime() >= new Date(schedule.endAt).getTime()) {
|
|
311
|
+
throw new HTTPException(422, { message: "interval schedule endAt must be after startAt" });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function trimmedScheduledTaskName(name: string): string {
|
|
316
|
+
const trimmed = name.trim();
|
|
317
|
+
if (!trimmed) {
|
|
318
|
+
throw new HTTPException(422, { message: "scheduled task name is required" });
|
|
319
|
+
}
|
|
320
|
+
return trimmed;
|
|
321
|
+
}
|