veryfront 0.1.75 → 0.1.76

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 (63) hide show
  1. package/README.md +2 -0
  2. package/esm/cli/commands/files/command.d.ts +3 -3
  3. package/esm/deno.d.ts +5 -0
  4. package/esm/deno.js +11 -6
  5. package/esm/src/data/server-data-fetcher.d.ts.map +1 -1
  6. package/esm/src/data/server-data-fetcher.js +17 -1
  7. package/esm/src/jobs/index.d.ts +34 -0
  8. package/esm/src/jobs/index.d.ts.map +1 -0
  9. package/esm/src/jobs/index.js +33 -0
  10. package/esm/src/jobs/jobs-client.d.ts +134 -0
  11. package/esm/src/jobs/jobs-client.d.ts.map +1 -0
  12. package/esm/src/jobs/jobs-client.js +218 -0
  13. package/esm/src/jobs/schemas.d.ts +1304 -0
  14. package/esm/src/jobs/schemas.d.ts.map +1 -0
  15. package/esm/src/jobs/schemas.js +159 -0
  16. package/esm/src/platform/adapters/veryfront-api-client/retry-handler.d.ts +4 -0
  17. package/esm/src/platform/adapters/veryfront-api-client/retry-handler.d.ts.map +1 -1
  18. package/esm/src/platform/adapters/veryfront-api-client/retry-handler.js +12 -6
  19. package/esm/src/proxy/handler.d.ts.map +1 -1
  20. package/esm/src/proxy/handler.js +21 -21
  21. package/esm/src/routing/api/route-executor.d.ts.map +1 -1
  22. package/esm/src/routing/api/route-executor.js +30 -3
  23. package/esm/src/security/deno-permissions.d.ts +1 -1
  24. package/esm/src/security/deno-permissions.d.ts.map +1 -1
  25. package/esm/src/security/deno-permissions.js +2 -1
  26. package/esm/src/security/sandbox/project-worker.d.ts.map +1 -1
  27. package/esm/src/security/sandbox/project-worker.js +2 -2
  28. package/esm/src/security/sandbox/worker-permissions.d.ts.map +1 -1
  29. package/esm/src/security/sandbox/worker-permissions.js +23 -20
  30. package/esm/src/security/sandbox/worker-pool.d.ts.map +1 -1
  31. package/esm/src/security/sandbox/worker-pool.js +17 -14
  32. package/esm/src/security/sandbox/worker-types.d.ts +2 -0
  33. package/esm/src/security/sandbox/worker-types.d.ts.map +1 -1
  34. package/esm/src/security/sandbox/worker-types.js +2 -0
  35. package/esm/src/server/handlers/request/internal-tasks-list.handler.d.ts +11 -0
  36. package/esm/src/server/handlers/request/internal-tasks-list.handler.d.ts.map +1 -0
  37. package/esm/src/server/handlers/request/internal-tasks-list.handler.js +72 -0
  38. package/esm/src/server/runtime-handler/index.d.ts +1 -1
  39. package/esm/src/server/runtime-handler/index.d.ts.map +1 -1
  40. package/esm/src/server/runtime-handler/index.js +3 -0
  41. package/esm/src/task/control-plane.d.ts +105 -0
  42. package/esm/src/task/control-plane.d.ts.map +1 -0
  43. package/esm/src/task/control-plane.js +52 -0
  44. package/esm/src/task/types.d.ts +6 -0
  45. package/esm/src/task/types.d.ts.map +1 -1
  46. package/package.json +5 -1
  47. package/src/deno.js +11 -6
  48. package/src/src/data/server-data-fetcher.ts +30 -2
  49. package/src/src/jobs/index.ts +85 -0
  50. package/src/src/jobs/jobs-client.ts +503 -0
  51. package/src/src/jobs/schemas.ts +202 -0
  52. package/src/src/platform/adapters/veryfront-api-client/retry-handler.ts +15 -6
  53. package/src/src/proxy/handler.ts +27 -19
  54. package/src/src/routing/api/route-executor.ts +43 -7
  55. package/src/src/security/deno-permissions.ts +2 -1
  56. package/src/src/security/sandbox/project-worker.ts +2 -2
  57. package/src/src/security/sandbox/worker-permissions.ts +22 -19
  58. package/src/src/security/sandbox/worker-pool.ts +21 -13
  59. package/src/src/security/sandbox/worker-types.ts +3 -0
  60. package/src/src/server/handlers/request/internal-tasks-list.handler.ts +103 -0
  61. package/src/src/server/runtime-handler/index.ts +3 -0
  62. package/src/src/task/control-plane.ts +76 -0
  63. package/src/src/task/types.ts +6 -0
