diff --git a/CHANGELOG.md b/CHANGELOG.md index b33cd2f..12588c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.2.0 + +- Validate manifest-declared Postgres-backed queues, their bounded retry and + concurrency policy, and system-only consumer Functions in local bundles. +- Preserve queue-free schema-2 archive compatibility while including declared + queues in deterministic artifacts and development capability validation. +- Add `jobs list|get` for retained production depth, created/retried/succeeded/ + failed rollups, inclusive creation-time filtering, cursor pagination, and + metadata-only job inspection without payloads or idempotency keys. + ## 3.1.0 - Add `app email list|get` for filtered, cursor-paginated production message diff --git a/README.md b/README.md index dfddb2d..b8cc5ca 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ offline source bundle, but cannot connect to or deploy through OpenCloud. ## Install a pinned release -OpenCloud application skills pin an exact CLI release. To install `v3.1.0` in +OpenCloud application skills pin an exact CLI release. To install `v3.2.0` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v3.1.0" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.1.0.tgz" +OPENCLOUD_CLI_VERSION="v3.2.0" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.2.0.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -181,13 +181,14 @@ Use the stable capability preview and isolated migration-replayed database befor ``` Development data is isolated from production and uses dummy records. Auth, -Files, and Functions are available; Realtime and cron are not. Manifest- +Files, Functions, and background jobs are available; Realtime and cron are not. Manifest- generated secrets receive isolated synthetic development values, while owner- configured required values remain unavailable and optional values may be -absent. Functions -imported from `@opencloud/server` remain dormant until `app dev invoke` or a -deliberate preview interaction calls them. Exact-revision verification requires -every declared Function to have a successful explicit invocation and runs the +absent. Ordinary Functions imported from `@opencloud/server` remain dormant +until `app dev invoke` or a deliberate preview interaction calls them. A +Function enqueue wakes its declared system consumer in the same isolated +namespace. Exact-revision verification requires every declared Function to be +successfully exercised through its intended path and runs the immutable `tests/opencloud.e2e.js` specification. The conventional test source stays outside `frontend.directory`, is included in the deterministic artifact, and must use only the bounded `@opencloud/test` UI fixtures. @@ -216,6 +217,26 @@ captured instead of delivered; `app dev email inject` accepts only reserved `.test` sender and Reply-To addresses, and body/attachment file paths resolve relative to the app directory. +## Background jobs + +Inspect retained production queue depth, per-queue policy and outcomes, or one +job's safe execution metadata: + +```bash +"$OPENCLOUD_CLI" jobs list "$APP_ID" --limit 25 +"$OPENCLOUD_CLI" jobs list "$APP_ID" \ + --queue reminder-delivery --state dead_lettered \ + --from 2026-08-18T09:00:00Z --to 2026-08-18T17:00:00Z +"$OPENCLOUD_CLI" jobs get "$APP_ID" "$JOB_ID" +``` + +`--from` and `--to` are inclusive ISO 8601 creation times and apply to totals, +queue rollups, and history. Pass `nextCursor` back through `--cursor` to +continue history. Successful and failed terminal records are retained for 14 +days; active work remains visible until terminal. These commands never return +job payloads, idempotency keys, or enqueuing user identifiers, and intentionally +provide no cancel or redrive action. + ## Agent Feed and alert rules Read the stable app health, signal, alert, and recent-event contract without diff --git a/package-lock.json b/package-lock.json index 31a3f99..c14a611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencloud/cli", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "3.1.0", + "version": "3.2.0", "dependencies": { "@napi-rs/keyring": "1.3.0" }, diff --git a/package.json b/package.json index e9cd484..3e87d6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "3.1.0", + "version": "3.2.0", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 4c40223..a298d6d 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -1,5 +1,12 @@ import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -38,6 +45,20 @@ ${body.trim()} ); } +async function readArchivedManifest( + root: string, + archive: Buffer, +): Promise> { + const archiveFile = path.join(root, `bundle-${crypto.randomUUID()}.tgz`); + const extracted = path.join(root, `extracted-${crypto.randomUUID()}`); + await writeFile(archiveFile, archive); + await mkdir(extracted); + await tar.extract({ cwd: extracted, file: archiveFile }); + return JSON.parse( + await readFile(path.join(extracted, "opencloud.json"), "utf8"), + ) as Record; +} + describe("bundle builder", () => { it("archives only manifest-reachable files in deterministic order", async () => { const root = await temporaryDirectory(); @@ -134,6 +155,49 @@ functions: }, }); expect(archivedFiles.sort()).toEqual(first.files); + expect(await readArchivedManifest(root, first.archive)).not.toHaveProperty( + "queues", + ); + }); + + it("validates and archives declared background queues", async () => { + const root = await temporaryDirectory(); + await mkdir(path.join(root, "frontend")); + await mkdir(path.join(root, "functions", "process-work"), { + recursive: true, + }); + await writeFile(path.join(root, "frontend", "index.html"), "hello"); + await writeFile( + path.join(root, "functions", "process-work", "index.ts"), + 'import { defineFunction, schema } from "@opencloud/server"; export default defineFunction({ input: schema.object({}), handler: ({ job }) => ({ job }) });', + ); + await writeManifest( + root, + ` +frontend: + directory: frontend +functions: + - name: process-work + entrypoint: functions/process-work/index.ts + access: system +queues: + - name: work + function: process-work + concurrency: 2 +`, + ); + + const bundle = await buildBundle(root); + const archived = await readArchivedManifest(root, bundle.archive); + + expect(bundle.manifest.queues).toEqual([ + expect.objectContaining({ + name: "work", + function: "process-work", + concurrency: 2, + }), + ]); + expect(archived.queues).toEqual(bundle.manifest.queues); }); it("never archives local .opencloud development metadata", async () => { diff --git a/src/index.ts b/src/index.ts index 018bdb9..c29baac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { devEmailInjectionRequest, emailHistoryQuery, } from "./email.js"; +import { backgroundJobPath, backgroundJobsQuery } from "./jobs.js"; import { deleteSession, loadSession, @@ -47,7 +48,7 @@ import { resolveWorkspaceFile, } from "./workspace-store.js"; -const CLI_VERSION = "3.1.0"; +const CLI_VERSION = "3.2.0"; const program = new Command() .name("opencloud") @@ -1044,7 +1045,7 @@ dev next: [ "Open session.previewUrl or use `opencloud app dev request /`.", "After edits run `opencloud app dev sync `.", - "Functions remain dormant until `app dev invoke` or a deliberate preview action calls them.", + "Ordinary Functions remain dormant until `app dev invoke` or a deliberate preview action calls them; enqueuing a declared job wakes its system consumer.", "Add isolated fixtures with `app dev data`, then verify and run `app dev promote`; promotion follows production verification and reports the live URL.", ], }); @@ -1398,12 +1399,12 @@ dev const bundle = await buildBundle(sourceRoot); if (!bundle.e2eTest) { throw new Error( - `${OPEN_CLOUD_E2E_TEST_PATH} is required before promotion. Add the external E2E specification, sync, invoke every Function, and verify the exact revision.`, + `${OPEN_CLOUD_E2E_TEST_PATH} is required before promotion. Add the external E2E specification, sync, exercise every Function through its intended path (including enqueueing queue consumers), and verify the exact revision.`, ); } if (bundle.sha256 !== state.artifactSha256) { throw new Error( - "Local source differs from the active development revision. Run app dev sync, invoke every Function, and verify again before promotion.", + "Local source differs from the active development revision. Run app dev sync, exercise every Function through its intended path, and verify again before promotion.", ); } const control = client(); @@ -1691,6 +1692,7 @@ program files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 }, migrations: [], functions: [], + queues: [], cron: [], health: { path: "/" }, secrets: {}, @@ -1819,6 +1821,7 @@ program artifactBytes: bundle.archive.byteLength, migrations: bundle.manifest.migrations.length, functions: bundle.manifest.functions.length, + queues: bundle.manifest.queues.length, cron: bundle.manifest.cron.filter((item) => item.enabled).length, secrets: bundle.manifest.secrets, files: bundle.files, @@ -1975,6 +1978,39 @@ cron ); }); +const jobs = program + .command("jobs") + .description("Inspect retained production background jobs and queue depth"); + +jobs + .command("list") + .argument("") + .option("--queue ", "filter recent jobs by declared queue") + .option( + "--state ", + "queued, running, retry_wait, succeeded, or dead_lettered", + ) + .option("--from ", "include jobs created at or after this time") + .option("--to ", "include jobs created at or before this time") + .option("--cursor ", "continue a retained history page") + .option("--limit ", "result limit", "50") + .action(async (appId, options) => { + const query = backgroundJobsQuery(options); + output( + await client().get( + `/v1/apps/${encodeURIComponent(appId)}/jobs?${query}`, + ), + ); + }); + +jobs + .command("get") + .argument("") + .argument("") + .action(async (appId, jobId) => { + output(await client().get(backgroundJobPath(appId, jobId))); + }); + const secret = program .command("secret") .description("Manage app-scoped secrets"); diff --git a/src/jobs.test.ts b/src/jobs.test.ts new file mode 100644 index 0000000..2c654c3 --- /dev/null +++ b/src/jobs.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { backgroundJobPath, backgroundJobsQuery } from "./jobs.js"; + +describe("background job CLI inputs", () => { + it("builds a bounded metadata-history query", () => { + expect( + backgroundJobsQuery({ + queue: "task-processing", + state: "retry_wait", + from: "2026-08-18T09:00:00.000Z", + to: "2026-08-18T12:00:00.000Z", + cursor: "page-2", + limit: "25", + }).toString(), + ).toBe( + "limit=25&queue=task-processing&state=retry_wait&from=2026-08-18T09%3A00%3A00.000Z&to=2026-08-18T12%3A00%3A00.000Z&cursor=page-2", + ); + }); + + it("rejects unsupported states, queue names, and limits", () => { + expect(() => backgroundJobsQuery({ state: "cancelled" })).toThrow( + "state must be", + ); + expect(() => backgroundJobsQuery({ queue: "Task Queue" })).toThrow( + "lowercase kebab-case", + ); + expect(() => backgroundJobsQuery({ limit: 201 })).toThrow( + "1 through 200", + ); + }); + + it("requires offset timestamps and an ordered creation-time range", () => { + expect(() => + backgroundJobsQuery({ from: "2026-08-18T09:00" }), + ).toThrow("with an offset"); + expect(() => + backgroundJobsQuery({ + from: "2026-08-18T12:00:00.000Z", + to: "2026-08-18T09:00:00.000Z", + }), + ).toThrow("after from"); + }); + + it("encodes app and job identifiers as path segments", () => { + expect(backgroundJobPath("app/id", "job id")).toBe( + "/v1/apps/app%2Fid/jobs/job%20id", + ); + }); +}); diff --git a/src/jobs.ts b/src/jobs.ts new file mode 100644 index 0000000..f87cc5b --- /dev/null +++ b/src/jobs.ts @@ -0,0 +1,66 @@ +const queueName = /^[a-z][a-z0-9-]{0,62}$/; +const backgroundJobStates = new Set([ + "queued", + "running", + "retry_wait", + "succeeded", + "dead_lettered", +]); + +export interface BackgroundJobsQueryOptions { + queue?: string; + state?: string; + from?: string; + to?: string; + cursor?: string; + limit?: string | number; +} + +function timestamp(value: string | undefined, label: string): number | null { + if (!value) return null; + const parsed = Date.parse(value); + if ( + !Number.isFinite(parsed) || + !/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) + ) { + throw new Error(`${label} must be an ISO 8601 timestamp with an offset`); + } + return parsed; +} + +export function backgroundJobsQuery( + options: BackgroundJobsQueryOptions, +): URLSearchParams { + const limit = Number(options.limit ?? 50); + if (!Number.isInteger(limit) || limit < 1 || limit > 200) { + throw new Error("Background job limit must be an integer from 1 through 200"); + } + if (options.queue && !queueName.test(options.queue)) { + throw new Error("Queue must use lowercase kebab-case and at most 63 characters"); + } + if (options.state && !backgroundJobStates.has(options.state)) { + throw new Error( + "Background job state must be queued, running, retry_wait, succeeded, or dead_lettered", + ); + } + if (options.cursor && options.cursor.length > 512) { + throw new Error("Background job cursor must not exceed 512 characters"); + } + const from = timestamp(options.from, "Background job from"); + const to = timestamp(options.to, "Background job to"); + if (from !== null && to !== null && from > to) { + throw new Error("Background job to must be after from"); + } + return new URLSearchParams({ + limit: String(limit), + ...(options.queue ? { queue: options.queue } : {}), + ...(options.state ? { state: options.state } : {}), + ...(options.from ? { from: options.from } : {}), + ...(options.to ? { to: options.to } : {}), + ...(options.cursor ? { cursor: options.cursor } : {}), + }); +} + +export function backgroundJobPath(appId: string, jobId: string): string { + return `/v1/apps/${encodeURIComponent(appId)}/jobs/${encodeURIComponent(jobId)}`; +} diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index 308f062..cb54269 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -42,6 +42,7 @@ interface AuthorManifest { sha256?: string; }>; functions?: unknown[]; + queues?: unknown[]; cron?: unknown[]; email?: unknown; health?: unknown; @@ -182,9 +183,13 @@ export async function buildBundle( await copyFile(sourceFile, destination); await chmod(destination, 0o644); } + const archiveManifest: Partial = { ...manifest }; + // Queue-free schema-2 apps keep the archive shape accepted by older + // platform releases while declared queues remain canonical bundle input. + if (manifest.queues.length === 0) delete archiveManifest.queues; await writeFile( path.join(staging, "opencloud.json"), - `${JSON.stringify(manifest, null, 2)}\n`, + `${JSON.stringify(archiveManifest, null, 2)}\n`, { flag: "wx", mode: 0o644 }, ); @@ -450,7 +455,7 @@ async function inspectFunctionEntrypoints( /["'`]\/rest\/v1\//.test(content) ) { throw new Error( - `Function entrypoint ${definition.entrypoint} uses unsupported direct platform backend access. Use defineFunction from @opencloud/server and its data, files, secrets, log, requestId, and environment context instead of guessed SUPABASE_* or backend URL environment variables and direct /rest/v1 fetches.`, + `Function entrypoint ${definition.entrypoint} uses unsupported direct platform backend access. Use defineFunction from @opencloud/server and its data, files, ai, email, jobs, job, integrations, secrets, log, requestId, and environment context instead of guessed SUPABASE_* or backend URL environment variables and direct /rest/v1 fetches.`, ); } const usesServerBoundary = diff --git a/vendor/contracts/src/api.ts b/vendor/contracts/src/api.ts index 815cead..5a5875f 100644 --- a/vendor/contracts/src/api.ts +++ b/vendor/contracts/src/api.ts @@ -233,6 +233,63 @@ export interface CronInvocationRecord { error: Record | null; } +export type BackgroundJobState = + | "queued" + | "running" + | "retry_wait" + | "succeeded" + | "dead_lettered"; + +export interface BackgroundJobRecord { + id: string; + appId: string; + deploymentId: string; + queue: string; + consumerFunction: string; + producerFunction: string; + state: BackgroundJobState; + attempt: number; + maxAttempts: number; + runAt: string; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + updatedAt: string; + lastError: Record | null; +} + +export interface BackgroundJobStats { + created: number; + retried: number; + succeeded: number; + failed: number; + active: number; + queued: number; + running: number; + retryWaiting: number; +} + +export interface BackgroundJobQueueStats extends BackgroundJobStats { + name: string; + declared: boolean; + functionName: string | null; + concurrency: number | null; + maxAttempts: number | null; + retryDelaySeconds: number | null; + retryBackoff: boolean | null; + timeoutSeconds: number | null; + oldestPendingAt: string | null; +} + +export interface BackgroundJobsPage { + asOf: string; + retentionDays: number; + stats: BackgroundJobStats; + queues: BackgroundJobQueueStats[]; + jobs: BackgroundJobRecord[]; + nextCursor: string | null; +} + export interface DeploymentRecord { id: string; appId: string; @@ -356,7 +413,7 @@ export interface AgentFeedBreach { export interface AgentFeedEvent { id: string; - type: "operation" | "cron"; + type: "operation" | "cron" | "job"; state: string; occurredAt: string; message: string; diff --git a/vendor/contracts/src/control-plane.test.ts b/vendor/contracts/src/control-plane.test.ts index d6ef5e9..bce03ba 100644 --- a/vendor/contracts/src/control-plane.test.ts +++ b/vendor/contracts/src/control-plane.test.ts @@ -30,6 +30,8 @@ describe("controlPlaneOperations", () => { "get_app_email_message", "generate_secret", "create_secret_entry_link", + "list_background_jobs", + "get_background_job", "get_agent_feed", "put_alert_rule", ]), @@ -210,6 +212,51 @@ describe("controlPlaneOperations", () => { ).toMatchObject({ id: messageId, content: { text: "Need help" } }); }); + it("types metadata-only background job observability", () => { + const appId = "22222222-2222-4222-8222-222222222222"; + const operation = controlPlaneOperations.listBackgroundJobs; + + expect(operation.mcp).toMatchObject({ + toolName: "list_background_jobs", + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }); + expect( + operation.input.parse({ + appId, + query: { + queue: "task-processing", + state: "retry_wait", + from: "2026-08-17T10:00:00.000Z", + to: "2026-08-17T12:00:00.000Z", + limit: 25, + }, + }), + ).toMatchObject({ + query: { + queue: "task-processing", + state: "retry_wait", + from: "2026-08-17T10:00:00.000Z", + to: "2026-08-17T12:00:00.000Z", + limit: 25, + }, + }); + expect(() => + operation.input.parse({ appId, query: { state: "cancelled" } }), + ).toThrow(); + expect(() => + operation.input.parse({ + appId, + query: { + from: "2026-08-17T12:00:00.000Z", + to: "2026-08-17T10:00:00.000Z", + }, + }), + ).toThrow(/after from/); + expect(operation.description).toContain("Payloads"); + }); + it("keeps MCP approval hints aligned with high-risk behavior", () => { const tools = new Map( Object.values(controlPlaneOperations).flatMap((operation) => @@ -373,6 +420,7 @@ describe("controlPlaneOperations", () => { frontend: true, database: true, functions: true, + jobs: true, files: true, productionSecrets: false, cron: false, diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index 033bee1..d9f4a57 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -517,6 +517,7 @@ export const devSessionOutput = z frontend: z.literal(true), database: z.literal(true), functions: z.literal(true), + jobs: z.literal(true), files: z.literal(true), productionSecrets: z.literal(false), cron: z.literal(false), @@ -706,7 +707,7 @@ const agentFeedBreachOutput = z const agentFeedEventOutput = z .object({ id: z.string(), - type: z.enum(["operation", "cron"]), + type: z.enum(["operation", "cron", "job"]), state: z.string(), occurredAt: z.string(), message: z.string(), @@ -767,6 +768,84 @@ const cronInvocationOutput = z }) .passthrough(); +export const backgroundJobStateSchema = z.enum([ + "queued", + "running", + "retry_wait", + "succeeded", + "dead_lettered", +]); + +export const backgroundJobsQuerySchema = z + .object({ + queue: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/).optional(), + state: backgroundJobStateSchema.optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + cursor: z.string().max(512).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + }) + .superRefine((value, context) => { + if (!value.from || !value.to) return; + if (Date.parse(value.from) > Date.parse(value.to)) { + context.addIssue({ + code: "custom", + path: ["to"], + message: "background jobs to must be after from", + }); + } + }); + +export const backgroundJobStatsSchema = z.object({ + created: z.number().int().nonnegative(), + retried: z.number().int().nonnegative(), + succeeded: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + active: z.number().int().nonnegative(), + queued: z.number().int().nonnegative(), + running: z.number().int().nonnegative(), + retryWaiting: z.number().int().nonnegative(), +}); + +export const backgroundJobOutput = z.object({ + id: uuid, + appId: uuid, + deploymentId: uuid, + queue: z.string(), + consumerFunction: z.string(), + producerFunction: z.string(), + state: backgroundJobStateSchema, + attempt: z.number().int().nonnegative(), + maxAttempts: z.number().int().positive(), + runAt: z.string(), + createdAt: z.string(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), + updatedAt: z.string(), + lastError: jsonObject.nullable(), +}); + +export const backgroundJobsPageOutput = z.object({ + asOf: z.string(), + retentionDays: z.number().int().positive(), + stats: backgroundJobStatsSchema, + queues: z.array( + backgroundJobStatsSchema.extend({ + name: z.string(), + declared: z.boolean(), + functionName: z.string().nullable(), + concurrency: z.number().int().positive().nullable(), + maxAttempts: z.number().int().positive().nullable(), + retryDelaySeconds: z.number().int().positive().nullable(), + retryBackoff: z.boolean().nullable(), + timeoutSeconds: z.number().int().positive().nullable(), + oldestPendingAt: z.string().nullable(), + }), + ), + jobs: z.array(backgroundJobOutput), + nextCursor: z.string().nullable(), +}); + export type ControlPlaneAuth = "none" | "bearer" | "user"; export interface McpOperationMetadata { @@ -1995,6 +2074,53 @@ export const controlPlaneOperations = { openWorldHint: false, }, }), + listBackgroundJobs: operation({ + method: "GET", + path: "/v1/apps/{appId}/jobs", + summary: "List background jobs", + description: + "Returns retained production background-job totals, per-queue rollups, current depth, and a cursor-paginated metadata-only history page. Inclusive from/to creation times apply to totals, queue rollups, and history; queue and state filters narrow history. Successful and failed terminal records are retained for 14 days; active records remain until terminal. Payloads and idempotency keys are never returned.", + auth: "bearer", + scopes: ["app:observe"], + input: appPath.extend({ + query: backgroundJobsQuerySchema.optional(), + }), + output: backgroundJobsPageOutput, + queryKey: "query", + idempotency: "none", + mcp: { + toolName: "list_background_jobs", + title: "List background jobs", + description: + "Inspect retained production queue depth, created/retried/succeeded/failed totals, per-queue rollups, and cursor-paginated safe job metadata, optionally bounded by creation time, without reading payloads.", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }), + getBackgroundJob: operation({ + method: "GET", + path: "/v1/apps/{appId}/jobs/{jobId}", + summary: "Get a background job", + description: + "Returns safe production background-job execution metadata without its payload, idempotency key, or enqueuing user.", + auth: "bearer", + scopes: ["app:observe"], + input: appPath.extend({ jobId: uuid }), + output: backgroundJobOutput, + idempotency: "none", + mcp: { + toolName: "get_background_job", + title: "Get background job", + description: + "Inspect one retained production background job and its sanitized last error without reading its payload.", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }), invokeCron: operation({ method: "POST", path: "/v1/apps/{appId}/cron/{name}/invoke", diff --git a/vendor/contracts/src/manifest.test.ts b/vendor/contracts/src/manifest.test.ts index e00533d..879acea 100644 --- a/vendor/contracts/src/manifest.test.ts +++ b/vendor/contracts/src/manifest.test.ts @@ -16,6 +16,7 @@ const valid = { ], functions: [], cron: [], + queues: [], email: { addresses: [] }, health: { path: "/" }, secrets: {}, @@ -249,6 +250,90 @@ describe("OpenCloud manifest", () => { ).toBe("system"); }); + it("declares bounded queues with system Function consumers", () => { + const manifest = parseManifest({ + ...valid, + functions: [ + { + name: "process-document", + entrypoint: "functions/process-document/index.ts", + access: "system", + }, + ], + queues: [ + { + name: "document-processing", + function: "process-document", + concurrency: 4, + maxAttempts: 5, + retryDelaySeconds: 10, + timeoutSeconds: 120, + }, + ], + }); + + expect(manifest.queues).toEqual([ + { + name: "document-processing", + function: "process-document", + concurrency: 4, + maxAttempts: 5, + retryDelaySeconds: 10, + retryBackoff: true, + timeoutSeconds: 120, + }, + ]); + }); + + it("rejects duplicate queues, unknown consumers, and non-system consumers", () => { + expect(() => + parseManifest({ + ...valid, + queues: [ + { name: "work", function: "missing" }, + { name: "work", function: "missing" }, + ], + }), + ).toThrow(/queues entries must be unique|unknown function/); + + expect(() => + parseManifest({ + ...valid, + functions: [ + { + name: "process-work", + entrypoint: "functions/process-work/index.ts", + access: "user", + }, + ], + queues: [{ name: "work", function: "process-work" }], + }), + ).toThrow(/must declare access: system/); + }); + + it("bounds queue concurrency, attempts, retry delay, and timeout", () => { + for (const queue of [ + { name: "work", function: "process-work", concurrency: 21 }, + { name: "work", function: "process-work", maxAttempts: 0 }, + { name: "work", function: "process-work", retryDelaySeconds: 3_601 }, + { name: "work", function: "process-work", timeoutSeconds: 901 }, + ]) { + expect(() => + parseManifest({ + ...valid, + functions: [ + { + name: "process-work", + entrypoint: "functions/process-work/index.ts", + access: "system", + }, + ], + queues: [queue], + }), + ).toThrow(); + } + }); + it("declares generated, required, and optional secrets without values", () => { expect( parseManifest({ diff --git a/vendor/contracts/src/manifest.ts b/vendor/contracts/src/manifest.ts index 6e869e3..4524997 100644 --- a/vendor/contracts/src/manifest.ts +++ b/vendor/contracts/src/manifest.ts @@ -46,6 +46,18 @@ export const cronSchema = z }) .strict(); +export const queueSchema = z + .object({ + name: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + function: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + concurrency: z.number().int().min(1).max(20).default(1), + maxAttempts: z.number().int().min(1).max(10).default(3), + retryDelaySeconds: z.number().int().min(1).max(3_600).default(5), + retryBackoff: z.boolean().default(true), + timeoutSeconds: z.number().int().min(1).max(15 * 60).default(15 * 60), + }) + .strict(); + export const filesAccessSchema = z.enum(["user", "app"]); export const secretModeSchema = z.enum(["generated", "required", "optional"]); @@ -160,6 +172,7 @@ export const openCloudManifestSchema = z migrations: z.array(migrationSchema).max(500).default([]), functions: z.array(functionSchema).max(100).default([]), cron: z.array(cronSchema).max(100).default([]), + queues: z.array(queueSchema).max(50).default([]), email: z .object({ addresses: z.array(emailAddressSchema).max(25).default([]), @@ -191,6 +204,7 @@ export const openCloudManifestSchema = z | "migrations" | "functions" | "cron" + | "queues" | "email" | "observability", ) => { @@ -215,6 +229,7 @@ export const openCloudManifestSchema = z "functions", ); assertUnique(manifest.cron.map((cron) => cron.name), "cron"); + assertUnique(manifest.queues.map((queue) => queue.name), "queues"); assertUnique( manifest.email.addresses.map((address) => address.name), "email", @@ -267,6 +282,24 @@ export const openCloudManifestSchema = z }); } }); + manifest.queues.forEach((queue, index) => { + const target = manifest.functions.find( + (definition) => definition.name === queue.function, + ); + if (!target) { + context.addIssue({ + code: "custom", + path: ["queues", index, "function"], + message: `queue references unknown function: ${queue.function}`, + }); + } else if (target.access !== "system") { + context.addIssue({ + code: "custom", + path: ["queues", index, "function"], + message: `queue function ${queue.function} must declare access: system`, + }); + } + }); manifest.email.addresses.forEach((address, index) => { if (!address.function) return; const target = manifest.functions.find( @@ -296,6 +329,7 @@ export type FunctionAccess = z.infer; export type SecretMode = z.infer; export type SdkVersion = z.infer; export type OpenCloudEmailAddress = z.infer; +export type OpenCloudQueue = z.infer; export type CustomMetricDefinition = z.infer< typeof customMetricDefinitionSchema >;