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
5 changes: 5 additions & 0 deletions docs/guides/manage-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ A `file` with a local `source` is uploaded during `apply` (Files API) and the re

```bash
agents deployment list # deployments tracked in state
agents deployment list --remote --provider qoder --all
agents deployment get <name> # status + resolved bindings
agents deployment pause <name> # stop scheduled runs (native providers)
agents deployment unpause <name> # resume scheduled runs (native providers)
agents deployment run <name> # trigger a run
```

Qoder deployments may also declare `environment_variables` as a semicolon- or newline-separated `KEY=VALUE` string. Qoder copies these variables into every Session created by that deployment; Claude does not support this extension.

## Native vs. emulated

| Provider | Deployment tier | What `deployment run` does |
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ Manage scheduled / triggered deployments.
| Subcommand | Description |
|------------|-------------|
| `deployment list` | List deployments tracked in state. |
| `deployment list --remote --provider <provider>` | List deployments from a native provider API; supports status, agent, archive, limit, and pagination filters. |
| `deployment get <name>` | Show a deployment's status and resolved bindings. |
| `deployment pause <name>` | Pause scheduled runs for a native deployment. |
| `deployment unpause <name>` | Resume a paused native deployment. |
| `deployment run <name>` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). |

## `agents memory-store`
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/commands/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,66 @@ import {
getDeploymentDetailsForContext,
getDeploymentRuntimeProviderForContext,
listDeploymentsForContext,
listRemoteDeploymentsForContext,
pauseDeploymentForContext,
runDeploymentForContext,
UserError,
} from "@openagentpack/sdk";
import chalk from "chalk";
import { buildCliRuntime } from "../config-loader.ts";
import { log } from "../logger.ts";
import { columnWidth, printTableFooter, printTableHeader, printTableRow, printTableTitle } from "../render-table.ts";
import { fetchAllPages } from "../utils/pagination.ts";

interface DeploymentListOpts {
file: string;
provider?: string;
remote?: boolean;
status?: "active" | "paused";
includeArchived?: boolean;
agentId?: string;
limit?: number;
all?: boolean;
}

