@opengeni/api-router 0.21.14 → 0.23.1

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 (54) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/{chunk-R5PDSH2A.js → chunk-T4T2PGU4.js} +3989 -1356
  5. package/dist/chunk-T4T2PGU4.js.map +1 -0
  6. package/dist/http/sse.d.ts +2 -0
  7. package/dist/index.js +29 -9
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/oauth-client.d.ts +8 -0
  10. package/dist/integrations/slack-interactions.d.ts +7 -1
  11. package/dist/mcp/receipts.d.ts +28 -0
  12. package/dist/mcp/scheduled-task-view.d.ts +350 -0
  13. package/dist/mcp/toolspace.d.ts +9 -0
  14. package/dist/routes/transcription-recordings.d.ts +3 -0
  15. package/dist/sandbox/auth-callout.d.ts +2 -0
  16. package/dist/sandbox/channel-a.d.ts +5 -1
  17. package/dist/transcription/segmenter.d.ts +10 -0
  18. package/dist/transcription/service.d.ts +5 -0
  19. package/package.json +12 -12
  20. package/src/app.ts +39 -6
  21. package/src/auth/managed-auth.ts +0 -16
  22. package/src/http/sse.ts +101 -6
  23. package/src/index.ts +28 -3
  24. package/src/integrations/oauth-client.ts +36 -56
  25. package/src/integrations/slack-interactions.ts +123 -15
  26. package/src/mcp/documents.ts +42 -25
  27. package/src/mcp/receipts.ts +95 -0
  28. package/src/mcp/scheduled-task-view.ts +608 -0
  29. package/src/mcp/server.ts +812 -182
  30. package/src/mcp/toolspace.ts +75 -71
  31. package/src/observability.ts +3 -3
  32. package/src/routes/api-keys.ts +7 -1
  33. package/src/routes/codex.ts +7 -4
  34. package/src/routes/connections.ts +74 -3
  35. package/src/routes/enrollments.ts +54 -12
  36. package/src/routes/environments.ts +60 -11
  37. package/src/routes/files.ts +175 -65
  38. package/src/routes/install.ts +31 -1
  39. package/src/routes/machines.ts +1 -1
  40. package/src/routes/scheduled-tasks.ts +39 -14
  41. package/src/routes/sessions.ts +77 -12
  42. package/src/routes/transcription-recordings.ts +754 -0
  43. package/src/routes/transcriptions.ts +2 -0
  44. package/src/sandbox/auth-callout.ts +16 -4
  45. package/src/sandbox/channel-a.ts +124 -7
  46. package/src/sandbox/enrollment.ts +13 -3
  47. package/src/sandbox/machines.ts +1 -1
  48. package/src/sandbox/viewer.ts +29 -20
  49. package/src/transcription/providers/azure-openai.ts +4 -3
  50. package/src/transcription/providers/codex-subscription.ts +4 -1
  51. package/src/transcription/providers/openai.ts +7 -2
  52. package/src/transcription/segmenter.ts +260 -0
  53. package/src/transcription/service.ts +111 -10
  54. package/dist/chunk-R5PDSH2A.js.map +0 -1
