diff --git a/docs/guides/manage-deployments.md b/docs/guides/manage-deployments.md index c67f9c6..3b5486d 100644 --- a/docs/guides/manage-deployments.md +++ b/docs/guides/manage-deployments.md @@ -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 # status + resolved bindings +agents deployment pause # stop scheduled runs (native providers) +agents deployment unpause # resume scheduled runs (native providers) agents deployment run # 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 | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1c568a6..cfee071 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -120,7 +120,10 @@ Manage scheduled / triggered deployments. | Subcommand | Description | |------------|-------------| | `deployment list` | List deployments tracked in state. | +| `deployment list --remote --provider ` | List deployments from a native provider API; supports status, agent, archive, limit, and pagination filters. | | `deployment get ` | Show a deployment's status and resolved bindings. | +| `deployment pause ` | Pause scheduled runs for a native deployment. | +| `deployment unpause ` | Resume a paused native deployment. | | `deployment run ` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). | ## `agents memory-store` diff --git a/packages/cli/src/commands/deployment.ts b/packages/cli/src/commands/deployment.ts index 3412fdf..f170a67 100644 --- a/packages/cli/src/commands/deployment.ts +++ b/packages/cli/src/commands/deployment.ts @@ -2,6 +2,8 @@ import { getDeploymentDetailsForContext, getDeploymentRuntimeProviderForContext, listDeploymentsForContext, + listRemoteDeploymentsForContext, + pauseDeploymentForContext, runDeploymentForContext, UserError, } from "@openagentpack/sdk"; @@ -9,14 +11,57 @@ 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) { @@ -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; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index fbeb3a7..c42abe9 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -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 { @@ -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 ", "Filter remote deployments by status").choices(["active", "paused"])) + .option("--include-archived", "Include archived remote deployments") + .option("--agent-id ", "Filter remote deployments by agent ID") + .option("--limit ", "Maximum remote deployments per page", parsePositiveInteger) + .option("--all", "Fetch all remote pages") .action(withResolvedConfigFile(deploymentListCommand)); deploymentCmd @@ -301,9 +312,23 @@ deploymentCmd .addOption(providerOption("Target provider")) .action(withResolvedConfigFile(deploymentGetCommand)); +deploymentCmd + .command("pause ") + .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 ") + .description("Resume a paused native deployment") + .addOption(configFileOption()) + .addOption(providerOption("Target provider")) + .action(withResolvedConfigFile((name, options) => deploymentPauseCommand(name, options, false))); + deploymentCmd .command("run ") - .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)); diff --git a/packages/cli/tests/unit/cli-contracts.test.ts b/packages/cli/tests/unit/cli-contracts.test.ts index 94cd495..668f44d 100644 --- a/packages/cli/tests/unit/cli-contracts.test.ts +++ b/packages/cli/tests/unit/cli-contracts.test.ts @@ -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); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9755dcc..5388797 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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, diff --git a/packages/sdk/src/internal/core/deployment-runtime.ts b/packages/sdk/src/internal/core/deployment-runtime.ts index a5dbccd..92a2969 100644 --- a/packages/sdk/src/internal/core/deployment-runtime.ts +++ b/packages/sdk/src/internal/core/deployment-runtime.ts @@ -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"; @@ -51,6 +56,17 @@ export interface DeploymentRun { result: DeploymentRunResult; } +export async function listRemoteDeploymentsForContext( + ctx: ProjectRuntimeContext, + provider: string, + filter?: DeploymentListFilter, +): Promise { + 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) { @@ -109,6 +125,20 @@ export async function runDeploymentForContext( }; } +export async function pauseDeploymentForContext( + ctx: ProjectRuntimeContext, + name: string, + paused: boolean, + resolvedProvider?: string, +): Promise { + 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, diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index ad10dcc..2ff289f 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -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`, diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 190dc7f..0eabae0 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -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({ diff --git a/packages/sdk/src/internal/providers/claude/adapter.ts b/packages/sdk/src/internal/providers/claude/adapter.ts index 710c859..9840195 100644 --- a/packages/sdk/src/internal/providers/claude/adapter.ts +++ b/packages/sdk/src/internal/providers/claude/adapter.ts @@ -35,6 +35,8 @@ import { toRemoteResource } from "../base-client.ts"; import type { DeploymentContext, DeploymentInfo, + DeploymentListFilter, + DeploymentListResult, DeploymentRunResult, ExportedResource, ModelInfo, @@ -64,6 +66,7 @@ import { fileToDecl, mapAgent, mapDeployment, + mapDeploymentUpdate, mapEnvironment, mapSendMessage, mapSession, @@ -377,7 +380,20 @@ export class ClaudeAdapter implements ProviderAdapter { basePath: string, ): Promise { const uploaded = await this.uploadDeploymentFiles(decl, basePath); - const body = mapDeployment(name, decl, refs, this.projectName, uploaded); + const current = (await this.client.get(`/deployments/${id}`)) as Record; + if (current.schedule && !decl.schedule) { + throw new UserError( + `Deployment '${name}' cannot remove its schedule through the documented Claude update API; archive and recreate it as a manual deployment.`, + ); + } + const body = mapDeploymentUpdate( + name, + decl, + refs, + this.projectName, + uploaded, + current.metadata as Record | undefined, + ); const res = (await this.client.post(`/deployments/${id}`, body)) as Record; return toRemoteResource(res); } @@ -437,6 +453,40 @@ export class ClaudeAdapter implements ProviderAdapter { }; } + async listDeployments(filter?: DeploymentListFilter): Promise { + const params = new URLSearchParams(); + if (filter?.agent_id) params.set("agent_id", filter.agent_id); + if (filter?.status) params.set("status", filter.status); + if (filter?.include_archived) params.set("include_archived", "true"); + if (filter?.limit) params.set("limit", String(filter.limit)); + if (filter?.page) params.set("page", filter.page); + if (filter?.created_at_gte) params.set("created_at[gte]", filter.created_at_gte); + if (filter?.created_at_lte) params.set("created_at[lte]", filter.created_at_lte); + const query = params.toString(); + const res = (await this.client.get(`/deployments${query ? `?${query}` : ""}`)) as Record; + const nextPage = (res.next_page as string | null | undefined) ?? undefined; + return { + deployments: ((res.data as Record[] | undefined) ?? []).map(toDeploymentInfo), + has_more: nextPage !== undefined, + next_page: nextPage, + }; + } + + async pauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, true); + } + + async unpauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, false); + } + + private async setDeploymentPaused(ctx: DeploymentContext, paused: boolean): Promise { + if (!ctx.id) throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); + const action = paused ? "pause" : "unpause"; + const res = (await this.client.post(`/deployments/${ctx.id}/${action}`, {})) as Record; + return toDeploymentInfo(res); + } + async createSession(bindings: SessionBindings): Promise { if (bindings.delivery === "forward") throw new UserError("Claude does not support Forward sessions."); const body = mapSession(bindings); @@ -532,6 +582,19 @@ export class ClaudeAdapter implements ProviderAdapter { } } +function toDeploymentInfo(res: Record): DeploymentInfo { + const sched = res.schedule as Record | null | undefined; + return { + id: (res.id as string | undefined) ?? null, + status: (res.status as string) ?? "unknown", + paused_reason: (res.paused_reason as DeploymentInfo["paused_reason"] | null | undefined) ?? undefined, + schedule: sched + ? { expression: sched.expression as string, timezone: sched.timezone as string | undefined } + : undefined, + attributes: res, + }; +} + export function toSessionInfo(res: Record): ProviderSessionInfo { return buildSessionInfo(res, (r) => ((r.resources as Array>) ?? []) diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 12f41c1..42a0ba7 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -340,6 +340,33 @@ export function mapDeployment( return body; } +export function mapDeploymentUpdate( + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + projectName?: string, + uploadedFiles?: Map, + existingMetadata?: Record, +): unknown { + const body = mapDeployment(name, decl, refs, projectName, uploadedFiles) as Record; + body.vault_ids = refs.vault_ids; + body.resources = mapDeploymentResources(decl, refs, uploadedFiles); + if (decl.schedule) { + body.schedule = { type: "cron", expression: decl.schedule.expression, timezone: decl.schedule.timezone }; + } + body.description = decl.description ?? ""; + const desiredMetadata = projectName ? injectMetadata(decl.metadata, projectName, name) : (decl.metadata ?? {}); + body.metadata = { + ...Object.fromEntries( + Object.keys(existingMetadata ?? {}) + .filter((key) => !(key in desiredMetadata)) + .map((key) => [key, null]), + ), + ...desiredMetadata, + }; + return body; +} + function mapInitialEvents(events: InitialEventDecl[]): unknown[] { return events.map((ev) => { if (ev.type === "user.message" || ev.type === "system.message") { diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 806191a..39d9b6b 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -123,6 +123,22 @@ export interface DeploymentInfo { attributes?: Record; } +export interface DeploymentListFilter { + agent_id?: string; + status?: "active" | "paused"; + include_archived?: boolean; + limit?: number; + page?: string; + created_at_gte?: string; + created_at_lte?: string; +} + +export interface DeploymentListResult { + deployments: DeploymentInfo[]; + has_more: boolean; + next_page?: string; +} + export interface ProviderAdapter { readonly name: string; /** @@ -226,6 +242,9 @@ export interface ProviderAdapter { deleteDeployment(id: string): Promise; runDeployment(ctx: DeploymentContext): Promise; getDeployment(ctx: DeploymentContext): Promise; + listDeployments?(filter?: DeploymentListFilter): Promise; + pauseDeployment?(ctx: DeploymentContext): Promise; + unpauseDeployment?(ctx: DeploymentContext): Promise; uploadFile(filePath: string, options?: { name?: string; purpose?: string }): Promise; /** Upload from in-memory content (no filesystem), for server contexts that receive bytes directly (e.g. webui browser uploads). */ diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 13f40cd..3ff31b2 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -44,6 +44,8 @@ import type { ComparableRemoteResource, DeploymentContext, DeploymentInfo, + DeploymentListFilter, + DeploymentListResult, DeploymentRunResult, DriftSupport, ExportedResource, @@ -76,6 +78,7 @@ import { mapAgent, mapCredential, mapDeployment, + mapDeploymentUpdate, mapEnvironment, mapForwardTemplate, mapMemoryStore, @@ -94,6 +97,19 @@ function deriveForwardGateway(cloudGateway?: string): string { return trimmed.endsWith("/cloud") ? `${trimmed.slice(0, -"/cloud".length)}/forward` : `${trimmed}/forward`; } +function toDeploymentInfo(res: Record): DeploymentInfo { + const sched = res.schedule as Record | null | undefined; + return { + id: (res.id as string | undefined) ?? null, + status: (res.status as string) ?? "unknown", + paused_reason: (res.paused_reason as DeploymentInfo["paused_reason"] | null | undefined) ?? undefined, + schedule: sched + ? { expression: sched.expression as string, timezone: sched.timezone as string | undefined } + : undefined, + attributes: res, + }; +} + export class QoderAdapter implements ProviderAdapter { readonly name = "qoder" as const; readonly eventResume = true; @@ -143,7 +159,7 @@ export class QoderAdapter implements ProviderAdapter { skill: "/skills", memory_store: "/memory_stores", file: "/files", - // deployment omitted: emulated on Qoder, no remote listing endpoint + deployment: "/deployments", }; async findResource(type: ResourceType, name: string, id?: string | null): Promise { @@ -662,7 +678,20 @@ export class QoderAdapter implements ProviderAdapter { basePath: string, ): Promise { const uploaded = await this.uploadDeploymentFiles(decl, basePath); - const body = mapDeployment(name, decl, refs, this.projectName, uploaded); + const current = (await this.client.get(`/deployments/${id}`)) as Record; + if (current.schedule && !decl.schedule) { + throw new UserError( + `Deployment '${name}' cannot remove its schedule through the documented Qoder update API; archive and recreate it as a manual deployment.`, + ); + } + const body = mapDeploymentUpdate( + name, + decl, + refs, + this.projectName, + uploaded, + current.metadata as Record | undefined, + ); const res = (await this.client.post(`/deployments/${id}`, body)) as Record; return toRemoteResource(res); } @@ -703,6 +732,39 @@ export class QoderAdapter implements ProviderAdapter { }; } + async listDeployments(filter?: DeploymentListFilter): Promise { + const params = new URLSearchParams(); + if (filter?.agent_id) params.set("agent_id", filter.agent_id); + if (filter?.status) params.set("status", filter.status); + if (filter?.include_archived) params.set("include_archived", "true"); + if (filter?.limit) params.set("limit", String(filter.limit)); + if (filter?.page) params.set("page", filter.page); + if (filter?.created_at_gte) params.set("created_at[gte]", filter.created_at_gte); + if (filter?.created_at_lte) params.set("created_at[lte]", filter.created_at_lte); + const query = params.toString(); + const res = (await this.client.get(`/deployments${query ? `?${query}` : ""}`)) as Record; + return { + deployments: ((res.data as Record[] | undefined) ?? []).map(toDeploymentInfo), + has_more: Boolean(res.has_more), + next_page: (res.next_page as string | null | undefined) ?? undefined, + }; + } + + async pauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, true); + } + + async unpauseDeployment(ctx: DeploymentContext): Promise { + return this.setDeploymentPaused(ctx, false); + } + + private async setDeploymentPaused(ctx: DeploymentContext, paused: boolean): Promise { + if (!ctx.id) throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); + const action = paused ? "pause" : "unpause"; + const res = (await this.client.post(`/deployments/${ctx.id}/${action}`, {})) as Record; + return toDeploymentInfo(res); + } + private async uploadDeploymentFiles(decl: DeploymentDecl, basePath: string): Promise> { const map = new Map(); for (const r of decl.resources ?? []) { diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 3571f46..4c625f1 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -261,6 +261,7 @@ export function mapDeployment( } if (decl.description) body.description = decl.description; + if (decl.environment_variables !== undefined) body.environment_variables = decl.environment_variables; if (projectName) { body.metadata = injectMetadata(decl.metadata, projectName, name); @@ -271,6 +272,34 @@ export function mapDeployment( return body; } +export function mapDeploymentUpdate( + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + projectName?: string, + uploadedFiles?: Map, + existingMetadata?: Record, +): unknown { + const body = mapDeployment(name, decl, refs, projectName, uploadedFiles) as Record; + body.vault_ids = refs.vault_ids; + body.resources = mapDeploymentResources(decl, refs, uploadedFiles); + if (decl.schedule) { + body.schedule = { type: "cron", expression: decl.schedule.expression, timezone: decl.schedule.timezone }; + } + body.description = decl.description ?? ""; + body.environment_variables = decl.environment_variables ?? null; + const desiredMetadata = projectName ? injectMetadata(decl.metadata, projectName, name) : (decl.metadata ?? {}); + body.metadata = { + ...Object.fromEntries( + Object.keys(existingMetadata ?? {}) + .filter((key) => !(key in desiredMetadata)) + .map((key) => [key, null]), + ), + ...desiredMetadata, + }; + return body; +} + function mapDeploymentInitialEvents(events: InitialEventDecl[]): unknown[] { return events.map((ev) => { if (ev.type === "user.message" || ev.type === "system.message") { diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index ee5ccc5..8859152 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -110,6 +110,11 @@ export interface ResourceCrudAdapter { export interface DeploymentRunAdapter { runDeployment(ctx: DeploymentContext): Promise; getDeployment(ctx: DeploymentContext): Promise; + listDeployments?( + filter?: import("./interface.ts").DeploymentListFilter, + ): Promise; + pauseDeployment?(ctx: DeploymentContext): Promise; + unpauseDeployment?(ctx: DeploymentContext): Promise; } /** diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index c20588d..9af2dca 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -241,6 +241,8 @@ export interface DeploymentDecl { description?: string; provider?: ProviderName; metadata?: Record; + /** Qoder-only deployment-level variables copied into each created session. */ + environment_variables?: string; } export type DeploymentResourceDecl = diff --git a/packages/sdk/tests/unit/deployment.test.ts b/packages/sdk/tests/unit/deployment.test.ts index c9a898a..442fa1d 100644 --- a/packages/sdk/tests/unit/deployment.test.ts +++ b/packages/sdk/tests/unit/deployment.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; +import { validateProjectConfig } from "../../src/internal/core/validate-config.ts"; import { resolveDeploymentRefs } from "../../src/internal/executor/resolver.ts"; import { loadConfig } from "../../src/internal/parser/index.ts"; import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; @@ -160,8 +161,32 @@ describe("resolveDeploymentRefs", () => { }); }); +describe("deployment provider validation", () => { + test("rejects Qoder-only environment_variables on Claude", () => { + const config = makeConfig(); + config.providers = { claude: {} }; + config.defaults = { provider: "claude" }; + config.deployments!.daily!.provider = "claude"; + config.deployments!.daily!.environment_variables = "FEATURE_FLAG=on"; + const diagnostics = validateProjectConfig(config); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + severity: "error", + code: "claude.deployment.environment_variables.unsupported", + }), + ); + }); + + test("allows deployment environment_variables on Qoder", () => { + const config = makeConfig(); + config.deployments!.daily!.environment_variables = "FEATURE_FLAG=on"; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code.includes("environment_variables"))).toBe(false); + }); +}); + describe("Qoder native deployment CRUD", () => { - function makeAdapter() { + function makeAdapter(currentSchedule = false) { const calls: Array<{ method: string; path: string; body?: unknown }> = []; const adapter = new QoderAdapter("pt-test-dummy", undefined, "proj") as QoderAdapter & { client: { @@ -173,11 +198,21 @@ describe("Qoder native deployment CRUD", () => { async post(path, body) { calls.push({ method: "post", path, body }); if (path.endsWith("/run")) return { id: "drun_1", type: "deployment_run", session_id: "sess_1", error: null }; + if (path.endsWith("/pause")) return { id: "dep_1", type: "deployment", status: "paused" }; return { id: "dep_1", type: "deployment", status: "active" }; }, async get(path) { calls.push({ method: "get", path }); - return { id: "dep_1", type: "deployment", status: "active", schedule: null }; + if (path.startsWith("/deployments?")) { + return { data: [{ id: "dep_1", name: "d", status: "active" }], has_more: true, next_page: "next" }; + } + return { + id: "dep_1", + type: "deployment", + status: "active", + schedule: currentSchedule ? { type: "cron", expression: "0 9 * * *", timezone: "UTC" } : null, + metadata: { stale: "value" }, + }; }, }; return { adapter, calls }; @@ -203,7 +238,51 @@ describe("Qoder native deployment CRUD", () => { const { adapter, calls } = makeAdapter(); const res = await adapter.updateDeployment("dep_1", "d", decl, refs, "/tmp/agents.yaml"); expect(res).toEqual({ id: "dep_1", type: "deployment" }); - expect(calls[0]).toMatchObject({ method: "post", path: "/deployments/dep_1" }); + expect(calls[0]).toMatchObject({ method: "get", path: "/deployments/dep_1" }); + expect(calls[1]).toMatchObject({ method: "post", path: "/deployments/dep_1" }); + expect(calls[1].body).toMatchObject({ + resources: [], + vault_ids: [], + description: "", + metadata: { stale: null, "agents.project": "proj", "agents.resource": "d" }, + }); + expect((calls[1].body as Record).schedule).toBeUndefined(); + }); + + test("refuses to silently preserve a removed schedule", async () => { + const { adapter, calls } = makeAdapter(true); + await expect(adapter.updateDeployment("dep_1", "d", decl, refs, "/tmp/agents.yaml")).rejects.toThrow( + /archive and recreate/, + ); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "get", path: "/deployments/dep_1" }); + }); + + test("lists remote deployments with filters and pagination", async () => { + const { adapter, calls } = makeAdapter(); + const result = await adapter.listDeployments({ + status: "active", + include_archived: true, + page: "cursor", + limit: 10, + }); + expect(result).toMatchObject({ + has_more: true, + next_page: "next", + deployments: [{ id: "dep_1", status: "active" }], + }); + expect(calls[0].path).toContain("/deployments?"); + expect(calls[0].path).toContain("status=active"); + expect(calls[0].path).toContain("include_archived=true"); + expect(calls[0].path).toContain("page=cursor"); + }); + + test("pauses and unpauses a remote deployment", async () => { + const { adapter, calls } = makeAdapter(); + const ctx = { name: "d", id: "dep_1", decl, refs, basePath: "/tmp/agents.yaml" }; + expect((await adapter.pauseDeployment(ctx)).status).toBe("paused"); + expect((await adapter.unpauseDeployment(ctx)).status).toBe("active"); + expect(calls.map((call) => call.path)).toEqual(["/deployments/dep_1/pause", "/deployments/dep_1/unpause"]); }); test("deleteDeployment archives the remote deployment", async () => { diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index 0255085..e1d14da 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; import { mapDeploymentToSession as mapBailianDeploymentToSession } from "../../src/internal/providers/bailian/mapper.ts"; -import { mapDeployment } from "../../src/internal/providers/claude/mapper.ts"; +import { mapDeployment, mapDeploymentUpdate } from "../../src/internal/providers/claude/mapper.ts"; import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; import { mapDeploymentToSession, mapDeployment as mapQoderDeployment, + mapDeploymentUpdate as mapQoderDeploymentUpdate, } from "../../src/internal/providers/qoder/mapper.ts"; import type { DeploymentDecl } from "../../src/internal/types/config.ts"; @@ -259,6 +260,42 @@ describe("Qoder mapDeployment", () => { expect(body.vault_ids).toEqual(["vault_a"]); expect(body.metadata).toEqual({ "agents.project": "myproj", "agents.resource": "daily-report" }); }); + + test("create carries environment variables and update explicitly clears removed fields", () => { + const configured = mapQoderDeployment( + "d", + { agent: "x", initial_events: [{ type: "user.message", content: "run" }], environment_variables: "B=2;A=1" }, + minimalRefs(), + ) as Record; + expect(configured.environment_variables).toBe("B=2;A=1"); + + const update = mapQoderDeploymentUpdate( + "d", + { agent: "x", initial_events: [{ type: "user.message", content: "run" }] }, + minimalRefs(), + undefined, + undefined, + { stale: "value" }, + ) as Record; + expect(update).toMatchObject({ + vault_ids: [], + resources: [], + description: "", + environment_variables: null, + metadata: { stale: null }, + }); + expect(update.schedule).toBeUndefined(); + }); + + test("Claude update explicitly clears removed optional fields", () => { + const update = mapDeploymentUpdate( + "d", + { agent: "x", initial_events: [{ type: "user.message", content: "run" }] }, + minimalRefs(), + ) as Record; + expect(update).toMatchObject({ vault_ids: [], resources: [], description: "" }); + expect(update.schedule).toBeUndefined(); + }); }); describe("Bailian mapDeploymentToSession", () => {