export async function deploymentListCommand(options: DeploymentListOpts) {
const ctx = await buildCliRuntime(options.file);
if (options.remote) {
if (!options.provider) throw new UserError("Remote deployment listing requires --provider.");
if (options.provider === "claude" && options.status && options.includeArchived) {
throw new UserError("Claude remote deployment listing cannot combine --status with --include-archived.");
}
const { items, hasMore } = await fetchAllPages(async (page) => {
const result = await listRemoteDeploymentsForContext(ctx, options.provider!, {
status: options.status,
include_archived: options.includeArchived,
agent_id: options.agentId,
limit: options.limit,
page,
});
return { items: result.deployments, hasMore: result.has_more, nextPage: result.next_page };
}, options.all);
if (items.length === 0) {
log.info("No remote deployments found.");
return;
}
printTableTitle("Remote Deployments", items.length);
printTableHeader(["Name".padEnd(24), "ID".padEnd(28), "Status".padEnd(10), "Schedule"], 82);
for (const item of items) {
const raw = item.attributes ?? {};
const name = String(raw.name ?? "")
.slice(0, 22)
.padEnd(24);
const id = String(item.id ?? "")
.slice(0, 26)
.padEnd(28);
const schedule = item.schedule?.expression ?? "manual";
printTableRow([chalk.bold(name), id, item.status.padEnd(10), schedule]);
}
printTableFooter();
if (hasMore) log.info("More deployments available. Use --all to fetch all.");
return;
}
const rows = listDeploymentsForContext(ctx, options.provider);

if (rows.length === 0) {
Expand All @@ -42,6 +87,18 @@ export async function deploymentListCommand(options: DeploymentListOpts) {
printTableFooter();
}

interface DeploymentPauseOpts {
file: string;
provider?: string;
}

export async function deploymentPauseCommand(name: string, options: DeploymentPauseOpts, paused = true) {
const ctx = await buildCliRuntime(options.file);
const info = await pauseDeploymentForContext(ctx, name, paused, options.provider);
log.success(`Deployment '${name}' ${paused ? "paused" : "unpaused"}.`);
console.log(` Status: ${info.status}`);
}

interface DeploymentGetOpts {
file: string;
provider?: string;
Expand Down
29 changes: 27 additions & 2 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Command, Option } from "commander";
import { applyCommand } from "./commands/apply.ts";
import { deploymentGetCommand, deploymentListCommand, deploymentRunCommand } from "./commands/deployment.ts";
import {
deploymentGetCommand,
deploymentListCommand,
deploymentPauseCommand,
deploymentRunCommand,
} from "./commands/deployment.ts";
import { destroyCommand } from "./commands/destroy.ts";
import { initCommand } from "./commands/init.ts";
import {
Expand Down Expand Up @@ -292,6 +297,12 @@ deploymentCmd
.description("List deployments tracked in state")
.addOption(configFileOption())
.addOption(providerOption("Filter by provider"))
.option("--remote", "List deployments from the provider API")
.addOption(new Option("--status <status>", "Filter remote deployments by status").choices(["active", "paused"]))
.option("--include-archived", "Include archived remote deployments")
.option("--agent-id <id>", "Filter remote deployments by agent ID")
.option("--limit <count>", "Maximum remote deployments per page", parsePositiveInteger)
.option("--all", "Fetch all remote pages")
.action(withResolvedConfigFile(deploymentListCommand));

deploymentCmd
Expand All @@ -301,9 +312,23 @@ deploymentCmd
.addOption(providerOption("Target provider"))
.action(withResolvedConfigFile(deploymentGetCommand));

deploymentCmd
.command("pause <name>")
.description("Pause a native deployment's scheduled runs")
.addOption(configFileOption())
.addOption(providerOption("Target provider"))
.action(withResolvedConfigFile((name, options) => deploymentPauseCommand(name, options, true)));

deploymentCmd
.command("unpause <name>")
.description("Resume a paused native deployment")
.addOption(configFileOption())
.addOption(providerOption("Target provider"))
.action(withResolvedConfigFile((name, options) => deploymentPauseCommand(name, options, false)));

deploymentCmd
.command("run <name>")
.description("Trigger a deployment run (native on Claude, emulated as a session on Qoder)")
.description("Trigger a deployment run (native on Qoder/Claude, emulated on Bailian/Ark)")
.addOption(configFileOption())
.addOption(providerOption("Target provider"))
.action(withResolvedConfigFile(deploymentRunCommand));
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/tests/unit/cli-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,49 @@ test("deployment list renders deployment rows through the core runtime", async (
expect(result.stdout).not.toMatch(/[\u4e00-\u9fff]/);
});

test("deployment help exposes remote list and lifecycle commands", async () => {
const help = await runAgents(["deployment", "--help"]);
expect(help.exitCode).toBe(0);
expect(help.stdout).toContain("pause");
expect(help.stdout).toContain("unpause");

const listHelp = await runAgents(["deployment", "list", "--help"]);
expect(listHelp.exitCode).toBe(0);
expect(listHelp.stdout).toContain("--remote");
expect(listHelp.stdout).toContain("--include-archived");
});

test("deployment remote list validates provider filter combinations locally", async () => {
const invalidStatus = await runAgents([
"deployment",
"list",
"--remote",
"--provider",
"claude",
"--status",
"running",
]);
expect(invalidStatus.exitCode).not.toBe(0);
expect(invalidStatus.stderr).toContain("Allowed choices are active, paused");

const dir = await makeTempDir();
const configPath = await writeDeploymentConfig(dir);
const incompatible = await runAgents([
"deployment",
"list",
"--file",
configPath,
"--remote",
"--provider",
"claude",
"--status",
"active",
"--include-archived",
]);
expect(incompatible.exitCode).not.toBe(0);
expect(incompatible.stderr).toContain("cannot combine --status with --include-archived");
});

test("destroy with empty state is handled through the core runtime", async () => {
const dir = await makeTempDir();
const configPath = await writeConfig(dir);
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ export {
getDeploymentDetailsForContext,
getDeploymentRuntimeProviderForContext,
listDeploymentsForContext,
listRemoteDeploymentsForContext,
pauseDeploymentForContext,
runDeploymentForContext,
} from "./internal/core/deployment-runtime.ts";

export type { DeploymentListFilter, DeploymentListResult } from "./internal/providers/interface.ts";

export type { DestroyResourceResult } from "./internal/core/destroy-runtime.ts";
export {
destroyPlannedProjectResources,
Expand Down
32 changes: 31 additions & 1 deletion packages/sdk/src/internal/core/deployment-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { UserError } from "../errors.ts";
import { resolveDeploymentRefs } from "../executor/resolver.ts";
import type { DeploymentContext } from "../providers/interface.ts";
import type {
DeploymentContext,
DeploymentListFilter,
DeploymentListResult,
DeploymentInfo as ProviderDeploymentInfo,
} from "../providers/interface.ts";
import type { DeploymentRunAdapter } from "../providers/resource-workflow.ts";
import type { ProjectConfig } from "../types/config.ts";
import type { ResourceState } from "../types/state.ts";
Expand Down Expand Up @@ -51,6 +56,17 @@ export interface DeploymentRun {
result: DeploymentRunResult;
}

export async function listRemoteDeploymentsForContext(
ctx: ProjectRuntimeContext,
provider: string,
filter?: DeploymentListFilter,
): Promise<DeploymentListResult> {
const adapter = getRuntimeProvider(ctx, provider);
if (!adapter.listDeployments)
throw new UserError(`Provider '${provider}' does not support remote deployment listing.`);
return adapter.listDeployments(filter);
}

export function listDeploymentsForContext(ctx: ProjectRuntimeContext, providerFilter?: string): DeploymentSummary[] {
let rows = ctx.state.listResources().filter((resource) => resource.address.type === "deployment");
if (providerFilter) {
Expand Down Expand Up @@ -109,6 +125,20 @@ export async function runDeploymentForContext(
};
}

export async function pauseDeploymentForContext(
ctx: ProjectRuntimeContext,
name: string,
paused: boolean,
resolvedProvider?: string,
): Promise<ProviderDeploymentInfo> {
const provider = resolvedProvider ?? resolveDeploymentProvider(name, ctx.config);
const adapter = getRuntimeProvider(ctx, provider);
const operation = paused ? adapter.pauseDeployment : adapter.unpauseDeployment;
if (!operation)
throw new UserError(`Provider '${provider}' does not support ${paused ? "pausing" : "unpausing"} deployments.`);
return operation.call(adapter, buildDeploymentContext(ctx, name, provider));
}

export function getDeploymentRuntimeProviderForContext(
ctx: ProjectRuntimeContext,
name: string,
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ export function collectProviderCapabilities(
}
}
for (const [name, deployment] of Object.entries(config.deployments ?? {})) {
if (deployment.provider && deployment.provider !== providerName) continue;
if (deployment.environment_variables !== undefined) {
diagnostics.error(
`${providerName}.deployment.environment_variables.unsupported`,
`deployment.${name}: environment_variables is supported only by Qoder deployments; ` +
`remove it or pin this deployment to the qoder provider.`,
{ type: "deployment", name, provider: providerName },
);
}
if (deployment.tunnel && (!deployment.provider || deployment.provider === providerName)) {
diagnostics.error(
`${providerName}.deployment.tunnel.unsupported`,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/internal/parser/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ const deploymentSchema = z.object({
description: z.string().optional(),
provider: z.string().optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.string().optional(),
});

export const projectConfigSchema = z.object({
Expand Down
Loading