@@ -8,6 +8,7 @@ import {
8
8
  RETAINED_OUTPUT_MAX_PAGE_BYTES,
9
9
  RetainedArtifactMetadataSchema,
10
10
  retainedArtifactReferenceFromFile,
11
+ retainedScreenshotReferenceFromFile,
11
12
  resolveRetainedOutputRange,
12
13
  type RetainedArtifactMetadata,
13
14
  type RetainedOutputUnavailableReason,
@@ -19,10 +20,12 @@ import {
19
20
  createFileUpload,
20
21
  getFileUpload,
21
22
  getRetainedFileArtifact,
23
+ getRetainedScreenshotArtifact,
22
24
  requireFile,
23
25
  type RetainedFileArtifact,
26
+ type RetainedScreenshotArtifact,
24
27
  } from "@opengeni/db";
25
- import type { Hono } from "hono";
28
+ import type { Context, Hono } from "hono";
26
29
  import { HTTPException } from "hono/http-exception";
27
30
  import { requireAccessGrant } from "@opengeni/core";
28
31
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
@@ -36,7 +39,9 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
36
39
  app.all("/v1/workspaces/:workspaceId/mcp/files", async (c) => {
37
40
  const workspaceId = c.req.param("workspaceId");
38
41
  const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
39
- const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
42
+ const transport = new WebStandardStreamableHTTPServerTransport({
43
+ enableJsonResponse: true,
44
+ });
40
45
  const server = buildFilesMcpServer(deps, grant);
41
46
  await server.connect(transport);
42
47
  return await transport.handleRequest(c.req.raw);
@@ -46,7 +51,9 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
46
51
  const workspaceId = c.req.param("workspaceId");
47
52
  const grant = await requireAccessGrant(c, deps, workspaceId, "files:upload");
48
53
  if (!objectStorage) {
49
- throw new HTTPException(503, { message: "object storage is not configured" });
54
+ throw new HTTPException(503, {
55
+ message: "object storage is not configured",
56
+ });
50
57
  }
51
58
  const payload = CreateFileUploadRequest.parse(await c.req.json());
52
59
  await requireLimit(deps, {
@@ -98,7 +105,9 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
98
105
  const workspaceId = c.req.param("workspaceId");
99
106
  const grant = await requireAccessGrant(c, deps, workspaceId, "files:upload");
100
107
  if (!objectStorage) {
101
- throw new HTTPException(503, { message: "object storage is not configured" });
108
+ throw new HTTPException(503, {
109
+ message: "object storage is not configured",
110
+ });
102
111
  }
103
112
  const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
104
113
  if (!upload) {
@@ -186,7 +195,9 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
186
195
  terminalStatus,
187
196
  });
188
197
  if (!settled) {
189
- throw new HTTPException(409, { message: "file upload cleanup claim was superseded" });
198
+ throw new HTTPException(409, {
199
+ message: "file upload cleanup claim was superseded",
200
+ });
190
201
  }
191
202
  throw new HTTPException(status, { message });
192
203
  };
@@ -275,75 +286,63 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
275
286
  return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
276
287
  }
277
288
 
278
- const metadata = retainedArtifactMetadata(artifact);
279
- if (!metadata.available) {
280
- return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
281
- }
282
- if (!objectStorage) {
283
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
284
- }
289
+ return await serveRetainedArtifactContent(
290
+ c,
291
+ objectStorage,
292
+ artifactId,
293
+ artifact.file,
294
+ retainedArtifactMetadata(artifact),
295
+ );
296
+ });
285
297
 
286
- const rangeHeader = c.req.header("range");
287
- const range = resolveRetainedOutputRange(
288
- rangeHeader,
289
- metadata.originalBytes,
290
- rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
298
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/artifacts/:artifactId", async (c) => {
299
+ const workspaceId = c.req.param("workspaceId");
300
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
301
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
302
+ const artifact = await getRetainedScreenshotArtifact(
303
+ db,
304
+ workspaceId,
305
+ c.req.param("sessionId"),
306
+ artifactId,
291
307
  );
292
- if (range.kind === "invalid") {
293
- return c.json(
294
- {
295
- message: "invalid retained artifact byte range",
296
- reason: range.reason,
297
- maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES,
298
- },
299
- 400,
300
- );
301
- }
302
- if (range.kind === "unsatisfiable") {
303
- return c.json(
304
- { message: "retained artifact byte range is not satisfiable", reason: range.reason },
305
- 416,
306
- {
307
- "Accept-Ranges": "bytes",
308
- "Content-Range": range.contentRange,
309
- "Cache-Control": "private, no-store",
310
- },
311
- );
308
+ if (!artifact) {
309
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
312
310
  }
311
+ return c.json(retainedScreenshotMetadata(artifact));
312
+ });
313
313
 
