@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.
@@ -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
+ }