@@ -18,6 +18,9 @@ export interface RequestOptions {
18
18
  returnText?: boolean;
19
19
  /** Request timeout in milliseconds. Defaults to 30000ms (30 seconds). */
20
20
  timeoutMs?: number;
21
+ method?: string;
22
+ body?: dntShim.BodyInit | null;
23
+ headers?: dntShim.HeadersInit;
21
24
  }
22
25
 
23
26
  /** Default timeout for API requests (30 seconds) */
@@ -48,13 +51,19 @@ export async function requestWithRetry(
48
51
  async () => {
49
52
  const startTime = performance.now();
50
53
 
51
- const headers = new dntShim.Headers({
52
- Authorization: `Bearer ${apiToken}`,
53
- "Content-Type": "application/json",
54
- });
54
+ const headers = new dntShim.Headers(options.headers);
55
+ headers.set("Authorization", `Bearer ${apiToken}`);
56
+ if (!headers.has("Content-Type")) {
57
+ headers.set("Content-Type", "application/json");
58
+ }
55
59
  injectContext(headers);
56
60
 
57
- const response = await dntShim.fetch(url, { headers, signal: controller.signal });
61
+ const response = await dntShim.fetch(url, {
62
+ method: options.method ?? "GET",
63
+ headers,
64
+ body: options.body,
65
+ signal: controller.signal,
66
+ });
58
67
  const duration = performance.now() - startTime;
59
68
 
60
69
  recordApiRequest(response.status);
@@ -89,7 +98,7 @@ export async function requestWithRetry(
89
98
  return { data, status: response.status, duration };
90
99
  },
91
100
  {
92
- "http.method": "GET",
101
+ "http.method": options.method ?? "GET",
93
102
  "http.url": url,
94
103
  "http.target": urlPath,
95
104
  "http.host": urlObj.host,
@@ -3,10 +3,11 @@ import { TokenManager, type TokenScope } from "./token-manager.js";
3
3
  import { type ParsedDomain, parseProjectDomain } from "../server/utils/domain-parser.js";
4
4
  import type { TokenCache } from "./cache/types.js";
5
5
  import { createFileSystem } from "../platform/compat/fs.js";
6
- import { cwd } from "../platform/compat/process.js";
6
+ import { cwd, getEnv } from "../platform/compat/process.js";
7
7
  import { join } from "../platform/compat/path/index.js";
8
8
  import { injectContext, ProxySpanNames, withSpan } from "./tracing.js";
9
9
  import { computeContentSourceId } from "../cache/keys.js";
10
+ import { jwtVerify } from "jose";
10
11
 
11
12
  export const INTERNAL_PROXY_HEADERS = [
12
13
  "x-token",
@@ -151,19 +152,27 @@ function extractUserToken(cookieHeader: string): string | undefined {
151
152
  return match?.[1] ? decodeURIComponent(match[1]) : undefined;
152
153
  }
153
154
 
154
- function extractUserIdFromToken(token: string): string | undefined {
155
+ async function extractUserIdFromToken(
156
+ token: string,
157
+ log?: ProxyLogger,
158
+ ): Promise<string | undefined> {
159
+ const jwtSecret = getEnv("JWT_SECRET");
160
+
161
+ if (!jwtSecret) {
162
+ log?.warn("JWT_SECRET not configured — cannot verify user token");
163
+ return undefined;
164
+ }
165
+
155
166
  try {
156
- const payload = token.split(".")[1];
157
- if (!payload) return undefined;
158
- // JWT payloads are base64url-encoded: normalize to standard base64 before decoding
159
- let base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
160
- const remainder = base64.length % 4;
161
- if (remainder === 2) base64 += "==";
162
- else if (remainder === 3) base64 += "=";
163
- const decoded = JSON.parse(atob(base64));
164
- return decoded?.userId;
165
- } catch (_) {
166
- /* expected: malformed JWT token */
167
+ const secret = new TextEncoder().encode(jwtSecret);
168
+ const { payload } = await jwtVerify(token, secret, {
169
+ algorithms: ["HS256"],
170
+ });
171
+ return (payload as { userId?: string }).userId;
172
+ } catch (error) {
173
+ log?.debug("JWT verification failed", {
174
+ error: error instanceof Error ? error.message : String(error),
175
+ });
167
176
  return undefined;
168
177
  }
169
178
  }
@@ -276,13 +285,13 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
276
285
  return `https://veryfront.com/sign-in?from=${encodeURIComponent(returnPath)}`;
277
286
  }
278
287
 
279
- function checkProtectedAccess(
288
+ async function checkProtectedAccess(
280
289
  req: dntShim.Request,
281
290
  matchingEnv: NonNullable<DomainLookupResult["environments"]>[number] | undefined,
282
291
  userToken: string | undefined,
283
292
  users: DomainLookupResult["users"],
284
293
  logContext: Record<string, unknown>,
285
- ): { status: number; message: string; redirectUrl?: string } | null {
294
+ ): Promise<{ status: number; message: string; redirectUrl?: string } | null> {
286
295
  if (!matchingEnv?.protected) return null;
287
296
 
288
297
  if (!userToken) {
@@ -295,9 +304,8 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
295
304
  return { status: 302, message: "Authentication required", redirectUrl };
296
305
  }
297
306
 
298
- const userId = extractUserIdFromToken(userToken);
307
+ const userId = await extractUserIdFromToken(userToken, logger);
299
308
  if (!userId) {
300
- // Malformed token — treat as unauthenticated so user can re-sign-in
301
309
  const redirectUrl = makeAuthRedirectUrl(req);
302
310
  logger?.info("Could not extract userId from token", {
303
311
  ...logContext,
@@ -334,7 +342,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
334
342
 
335
343
  const matchingEnv = lookupResult.environments?.find(envMatcher);
336
344
 
337
- const protectionError = checkProtectedAccess(
345
+ const protectionError = await checkProtectedAccess(
338
346
  req,
339
347
  matchingEnv,
340
348
  userToken,
@@ -442,7 +450,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) {
442
450
  releaseId = matchingEnv?.active_release_id ?? undefined;
443
451
  environmentId = matchingEnv?.id;
444
452
 
445
- const protectionError = checkProtectedAccess(
453
+ const protectionError = await checkProtectedAccess(
446
454
  req,
447
455
  matchingEnv,
448
456
  userToken,
@@ -24,10 +24,11 @@ import {
24
24
  getWorkerPool,
25
25
  isWorkerIsolationEnabled,
26
26
  } from "../../security/sandbox/worker-pool.js";
27
- import type {
28
- SerializedRequest,
29
- SerializedResponse,
30
- WorkerResponse,
27
+ import {
28
+ MAX_WORKER_BODY_BYTES,
29
+ type SerializedRequest,
30
+ type SerializedResponse,
31
+ type WorkerResponse,
31
32
  } from "../../security/sandbox/worker-types.js";
32
33
  import { getProjectEnvSnapshot } from "../../server/project-env/storage.js";
33
34
 
@@ -129,13 +130,48 @@ function toHeadResponse(response: dntShim.Response): dntShim.Response {
129
130
  // Worker Isolation Helpers
130
131
  // ---------------------------------------------------------------------------
131
132
 
133
+ function checkContentLengthLimit(request: dntShim.Request): void {
134
+ const contentLength = request.headers.get("content-length");
135
+ if (!contentLength) return;
136
+
137
+ const bytes = parseInt(contentLength, 10);
138
+ if (bytes > MAX_WORKER_BODY_BYTES) {
139
+ throw createError({
140
+ type: "api",
141
+ message: `Request body too large for isolated execution (${
142
+ (bytes / 1024 / 1024).toFixed(1)
143
+ } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`,
144
+ });
145
+ }
146
+ }
147
+
148
+ async function readBodyWithSizeGuard(request: dntShim.Request): Promise<Uint8Array | null> {
149
+ if (!request.body) return null;
150
+
151
+ // Fast path: reject before buffering if Content-Length is known
152
+ checkContentLengthLimit(request);
153
+
154
+ const body = new Uint8Array(await request.arrayBuffer());
155
+
156
+ // Fallback: check actual size for chunked/streaming bodies
157
+ if (body.byteLength > MAX_WORKER_BODY_BYTES) {
158
+ throw createError({
159
+ type: "api",
160
+ message: `Request body too large for isolated execution (${
161
+ (body.byteLength / 1024 / 1024).toFixed(1)
162
+ } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`,
163
+ });
164
+ }
165
+
166
+ return body;
167
+ }
168
+
132
169
  async function serializeRequest(request: dntShim.Request): Promise<SerializedRequest> {
133
- const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
134
170
  return {
135
171
  url: request.url,
136
172
  method: request.method,
137
173
  headers: [...request.headers.entries()],
138
- body,
174
+ body: await readBodyWithSizeGuard(request),
139
175
  };
140
176
  }
141
177
 
@@ -252,7 +288,7 @@ function executePagesRouteIsolated(
252
288
  async () => {
253
289
  try {
254
290
  const pool = getWorkerPool();
255
- const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
291
+ const body = await readBodyWithSizeGuard(request);
256
292
 
257
293
  const workerResponse = await pool.execute(
258
294
  projectDir,
@@ -17,8 +17,9 @@ export const SERVER_PERMISSIONS = [
17
17
  "--allow-net",
18
18
  "--allow-env",
19
19
  "--allow-run",
20
- "--allow-ffi",
21
20
  "--allow-sys",
21
+ "--unstable-worker-options",
22
+ "--unstable-net",
22
23
  ] as const;
23
24
 
24
25
  /**
@@ -23,6 +23,7 @@ import type {
23
23
  } from "./worker-types.js";
24
24
 
25
25
  const logger = serverLogger.component("project-worker");
26
+ const textEncoder = new TextEncoder();
26
27
 
27
28
  type ExtendedWorkerOptions = {
28
29
  type: "module";
@@ -179,7 +180,6 @@ export class ProjectWorker {
179
180
  this._status = "busy";
180
181
 
181
182
  const requestId = request.id;
182
- const encoder = new TextEncoder();
183
183
 
184
184
  return new ReadableStream<Uint8Array>({
185
185
  start: (controller) => {
@@ -240,7 +240,7 @@ export class ProjectWorker {
240
240
 
241
241
  // If we get an ssr-result, emit it as a single chunk
242
242
  if (response.type === "ssr-result") {
243
- controller.enqueue(encoder.encode(response.html));
243
+ controller.enqueue(textEncoder.encode(response.html));
244
244
  controller.close();
245
245
  } else if (response.type === "error") {
246
246
  const err = new Error(response.error.message);
@@ -23,6 +23,18 @@ export interface WorkerPermissions {
23
23
  sys: boolean;
24
24
  }
25
25
 
26
+ // Cache compiled binary check — Deno.execPath() is a syscall that never changes at runtime
27
+ const _isCompiledBinary = (() => {
28
+ try {
29
+ const exec = typeof dntShim.Deno !== "undefined" ? dntShim.Deno.execPath?.() : undefined;
30
+ if (!exec) return false;
31
+ const name = exec.split(/[/\\]/).pop()?.toLowerCase() ?? "";
32
+ return name !== "deno" && name !== "deno.exe";
33
+ } catch {
34
+ return false;
35
+ }
36
+ })();
37
+
26
38
  /**
27
39
  * Build scoped permissions for a project worker.
28
40
  *
@@ -41,25 +53,16 @@ export function buildWorkerPermissions(
41
53
  // is outside the project directory. Rather than trying to enumerate all
42
54
  // read paths, grant full read access — the security boundary is enforced
43
55
  // by denying write/run/ffi/sys, not by restricting reads.
44
- // Check for compiled binary by testing if execPath is NOT "deno"/"deno.exe"
45
- try {
46
- const exec = typeof dntShim.Deno !== "undefined" ? dntShim.Deno.execPath?.() : undefined;
47
- if (exec) {
48
- const name = exec.split(/[/\\]/).pop()?.toLowerCase() ?? "";
49
- if (name !== "deno" && name !== "deno.exe") {
50
- return {
51
- read: true,
52
- write: false,
53
- net: true,
54
- env: true,
55
- run: false,
56
- ffi: false,
57
- sys: false,
58
- };
59
- }
60
- }
61
- } catch {
62
- // execPath may not be available
56
+ if (_isCompiledBinary) {
57
+ return {
58
+ read: true,
59
+ write: false,
60
+ net: true,
61
+ env: true,
62
+ run: false,
63
+ ffi: false,
64
+ sys: false,
65
+ };
63
66
  }
64
67
 
65
68
  return {
@@ -121,21 +121,29 @@ export class WorkerPool {
121
121
 
122
122
  if (shouldRecycle && !this.recycling.has(projectId)) {
123
123
  this.recycling.add(projectId);
124
- try {
125
- logger.debug("Recycling worker", {
126
- projectId,
127
- requestCount: worker.requestCount,
128
- ageMs: entry ? Date.now() - entry.createdAt : 0,
129
- reason: worker.requestCount >= this.config.maxRequestsPerWorker
130
- ? "request_count"
131
- : "age",
132
- });
124
+
125
+ logger.debug("Recycling worker", {
126
+ projectId,
127
+ requestCount: worker.requestCount,
128
+ ageMs: entry ? Date.now() - entry.createdAt : 0,
129
+ reason: worker.requestCount >= this.config.maxRequestsPerWorker
130
+ ? "request_count"
131
+ : "age",
132
+ });
133
+
134
+ // Warm replacement: let the old worker handle this last request,
135
+ // then evict it and create a replacement after the request settles.
136
+ // This avoids cold-start latency for the caller AND prevents the
137
+ // old worker from being terminated while it still has pending work.
138
+ const result = worker.execute(request);
139
+
140
+ void result.finally(() => {
133
141
  this.evictWorker(projectId);
134
- const fresh = this.getOrCreateWorker(projectId, readPaths);
135
- return fresh.execute(request);
136
- } finally {
142
+ this.getOrCreateWorker(projectId, readPaths);
137
143
  this.recycling.delete(projectId);
138
- }
144
+ });
145
+
146
+ return result;
139
147
  }
140
148
 
141
149
  return worker.execute(request);
@@ -198,6 +198,9 @@ export interface WorkerPoolConfig {
198
198
  memoryBudgetMb: number;
199
199
  }
200
200
 
201
+ /** Maximum request body size for worker isolation (10 MB) */
202
+ export const MAX_WORKER_BODY_BYTES = 10 * 1024 * 1024;
203
+
201
204
  export const DEFAULT_WORKER_POOL_CONFIG: WorkerPoolConfig = {
202
205
  maxPoolSize: 20,
203
206
  idleTimeoutMs: 300_000,
@@ -0,0 +1,103 @@
1
+ import * as dntShim from "../../../../_dnt.shims.js";
2
+ import { PRIORITY_MEDIUM_API } from "../../../utils/constants/index.js";
3
+ import { ZodError } from "zod";
4
+ import {
5
+ type ControlPlaneTasksListRequest,
6
+ ControlPlaneTasksListRequestSchema,
7
+ defaultRuntimeTaskDiscoveryDeps,
8
+ listRuntimeTasks,
9
+ type RuntimeTaskDiscoveryDeps,
10
+ } from "../../../task/control-plane.js";
11
+ import {
12
+ ControlPlaneRequestError,
13
+ verifyControlPlaneRequest,
14
+ } from "../../../internal-agents/control-plane-auth.js";
15
+ import {
16
+ INTERNAL_AGENT_CONTROL_PLANE_MAX_BODY_BYTES,
17
+ InternalAgentRequestBodyTooLargeError,
18
+ readInternalAgentRequestBody,
19
+ } from "../../../internal-agents/request-body.js";
20
+ import { BaseHandler } from "../response/base.js";
21
+ import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.js";
22
+
23
+ export class InternalTasksListHandler extends BaseHandler {
24
+ metadata: HandlerMetadata = {
25
+ name: "InternalTasksListHandler",
26
+ priority: PRIORITY_MEDIUM_API as HandlerPriority,
27
+ patterns: [{ pattern: "/internal/tasks/list", exact: true, method: "POST" }],
28
+ };
29
+
30
+ constructor(
31
+ private readonly deps: RuntimeTaskDiscoveryDeps = defaultRuntimeTaskDiscoveryDeps,
32
+ ) {
33
+ super();
34
+ }
35
+
36
+ async handle(req: dntShim.Request, ctx: HandlerContext): Promise<HandlerResult> {
37
+ if (!this.shouldHandle(req, ctx)) {
38
+ return this.continue();
39
+ }
40
+
41
+ return this.withProxyContext(ctx, async () => {
42
+ const builder = this.createResponseBuilder(ctx)
43
+ .withCORS(req, ctx.securityConfig?.cors)
44
+ .withSecurity(ctx.securityConfig ?? undefined, req);
45
+
46
+ try {
47
+ const rawBody = await readInternalAgentRequestBody(
48
+ req,
49
+ INTERNAL_AGENT_CONTROL_PLANE_MAX_BODY_BYTES,
50
+ );
51
+ const payload: ControlPlaneTasksListRequest = ControlPlaneTasksListRequestSchema.parse(
52
+ JSON.parse(rawBody),
53
+ );
54
+ const claims = await verifyControlPlaneRequest(req, ctx, rawBody, {
55
+ expectedSubject: payload.requestId,
56
+ expectedSurface: payload.surface,
57
+ });
58
+
59
+ if (
60
+ payload.projectId !== claims.project_id ||
61
+ (ctx.projectId !== undefined && payload.projectId !== ctx.projectId)
62
+ ) {
63
+ this.logWarn("Internal tasks list request body did not match signed claims", {
64
+ projectSlug: ctx.projectSlug,
65
+ projectId: ctx.projectId,
66
+ requestId: payload.requestId,
67
+ signedRequestId: claims.sub,
68
+ surface: payload.surface,
69
+ signedSurface: claims.surface,
70
+ });
71
+ return this.respond(builder.json({ error: "Invalid control-plane signature" }, 401));
72
+ }
73
+
74
+ const response = await listRuntimeTasks(ctx, this.deps);
75
+ return this.respond(builder.json(response, 200));
76
+ } catch (error) {
77
+ if (error instanceof InternalAgentRequestBodyTooLargeError) {
78
+ return this.respond(builder.json({ error: error.message }, error.status));
79
+ }
80
+
81
+ if (error instanceof ControlPlaneRequestError) {
82
+ this.logWarn("Internal tasks list signature verification failed", {
83
+ error: error.message,
84
+ projectSlug: ctx.projectSlug,
85
+ projectId: ctx.projectId,
86
+ });
87
+ return this.respond(builder.json({ error: error.message }, error.status));
88
+ }
89
+
90
+ if (error instanceof SyntaxError || error instanceof ZodError) {
91
+ this.logWarn("Internal tasks list request validation failed", {
92
+ error: error instanceof Error ? error.message : String(error),
93
+ projectSlug: ctx.projectSlug,
94
+ projectId: ctx.projectId,
95
+ });
96
+ return this.respond(builder.json({ error: "Invalid internal tasks request" }, 400));
97
+ }
98
+
99
+ throw error;
100
+ }
101
+ });
102
+ }
103
+ }
@@ -55,6 +55,7 @@ import { MarkdownPreviewHandler } from "../handlers/preview/markdown-preview.han
55
55
  import { OpenAPIHandler } from "../handlers/request/openapi.handler.js";
56
56
  import { OpenAPIDocsHandler } from "../handlers/request/openapi-docs.handler.js";
57
57
  import { InternalAgentsListHandler } from "../handlers/request/internal-agents-list.handler.js";
58
+ import { InternalTasksListHandler } from "../handlers/request/internal-tasks-list.handler.js";
58
59
  import { AgentStreamHandler } from "../handlers/request/agent-stream.handler.js";
59
60
  import { AgentRunResumeHandler } from "../handlers/request/agent-run-resume.handler.js";
60
61
  import { AgentRunCancelHandler } from "../handlers/request/agent-run-cancel.handler.js";
@@ -131,6 +132,7 @@ export const HANDLER_NAMES = [
131
132
  "OpenAPIHandler",
132
133
  "OpenAPIDocsHandler",
133
134
  "InternalAgentsListHandler",
135
+ "InternalTasksListHandler",
134
136
  "AgentStreamHandler",
135
137
  "AgentRunResumeHandler",
136
138
  "AgentRunCancelHandler",
@@ -185,6 +187,7 @@ const handlerFactories: Record<
185
187
  OpenAPIHandler: () => new OpenAPIHandler(),
186
188
  OpenAPIDocsHandler: () => new OpenAPIDocsHandler(),
187
189
  InternalAgentsListHandler: () => new InternalAgentsListHandler(),
190
+ InternalTasksListHandler: () => new InternalTasksListHandler(),
188
191
  AgentStreamHandler: () => new AgentStreamHandler(),
189
192
  AgentRunResumeHandler: () => new AgentRunResumeHandler(),
190
193
  AgentRunCancelHandler: () => new AgentRunCancelHandler(),
@@ -0,0 +1,76 @@
1
+ import { ControlPlaneSurfaceSchema } from "../channels/control-plane.js";
2
+ import type { HandlerContext } from "../types/index.js";
3
+ import { z } from "zod";
4
+ import { discoverTasks, type TaskDiscoveryOptions } from "./discovery.js";
5
+
6
+ export const ControlPlaneTasksListRequestSchema = z.object({
7
+ requestId: z.string().min(1),
8
+ projectId: z.string().min(1),
9
+ surface: ControlPlaneSurfaceSchema,
10
+ });
11
+
12
+ const JsonSchemaRecordSchema = z.record(z.unknown());
13
+
14
+ export const RuntimeTaskSchema = z.object({
15
+ id: z.string().min(1),
16
+ name: z.string().min(1),
17
+ description: z.string().nullable(),
18
+ target: z.string().min(1),
19
+ sourcePath: z.string().min(1),
20
+ inputSchema: JsonSchemaRecordSchema.nullable(),
21
+ outputSchema: JsonSchemaRecordSchema.nullable(),
22
+ schedulable: z.boolean(),
23
+ });
24
+
25
+ export const RuntimeTaskListResponseSchema = z.object({
26
+ tasks: z.array(RuntimeTaskSchema),
27
+ });
28
+
29
+ export type ControlPlaneTasksListRequest = z.infer<typeof ControlPlaneTasksListRequestSchema>;
30
+ export type RuntimeTask = z.infer<typeof RuntimeTaskSchema>;
31
+ export type RuntimeTaskListResponse = z.infer<typeof RuntimeTaskListResponseSchema>;
32
+
33
+ export interface RuntimeTaskDiscoveryDeps {
34
+ discoverTasks: (options: TaskDiscoveryOptions) => ReturnType<typeof discoverTasks>;
35
+ }
36
+
37
+ export const defaultRuntimeTaskDiscoveryDeps: RuntimeTaskDiscoveryDeps = {
38
+ discoverTasks,
39
+ };
40
+
41
+ function normalizeJsonSchema(value: unknown): Record<string, unknown> | null {
42
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
43
+ return null;
44
+ }
45
+
46
+ return value as Record<string, unknown>;
47
+ }
48
+
49
+ export async function listRuntimeTasks(
50
+ ctx: HandlerContext,
51
+ deps: RuntimeTaskDiscoveryDeps = defaultRuntimeTaskDiscoveryDeps,
52
+ ): Promise<RuntimeTaskListResponse> {
53
+ const discovery = await deps.discoverTasks({
54
+ projectDir: ctx.projectDir,
55
+ adapter: ctx.adapter,
56
+ config: ctx.config,
57
+ debug: ctx.debug ?? false,
58
+ });
59
+
60
+ const tasks = discovery.tasks
61
+ .map((task) =>
62
+ RuntimeTaskSchema.parse({
63
+ id: task.id,
64
+ name: task.name,
65
+ description: task.definition.description ?? null,
66
+ target: `task:${task.id}`,
67
+ sourcePath: task.filePath,
68
+ inputSchema: normalizeJsonSchema(task.definition.inputSchema),
69
+ outputSchema: normalizeJsonSchema(task.definition.outputSchema),
70
+ schedulable: task.definition.schedulable ?? true,
71
+ })
72
+ )
73
+ .sort((left, right) => left.name.localeCompare(right.name));
74
+
75
+ return RuntimeTaskListResponseSchema.parse({ tasks });
76
+ }
@@ -26,6 +26,12 @@ export interface TaskDefinition {
26
26
  name?: string;
27
27
  /** Task description */
28
28
  description?: string;
29
+ /** Optional JSON-schema-like input contract surfaced in APIs/UIs */
30
+ inputSchema?: Record<string, unknown>;
31
+ /** Optional JSON-schema-like output contract surfaced in APIs/UIs */
32
+ outputSchema?: Record<string, unknown>;
33
+ /** Whether this task can be scheduled via cron jobs */
34
+ schedulable?: boolean;
29
35
  /** The function to execute */
30
36
  run: (ctx: TaskContext) => Promise<unknown> | unknown;
31
37
  }