314
- const headers = {
315
- "Accept-Ranges": range.acceptRanges,
316
- "Cache-Control": "private, no-store",
317
- "Content-Length": String(range.length),
318
- "Content-Type": metadata.contentType,
319
- "X-Content-Type-Options": "nosniff",
320
- ...(range.contentRange ? { "Content-Range": range.contentRange } : {}),
321
- };
322
- if (range.kind === "empty") {
323
- if (!(await objectStorage.fileExists(artifact.file))) {
324
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
314
+ app.get(
315
+ "/v1/workspaces/:workspaceId/sessions/:sessionId/artifacts/:artifactId/content",
316
+ async (c) => {
317
+ const workspaceId = c.req.param("workspaceId");
318
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
319
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
320
+ const artifact = await getRetainedScreenshotArtifact(
321
+ db,
322
+ workspaceId,
323
+ c.req.param("sessionId"),
324
+ artifactId,
325
+ );
326
+ if (!artifact) {
327
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
325
328
  }
326
- return c.body(null, 200, headers);
327
- }
328
-
329
- const bytes = await objectStorage.getFileRange(artifact.file, {
330
- start: range.start,
331
- end: range.end,
332
- });
333
- if (!bytes) {
334
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
335
- }
336
- if (bytes.byteLength !== range.length) {
337
- throw new HTTPException(502, { message: "object storage returned an invalid byte range" });
338
- }
339
- return c.body(new Uint8Array(bytes), range.status, headers);
340
- });
329
+ return await serveRetainedArtifactContent(
330
+ c,
331
+ objectStorage,
332
+ artifactId,
333
+ artifact.file,
334
+ retainedScreenshotMetadata(artifact),
335
+ );
336
+ },
337
+ );
341
338
 
342
339
  app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
343
340
  const workspaceId = c.req.param("workspaceId");
344
341
  await requireAccessGrant(c, deps, workspaceId, "files:read");
345
342
  if (!objectStorage) {
346
- throw new HTTPException(503, { message: "object storage is not configured" });
343
+ throw new HTTPException(503, {
344
+ message: "object storage is not configured",
345
+ });
347
346
  }
348
347
  const file = await requireFile(db, workspaceId, c.req.param("fileId")).catch(() => null);
349
348
  if (!file) {
@@ -362,6 +361,81 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
362
361
  });
363
362
  }
364
363
 
