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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand Up@@ -1691,6 +1692,7 @@ program
files: { access: "user", maxUploadBytes: 50 * 1024 * 1024 },
migrations: [],
functions: [],
queues: [],
cron: [],
health: { path: "/" },
secrets: {},
Expand DownExpand 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 DownExpand 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 numberDiff line numberDiff 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