Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
66 changes: 65 additions & 1 deletion src/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -38,6 +45,20 @@ ${body.trim()}
);
}

async function readArchivedManifest(
root: string,
archive: Buffer,
): Promise<Record<string, unknown>> {
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<string, unknown>;
}

describe("bundle builder", () => {
it("archives only manifest-reachable files in deterministic order", async () => {
const root = await temporaryDirectory();
Expand Down Expand Up @@ -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 () => {
Expand Down
44 changes: 40 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
devEmailInjectionRequest,
emailHistoryQuery,
} from "./email.js";
import { backgroundJobPath, backgroundJobsQuery } from "./jobs.js";
import {
deleteSession,
loadSession,
Expand All @@ -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")
Expand Down Expand Up @@ -1044,7 +1045,7 @@ dev
next: [
"Open session.previewUrl or use `opencloud app dev request <directory> /`.",
"After edits run `opencloud app dev sync <directory>`.",
"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.",
],
});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1975,6 +1978,39 @@ cron
);
});

const jobs = program
.command("jobs")
.description("Inspect retained production background jobs and queue depth");

jobs
.command("list")
.argument("<app-id>")
.option("--queue <name>", "filter recent jobs by declared queue")
.option(
"--state <state>",
"queued, running, retry_wait, succeeded, or dead_lettered",
)
.option("--from <iso>", "include jobs created at or after this time")
.option("--to <iso>", "include jobs created at or before this time")
.option("--cursor <cursor>", "continue a retained history page")
.option("--limit <number>", "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("<app-id>")
.argument("<job-id>")
.action(async (appId, jobId) => {
output(await client().get(backgroundJobPath(appId, jobId)));
});

const secret = program
.command("secret")
.description("Manage app-scoped secrets");
Expand Down
49 changes: 49 additions & 0 deletions src/jobs.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Loading
Loading