364
+ async function serveRetainedArtifactContent(
365
+ c: Context,
366
+ objectStorage: ApiRouteDeps["objectStorage"],
367
+ artifactId: string,
368
+ file: RetainedFileArtifact["file"],
369
+ metadata: RetainedArtifactMetadata,
370
+ ) {
371
+ if (!metadata.available) {
372
+ return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
373
+ }
374
+ if (!objectStorage) {
375
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
376
+ }
377
+
378
+ const rangeHeader = c.req.header("range");
379
+ const range = resolveRetainedOutputRange(
380
+ rangeHeader,
381
+ metadata.originalBytes,
382
+ rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
383
+ );
384
+ if (range.kind === "invalid") {
385
+ return c.json(
386
+ {
387
+ message: "invalid retained artifact byte range",
388
+ reason: range.reason,
389
+ maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES,
390
+ },
391
+ 400,
392
+ );
393
+ }
394
+ if (range.kind === "unsatisfiable") {
395
+ return c.json(
396
+ {
397
+ message: "retained artifact byte range is not satisfiable",
398
+ reason: range.reason,
399
+ },
400
+ 416,
401
+ {
402
+ "Accept-Ranges": "bytes",
403
+ "Content-Range": range.contentRange,
404
+ "Cache-Control": "private, no-store",
405
+ },
406
+ );
407
+ }
408
+
409
+ const headers = {
410
+ "Accept-Ranges": range.acceptRanges,
411
+ "Cache-Control": "private, no-store",
412
+ "Content-Length": String(range.length),
413
+ "Content-Type": metadata.contentType,
414
+ "X-Content-Type-Options": "nosniff",
415
+ ...(range.contentRange ? { "Content-Range": range.contentRange } : {}),
416
+ };
417
+ if (range.kind === "empty") {
418
+ if (!(await objectStorage.fileExists(file))) {
419
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
420
+ }
421
+ return c.body(null, 200, headers);
422
+ }
423
+
424
+ const bytes = await objectStorage.getFileRange(file, {
425
+ start: range.start,
426
+ end: range.end,
427
+ });
428
+ if (!bytes) {
429
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
430
+ }
431
+ if (bytes.byteLength !== range.length) {
432
+ throw new HTTPException(502, {
433
+ message: "object storage returned an invalid byte range",
434
+ });
435
+ }
436
+ return c.body(new Uint8Array(bytes), range.status, headers);
437
+ }
438
+
365
439
  export function sanitizeFilename(filename: string): string {
366
440
  const trimmed = filename.trim().replace(/[/\\]/g, "_");
367
441
  const safe = trimmed
@@ -388,7 +462,11 @@ function retainedArtifactUnavailable(
388
462
  artifactId: string,
389
463
  reason: RetainedOutputUnavailableReason,
390
464
  ): RetainedArtifactMetadata {
391
- return RetainedArtifactMetadataSchema.parse({ available: false, artifactId, reason });
465
+ return RetainedArtifactMetadataSchema.parse({
466
+ available: false,
467
+ artifactId,
468
+ reason,
469
+ });
392
470
  }
393
471
 
394
472
  function retainedArtifactMetadata(artifact: RetainedFileArtifact): RetainedArtifactMetadata {
@@ -417,6 +495,35 @@ function retainedArtifactMetadata(artifact: RetainedFileArtifact): RetainedArtif
417
495
  return retainedArtifactUnavailable(file.id, "unsupported");
418
496
  }
419
497
 
498
+ function retainedScreenshotMetadata(
499
+ artifact: RetainedScreenshotArtifact,
500
+ ): RetainedArtifactMetadata {
501
+ if (
502
+ artifact.status === "ready" &&
503
+ artifact.sessionId &&
504
+ artifact.retentionExpiresAt.getTime() > Date.now()
505
+ ) {
506
+ const reference = retainedScreenshotReferenceFromFile({
507
+ ...artifact.file,
508
+ sessionId: artifact.sessionId,
509
+ width: artifact.width,
510
+ height: artifact.height,
511
+ expiresAt: artifact.retentionExpiresAt.toISOString(),
512
+ });
513
+ if (reference) return reference;
514
+ }
515
+ if (artifact.status === "deleted") {
516
+ return retainedArtifactUnavailable(artifact.artifactId, "deleted");
517
+ }
518
+ if (artifact.status === "expired" || artifact.retentionExpiresAt.getTime() <= Date.now()) {
519
+ return retainedArtifactUnavailable(artifact.artifactId, "expired");
520
+ }
521
+ if (artifact.status === "pending" || artifact.status === "reconciling") {
522
+ return retainedArtifactUnavailable(artifact.artifactId, "pending");
523
+ }
524
+ return retainedArtifactUnavailable(artifact.artifactId, "failed");
525
+ }
526
+
420
527
  function retainedArtifactUnavailableStatus(
421
528
  reason: RetainedOutputUnavailableReason,
422
529
  ): 404 | 409 | 410 | 422 {
@@ -429,6 +536,9 @@ function retainedArtifactUnavailableStatus(
429
536
  case "unsupported":
430
537
  case "not_retained":
431
538
  case "storage_write_failed":
539
+ case "quota_exceeded":
540
+ case "invalid_content":
541
+ case "oversized":
432
542
  return 422;
433
543
  case "pending":
434
544
  case "failed":
@@ -200,6 +200,31 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
200
200
  return serveAsset(asset, `${releasesBase}/download/${stableAgentTag}/${asset}`);
201
201
  });
202
202
 
203
+ // Signed self-update channel manifests. Each deployment exposes its own
204
+ // immutable promotion pointer, so an enrolled machine never depends on the
205
+ // public get.opengeni.ai hostname. The manifest and signature are ordinary
206
+ // assets on the selected immutable agent release.
207
+ for (const [channel, version] of [
208
+ ["stable", deps.settings.agentStableVersion],
209
+ ["beta", deps.settings.agentBetaVersion],
210
+ ] as const) {
211
+ app.get(`/agent/${channel}/:manifestAsset`, (c) => {
212
+ const manifestAsset = c.req.param("manifestAsset");
213
+ if (manifestAsset !== "manifest.json" && manifestAsset !== "manifest.json.minisig") {
214
+ throw new HTTPException(404, { message: "update manifest asset not found" });
215
+ }
216
+ if (!version) {
217
+ throw new HTTPException(404, { message: `${channel} agent channel is not configured` });
218
+ }
219
+ return new Response(null, {
220
+ status: 302,
221
+ headers: {
222
+ location: `${releasesBase}/download/agent-v${version}/${manifestAsset}`,
223
+ },
224
+ });
225
+ });
226
+ }
227
+
203
228
  // The version segment is the literal `v<ver>` (e.g. `v1.2.3`) — Hono cannot bind
204
229
  // a param glued to a literal prefix, so the whole segment is the param and the
205
230
  // `v` prefix is validated/stripped here. The release tag is `agent-v<ver>`.
@@ -220,5 +245,10 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
220
245
  export const installExactPaths: ReadonlySet<string> = new Set(Object.keys(TEXT_ASSETS));
221
246
 
222
247
  export function isInstallRedirectPath(path: string): boolean {
223
- return path.startsWith("/agent/latest/") || path.startsWith("/agent/v");
248
+ return (
249
+ path.startsWith("/agent/latest/") ||
250
+ path.startsWith("/agent/v") ||
251
+ path.startsWith("/agent/stable/") ||
252
+ path.startsWith("/agent/beta/")
253
+ );
224
254
  }
@@ -73,7 +73,7 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
73
73
  // Validate the machine belongs to this workspace (RLS already scopes the read,
74
74
  // but a clear 404 beats an empty series for an unknown/cross-workspace id).
75
75
  const enrollment = await getEnrollment(db, workspaceId, enrollmentId);
76
- if (!enrollment) {
76
+ if (!enrollment || enrollment.status !== "active") {
77
77
  throw new HTTPException(404, { message: "machine not found in this workspace" });
78
78
  }
79
79
  const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
@@ -19,10 +19,13 @@ import {
19
19
  manualScheduledTaskTriggerUsageKey,
20
20
  manualScheduledTaskTriggerWorkflowId,
21
21
  scheduledTaskToolsProvided,
22
+ scheduledTaskForGrant,
23
+ scheduledTaskRunForGrant,
22
24
  scheduledTaskTriggerToken,
23
25
  requireScheduledTaskForApi,
24
26
  syncCreatedScheduledTask,
25
27
  syncUpdatedScheduledTask,
28
+ validateScheduledTaskTarget,
26
29
  validatedScheduledTaskUpdate,
27
30
  } from "@opengeni/core";
28
31
  import { boundedLimit } from "../http/common";
@@ -48,21 +51,25 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
48
51
  grant,
49
52
  payload,
50
53
  toolsProvided: scheduledTaskToolsProvided(rawPayload),
54
+ sessionAuthorization: deps.sessionAuthorization,
55
+ authorizationSurface: "http",
51
56
  });
52
57
  await syncCreatedScheduledTask({ db, workflowClient, task });
53
- return c.json(task, 201);
58
+ return c.json(scheduledTaskForGrant(task, grant), 201);
54
59
  });
55
60
 
56
61
  app.get("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
57
62
  const workspaceId = c.req.param("workspaceId");
58
- await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
59
- return c.json(await listScheduledTasks(db, workspaceId, boundedLimit(c.req.query("limit"))));
63
+ const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
64
+ const tasks = await listScheduledTasks(db, workspaceId, boundedLimit(c.req.query("limit")));
65
+ return c.json(tasks.map((task) => scheduledTaskForGrant(task, grant)));
60
66
  });
61
67
 
62
68
  app.get("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
63
69
  const workspaceId = c.req.param("workspaceId");
64
- await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
65
- return c.json(await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId")));
70
+ const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
71
+ const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
72
+ return c.json(scheduledTaskForGrant(task, grant));
66
73
  });
67
74
 
68
75
  app.patch("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
@@ -81,30 +88,32 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
81
88
  existing,
82
89
  payload,
83
90
  toolsProvided: scheduledTaskToolsProvided(rawPayload),
91
+ sessionAuthorization: deps.sessionAuthorization,
92
+ authorizationSurface: "http",
84
93
  });
85
94
  const task = await updateScheduledTask(db, workspaceId, taskId, update);
86
95
  await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
87
- return c.json(task);
96
+ return c.json(scheduledTaskForGrant(task, grant));
88
97
  });
89
98
 
90
99
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/pause", async (c) => {
91
100
  const workspaceId = c.req.param("workspaceId");
92
- await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
101
+ const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
93
102
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
94
103
  const previous = await captureScheduledTaskRestoreState(db, existing);
95
104
  const task = await updateScheduledTask(db, workspaceId, existing.id, { status: "paused" });
96
105
  await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
97
- return c.json(task);
106
+ return c.json(scheduledTaskForGrant(task, grant));
98
107
  });
99
108
 
100
109
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/resume", async (c) => {
101
110
  const workspaceId = c.req.param("workspaceId");
102
- await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
111
+ const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:manage");
103
112
  const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
104
113
  const previous = await captureScheduledTaskRestoreState(db, existing);
105
114
  const task = await updateScheduledTask(db, workspaceId, existing.id, { status: "active" });
106
115
  await syncUpdatedScheduledTask({ db, workflowClient, previous, task });
107
- return c.json(task);
116
+ return c.json(scheduledTaskForGrant(task, grant));
108
117
  });
109
118
 
110
119
  app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/trigger", async (c) => {
@@ -113,6 +122,18 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
113
122
  // Load the task before the gate so a codex-model scheduled task can be
114
123
  // recognised as codex-billed and skip the credit/cost gates at the edge.
115
124
  const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
125
+ await validateScheduledTaskTarget({
126
+ db,
127
+ sessionAuthorization: deps.sessionAuthorization,
128
+ authorizationSurface: "http",
129
+ grant,
130
+ targetSessionId: task.targetSessionId,
131
+ runMode: task.runMode,
132
+ variableSetId: task.variableSetId,
133
+ rigId: task.rigId,
134
+ agentConfig: task.agentConfig,
135
+ missingTargetStatus: 404,
136
+ });
116
137
  await requireLimit(deps, {
117
138
  accountId: grant.accountId,
118
139
  workspaceId,
@@ -148,7 +169,7 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
148
169
  sourceResourceId: task.id,
149
170
  idempotencyKey: agentRunUsageIdempotencyKey,
150
171
  });
151
- return c.json(task, 202);
172
+ return c.json(scheduledTaskForGrant(task, grant), 202);
152
173
  });
153
174
 
154
175
  app.delete("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
@@ -164,10 +185,14 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
164
185
 
165
186
  app.get("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/runs", async (c) => {
166
187
  const workspaceId = c.req.param("workspaceId");
167
- await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
188
+ const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
168
189
  const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
169
- return c.json(
170
- await listScheduledTaskRuns(db, workspaceId, task.id, boundedLimit(c.req.query("limit"))),
190
+ const taskRuns = await listScheduledTaskRuns(
191
+ db,
192
+ workspaceId,
193
+ task.id,
194
+ boundedLimit(c.req.query("limit")),
171
195
  );
196
+ return c.json(taskRuns.map((run) => scheduledTaskRunForGrant(run, grant)));
172
197
  });
173
198
  }