From 265f37fea5679c34e25dd058f7bd79fda4816396 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Tue, 21 Jul 2026 12:55:21 +0800 Subject: [PATCH 1/2] Complete deployment API lifecycle support Change-Id: Id64addfb4d90b3f879d37951c9099b7225644051 --- docs/guides/manage-deployments.md | 5 ++ docs/reference/cli.md | 3 + packages/cli/src/commands/deployment.ts | 57 +++++++++++++ packages/cli/src/program.ts | 29 ++++++- packages/cli/tests/unit/cli-contracts.test.ts | 43 ++++++++++ packages/sdk/src/index.ts | 4 + .../src/internal/core/deployment-runtime.ts | 32 ++++++- .../sdk/src/internal/core/validate-config.ts | 9 ++ packages/sdk/src/internal/parser/schema.ts | 1 + .../src/internal/providers/claude/adapter.ts | 65 +++++++++++++- .../src/internal/providers/claude/mapper.ts | 27 ++++++ .../sdk/src/internal/providers/interface.ts | 19 +++++ .../src/internal/providers/qoder/adapter.ts | 66 +++++++++++++- .../src/internal/providers/qoder/mapper.ts | 29 +++++++ .../internal/providers/resource-workflow.ts | 5 ++ packages/sdk/src/internal/types/config.ts | 2 + packages/sdk/tests/unit/deployment.test.ts | 85 ++++++++++++++++++- .../sdk/tests/unit/map-deployment.test.ts | 39 ++++++++- 18 files changed, 510 insertions(+), 10 deletions(-) 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", () => { From 248fe3821fcb857d08ceab23534b0e25bc398975 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Tue, 21 Jul 2026 15:26:01 +0800 Subject: [PATCH 2/2] Connect schedule UI to native deployments Change-Id: If67249ef2946589a4456738b79f57671e6b9ed4d --- apps/server/openapi.json | 495 ++++++++++++++++++ apps/server/src/app.ts | 2 + apps/server/src/lib/build-runtime-config.ts | 4 +- apps/server/src/routes/deployments.ts | 84 +++ apps/server/src/schemas/deployments.ts | 36 ++ .../server/src/services/deployments/manage.ts | 197 +++++++ apps/server/src/services/runtime-factory.ts | 17 +- apps/server/tests/deployments-manage.test.ts | 183 +++++++ apps/webui/src/App.tsx | 28 +- apps/webui/src/app/schedule.css | 408 +++++++++++++++ apps/webui/src/components/BottomBar.tsx | 5 + apps/webui/src/components/Composer.tsx | 5 + apps/webui/src/components/ScheduleButton.tsx | 69 +++ apps/webui/src/components/ScheduleCenter.tsx | 263 ++++++++++ apps/webui/src/components/TopBar.tsx | 8 + apps/webui/src/lib/api/client.ts | 18 + apps/webui/src/lib/api/contract.ts | 26 + apps/webui/src/lib/api/generated/schema.d.ts | 406 ++++++++++++++ apps/webui/src/lib/api/transports/rest.ts | 6 + apps/webui/src/lib/schedule.ts | 40 ++ apps/webui/src/lib/topbar-route.ts | 22 +- apps/webui/src/main.tsx | 1 + apps/webui/tests/schedule.test.ts | 17 + apps/webui/tests/topbar-route.test.ts | 22 + 24 files changed, 2350 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/routes/deployments.ts create mode 100644 apps/server/src/schemas/deployments.ts create mode 100644 apps/server/src/services/deployments/manage.ts create mode 100644 apps/server/tests/deployments-manage.test.ts create mode 100644 apps/webui/src/app/schedule.css create mode 100644 apps/webui/src/components/ScheduleButton.tsx create mode 100644 apps/webui/src/components/ScheduleCenter.tsx create mode 100644 apps/webui/src/lib/schedule.ts create mode 100644 apps/webui/tests/schedule.test.ts diff --git a/apps/server/openapi.json b/apps/server/openapi.json index c6ca75b..a888f0b 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -577,6 +577,501 @@ } } }, + "/api/deployments": { + "get": { + "responses": { + "200": { + "description": "List managed deployments", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deployments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "playbookId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "schedule": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": ["expression", "timezone"] + }, + "status": { + "type": "string" + }, + "remoteId": { + "type": "string", + "nullable": true + } + }, + "required": ["id", "name", "playbookId", "provider", "prompt", "schedule", "status", "remoteId"] + } + } + }, + "required": ["deployments"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "playbookId": { + "type": "string", + "minLength": 1 + }, + "prompt": { + "type": "string", + "minLength": 1 + }, + "expression": { + "type": "string", + "minLength": 1 + }, + "timezone": { + "type": "string", + "minLength": 1, + "default": "Asia/Shanghai" + } + }, + "required": ["name", "playbookId", "prompt", "expression"] + } + } + } + }, + "responses": { + "201": { + "description": "Create a native deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "playbookId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "schedule": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": ["expression", "timezone"] + }, + "status": { + "type": "string" + }, + "remoteId": { + "type": "string", + "nullable": true + } + }, + "required": ["id", "name", "playbookId", "provider", "prompt", "schedule", "status", "remoteId"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/deployments/{id}/paused": { + "put": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "paused": { + "type": "boolean" + } + }, + "required": ["paused"] + } + } + } + }, + "responses": { + "200": { + "description": "Pause or resume a deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + } + }, + "required": ["id", "status"], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/deployments/{id}/runs": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "201": { + "description": "Trigger a deployment run", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "result": { + "type": "object", + "properties": { + "run_id": { + "type": "string" + }, + "session_id": { + "type": "string", + "nullable": true + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + } + }, + "required": ["session_id"] + } + }, + "required": ["name", "provider", "result"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/deployments/{id}": { + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Delete a deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + } + }, + "required": ["deleted"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/agents": { "get": { "parameters": [ diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a736f49..9432e38 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -3,6 +3,7 @@ import { cors } from "hono/cors"; import { jsonError } from "@/lib/http-error"; import { agentsRoute } from "@/routes/agents"; import { configRoute } from "@/routes/config"; +import { deploymentsRoute } from "@/routes/deployments"; import { environmentsRoute } from "@/routes/environments"; import { filesRoute } from "@/routes/files"; import { modelsRoute } from "@/routes/models"; @@ -25,6 +26,7 @@ app.use( // Routes app.route("/api", configRoute); +app.route("/api", deploymentsRoute); app.route("/api", agentsRoute); app.route("/api", environmentsRoute); app.route("/api", vaultsRoute); diff --git a/apps/server/src/lib/build-runtime-config.ts b/apps/server/src/lib/build-runtime-config.ts index 9a5c70e..99adb7d 100644 --- a/apps/server/src/lib/build-runtime-config.ts +++ b/apps/server/src/lib/build-runtime-config.ts @@ -23,8 +23,8 @@ export function resolveRuntimeProvider(): string { * resolved from env via the SDK's central provider→env mapping so the var list lives in * one place (the registry) rather than being duplicated here. */ -export async function buildRuntimeConfig(): Promise { - const provider = resolveRuntimeProvider(); +export async function buildRuntimeConfig(providerOverride?: string): Promise { + const provider = providerOverride?.trim() || resolveRuntimeProvider(); const providerConfig = resolveProviderConfigFromEnv(provider); const environment = getEnvironmentProfile(provider); diff --git a/apps/server/src/routes/deployments.ts b/apps/server/src/routes/deployments.ts new file mode 100644 index 0000000..42d44db --- /dev/null +++ b/apps/server/src/routes/deployments.ts @@ -0,0 +1,84 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { errorResponses } from "@/schemas/common"; +import { + CreateDeploymentBodySchema, + DeleteDeploymentSchema, + DeploymentListSchema, + DeploymentRunSchema, + DeploymentStatusSchema, + SetDeploymentPausedBodySchema, +} from "@/schemas/deployments"; +import { + createManagedDeployment, + deleteManagedDeployment, + listManagedDeployments, + runManagedDeployment, + setManagedDeploymentPaused, +} from "@/services/deployments/manage"; + +export const deploymentsRoute = new OpenAPIHono(); +const idParam = z.object({ id: z.string().min(1) }); + +const listRoute = createRoute({ + method: "get", + path: "/deployments", + responses: { + 200: { description: "List managed deployments", content: { "application/json": { schema: DeploymentListSchema } } }, + ...errorResponses, + }, +}); +deploymentsRoute.openapi(listRoute, async (c) => c.json({ deployments: await listManagedDeployments() }, 200)); + +const createRouteDef = createRoute({ + method: "post", + path: "/deployments", + request: { body: { content: { "application/json": { schema: CreateDeploymentBodySchema } } } }, + responses: { + 201: { + description: "Create a native deployment", + content: { "application/json": { schema: DeploymentStatusSchema } }, + }, + ...errorResponses, + }, +}); +deploymentsRoute.openapi(createRouteDef, async (c) => c.json(await createManagedDeployment(c.req.valid("json")), 201)); + +const pauseRoute = createRoute({ + method: "put", + path: "/deployments/{id}/paused", + request: { params: idParam, body: { content: { "application/json": { schema: SetDeploymentPausedBodySchema } } } }, + responses: { + 200: { + description: "Pause or resume a deployment", + content: { + "application/json": { schema: z.object({ id: z.string().nullable(), status: z.string() }).passthrough() }, + }, + }, + ...errorResponses, + }, +}); +deploymentsRoute.openapi(pauseRoute, async (c) => + c.json(await setManagedDeploymentPaused(c.req.valid("param").id, c.req.valid("json").paused), 200), +); + +const runRoute = createRoute({ + method: "post", + path: "/deployments/{id}/runs", + request: { params: idParam }, + responses: { + 201: { description: "Trigger a deployment run", content: { "application/json": { schema: DeploymentRunSchema } } }, + ...errorResponses, + }, +}); +deploymentsRoute.openapi(runRoute, async (c) => c.json(await runManagedDeployment(c.req.valid("param").id), 201)); + +const deleteRoute = createRoute({ + method: "delete", + path: "/deployments/{id}", + request: { params: idParam }, + responses: { + 200: { description: "Delete a deployment", content: { "application/json": { schema: DeleteDeploymentSchema } } }, + ...errorResponses, + }, +}); +deploymentsRoute.openapi(deleteRoute, async (c) => c.json(await deleteManagedDeployment(c.req.valid("param").id), 200)); diff --git a/apps/server/src/schemas/deployments.ts b/apps/server/src/schemas/deployments.ts new file mode 100644 index 0000000..0d3303c --- /dev/null +++ b/apps/server/src/schemas/deployments.ts @@ -0,0 +1,36 @@ +import { z } from "@hono/zod-openapi"; + +export const DeploymentStatusSchema = z.object({ + id: z.string(), + name: z.string(), + playbookId: z.string(), + provider: z.string(), + prompt: z.string(), + schedule: z.object({ expression: z.string(), timezone: z.string() }), + status: z.string(), + remoteId: z.string().nullable(), +}); + +export const DeploymentListSchema = z.object({ deployments: z.array(DeploymentStatusSchema) }); + +export const CreateDeploymentBodySchema = z.object({ + name: z.string().min(1), + playbookId: z.string().min(1), + prompt: z.string().min(1), + expression: z.string().min(1), + timezone: z.string().min(1).default("Asia/Shanghai"), +}); + +export const SetDeploymentPausedBodySchema = z.object({ paused: z.boolean() }); + +export const DeploymentRunSchema = z.object({ + name: z.string(), + provider: z.string(), + result: z.object({ + run_id: z.string().optional(), + session_id: z.string().nullable(), + error: z.object({ type: z.string(), message: z.string() }).optional(), + }), +}); + +export const DeleteDeploymentSchema = z.object({ deleted: z.boolean() }); diff --git a/apps/server/src/services/deployments/manage.ts b/apps/server/src/services/deployments/manage.ts new file mode 100644 index 0000000..62b21e0 --- /dev/null +++ b/apps/server/src/services/deployments/manage.ts @@ -0,0 +1,197 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { + executePlannedProject, + getDeploymentDetailsForContext, + pauseDeploymentForContext, + planProjectContext, + type ResolvedProjectConfig, + runDeploymentForContext, + syncAgentResourcesWithStateBackend, + UserError, + writeProjectRuntime, +} from "@openagentpack/sdk"; +import { loadCompiledRuntimeInput } from "@/services/runtime-factory"; + +export interface StoredDeployment { + id: string; + name: string; + playbookId: string; + prompt: string; + expression: string; + timezone: string; + provider: "qoder" | "claude"; +} + +const storePath = () => + process.env.AGENTS_DEPLOYMENTS_PATH?.trim() || join(homedir(), ".agents", "playground.deployments.json"); + +async function readStore(): Promise { + try { + const parsed = JSON.parse(await readFile(storePath(), "utf8")) as { deployments?: StoredDeployment[] }; + return Array.isArray(parsed.deployments) + ? parsed.deployments.map((item) => ({ + ...item, + // Legacy records predate provider ownership. The active provider is the only + // recoverable source for those records; every new record persists it explicitly. + provider: item.provider ?? process.env.AGENTS_PROVIDER, + })) + : []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function writeStore(deployments: StoredDeployment[]): Promise { + const path = storePath(); + await mkdir(dirname(path), { recursive: true }); + const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`; + await writeFile(temp, `${JSON.stringify({ deployments }, null, 2)}\n`, { mode: 0o600 }); + await rename(temp, path); +} + +function attachDeployment(input: Awaited>, item: StoredDeployment) { + const config = JSON.parse(JSON.stringify(input.config)) as ResolvedProjectConfig; + config.deployments = { + ...(config.deployments ?? {}), + [item.id]: { + agent: input.compiled.agentId, + environment: Object.keys(config.environments ?? {})[0], + initial_events: [{ type: "user.message", content: item.prompt }], + schedule: { expression: item.expression, timezone: item.timezone }, + description: item.name, + provider: config.defaults?.provider, + metadata: { "openagentpack.webui": "schedule", "openagentpack.title": item.name }, + }, + }; + return { ...input, config, providers: config.providers }; +} + +async function runtimeFor(item: StoredDeployment) { + return attachDeployment(await loadCompiledRuntimeInput(item.playbookId, item.provider), item); +} + +function assertNativeProvider(provider: string | undefined): asserts provider is "qoder" | "claude" { + if (provider !== "qoder" && provider !== "claude") { + throw new UserError( + `WebUI schedules require a native deployment provider (qoder or claude), got '${provider ?? "unknown"}'.`, + ); + } +} + +async function executeOnlyDeployment(input: Awaited>, id: string, deleting = false) { + return writeProjectRuntime(input, async (ctx) => { + const planned = await planProjectContext(ctx, { provider: input.config.defaults?.provider, quiet: true }); + const actions = planned.plan.actions.filter( + (action) => + action.address.type === "deployment" && action.address.name === id && (!deleting || action.action === "delete"), + ); + if (actions.length === 0) + throw new UserError(`Deployment '${id}' produced no ${deleting ? "delete" : "apply"} action.`); + const execution = await executePlannedProject( + { + ...planned, + plan: { ...planned.plan, actions }, + destructiveActions: actions.filter((a) => a.action === "delete"), + }, + { policy: deleting ? "force" : "block" }, + ); + const failed = execution.results.find((result) => result.status !== "success"); + if (failed) { + throw new UserError(failed.error ?? `Deployment '${id}' ${failed.status}.`); + } + return execution; + }); +} + +let storeMutationQueue: Promise = Promise.resolve(); + +function serializeStoreMutation(mutation: () => Promise): Promise { + const result = storeMutationQueue.then(mutation, mutation); + storeMutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +export async function listManagedDeployments() { + const records = await readStore(); + return Promise.all( + records.map(async (item) => { + try { + const input = await runtimeFor(item); + assertNativeProvider(input.config.defaults?.provider); + return await writeProjectRuntime(input, async (ctx) => { + const detail = await getDeploymentDetailsForContext(ctx, item.id); + return { + ...item, + provider: detail.provider, + schedule: { expression: item.expression, timezone: item.timezone }, + status: detail.info.status, + remoteId: detail.info.id, + }; + }); + } catch { + return { + ...item, + schedule: { expression: item.expression, timezone: item.timezone }, + status: "unavailable", + remoteId: null, + }; + } + }), + ); +} + +export async function createManagedDeployment(input: Omit) { + return serializeStoreMutation(async () => { + const records = await readStore(); + const id = `schedule-${crypto.randomUUID()}`; + const base = await loadCompiledRuntimeInput(input.playbookId); + assertNativeProvider(base.config.defaults?.provider); + const item: StoredDeployment = { ...input, id, provider: base.config.defaults.provider }; + const runtime = attachDeployment(base, item); + const agentRun = await syncAgentResourcesWithStateBackend(runtime, runtime.compiled.agentId); + if (agentRun.status !== "completed") throw new UserError(agentRun.error ?? "Failed to provision schedule agent."); + await executeOnlyDeployment(runtime, id); + await writeStore([...records, item]); + return (await listManagedDeployments()).find((deployment) => deployment.id === id)!; + }); +} + +async function requireStored(id: string) { + const records = await readStore(); + const item = records.find((record) => record.id === id); + if (!item) throw new UserError(`Deployment '${id}' not found.`); + return { item, records }; +} + +export async function setManagedDeploymentPaused(id: string, paused: boolean) { + const { item } = await requireStored(id); + const input = await runtimeFor(item); + return writeProjectRuntime(input, (ctx) => pauseDeploymentForContext(ctx, id, paused)); +} + +export async function runManagedDeployment(id: string) { + const { item } = await requireStored(id); + const input = await runtimeFor(item); + const run = await writeProjectRuntime(input, (ctx) => runDeploymentForContext(ctx, id)); + if (run.result.error) { + throw new UserError(`Deployment run failed: ${run.result.error.type} - ${run.result.error.message}`); + } + return run; +} + +export async function deleteManagedDeployment(id: string) { + return serializeStoreMutation(async () => { + const { item, records } = await requireStored(id); + const input = await runtimeFor(item); + input.config.deployments = {}; + await executeOnlyDeployment(input, id, true); + await writeStore(records.filter((record) => record.id !== id)); + return { deleted: true }; + }); +} diff --git a/apps/server/src/services/runtime-factory.ts b/apps/server/src/services/runtime-factory.ts index 39f5c38..850938d 100644 --- a/apps/server/src/services/runtime-factory.ts +++ b/apps/server/src/services/runtime-factory.ts @@ -16,8 +16,8 @@ export type AgentRuntimeInput = ServerRuntimeInput & { * BackendRuntimeInput that core's scoped entries * (readProjectRuntime / writeProjectRuntime) consume directly. */ -export async function loadServerRuntimeConfig(): Promise { - const { configPath, projectName, config } = await buildRuntimeConfig(); +export async function loadServerRuntimeConfig(providerOverride?: string): Promise { + const { configPath, projectName, config } = await buildRuntimeConfig(providerOverride); const stateScope = deriveWebUiStateScope(); const stateBackend = createWebUiStateBackend(); const statePath = stateBackend.getStatePath(stateScope); @@ -37,8 +37,12 @@ export async function loadServerRuntimeConfig(): Promise { * and keep the scoped backend. Consumed by the *WithStateBackend plan/sync flows. * A `modelOverride` recompiles the agent decl with a switched model so a sync applies it. */ -export async function loadAgentRuntimeInput(agentId: string, modelOverride?: string): Promise { - const base = await loadServerRuntimeConfig(); +export async function loadAgentRuntimeInput( + agentId: string, + modelOverride?: string, + providerOverride?: string, +): Promise { + const base = await loadServerRuntimeConfig(providerOverride); const compiled = compileAgentRuntime(agentId, base.config, modelOverride); return { ...base, @@ -49,6 +53,11 @@ export async function loadAgentRuntimeInput(agentId: string, modelOverride?: str }; } +/** Build a runtime input whose config contains a compiled playbook plus caller-owned resources. */ +export async function loadCompiledRuntimeInput(agentId: string, provider?: string): Promise { + return loadAgentRuntimeInput(agentId, undefined, provider); +} + /** * Run a function within an agent-scoped runtime context. Reads through the core * scoped entry, which hands back a cloned in-memory snapshot. diff --git a/apps/server/tests/deployments-manage.test.ts b/apps/server/tests/deployments-manage.test.ts new file mode 100644 index 0000000..0f0360e --- /dev/null +++ b/apps/server/tests/deployments-manage.test.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let activeProvider = "qoder"; +let executionStatus: "success" | "failed" = "success"; +let executionGate: Promise | undefined; +const missingRemoteIds = new Set(); +const unavailableProviders = new Set(); +let runError: { type: string; message: string } | undefined; + +mock.module("@/services/runtime-factory", () => ({ + loadCompiledRuntimeInput: async (playbookId: string, providerOverride?: string) => { + const provider = providerOverride ?? activeProvider; + if (unavailableProviders.has(provider)) throw new Error(`credentials unavailable for ${provider}`); + return { + projectName: "test", + configPath: "/tmp/agents.yaml", + statePath: "/tmp/state.json", + stateBackend: {}, + stateScope: { projectId: "test" }, + providers: new Map(), + config: { + _resolved: true, + version: "1", + providers: { [provider]: {} }, + defaults: { provider }, + environments: { base: {} }, + agents: { agent: { model: "model", instructions: "test" } }, + }, + agentId: "agent", + compiled: { agentId: "agent", agent: { id: playbookId }, agentConfigHash: "hash" }, + }; + }, +})); + +mock.module("@openagentpack/sdk", () => ({ + UserError: class UserError extends Error {}, + syncAgentResourcesWithStateBackend: async () => ({ status: "completed" }), + writeProjectRuntime: async (input: unknown, fn: (ctx: unknown) => unknown) => fn({ input }), + planProjectContext: async (ctx: { input: { config: { deployments?: Record } } }) => { + const configured = Object.keys(ctx.input.config.deployments ?? {}); + const id = configured[0] ?? globalThis.__deploymentDeleteId; + return { + executionContext: ctx, + plan: { + diagnostics: [], + actions: [ + { + address: { type: "deployment", name: id, provider: activeProvider }, + action: configured.length ? "create" : "delete", + dependencies: [], + }, + ], + }, + destructiveActions: [], + }; + }, + executePlannedProject: async (planned: { plan: { actions: unknown[] } }) => { + if (executionGate) await executionGate; + return { + results: planned.plan.actions.map((action) => ({ + action, + status: executionStatus, + ...(executionStatus === "failed" ? { error: "provider failed" } : {}), + })), + partial: executionStatus === "failed", + }; + }, + getDeploymentDetailsForContext: async ( + ctx: { input: { config: { defaults: { provider: string }; deployments: Record } } }, + id: string, + ) => { + if (missingRemoteIds.has(id)) throw new Error("remote deployment not found"); + return { provider: ctx.input.config.defaults.provider, info: { id: `remote-${id}`, status: "active" } }; + }, + pauseDeploymentForContext: async () => ({ id: "remote", status: "paused" }), + runDeploymentForContext: async (_ctx: unknown, name: string) => ({ + name, + provider: activeProvider, + result: { session_id: runError ? null : "session", ...(runError ? { error: runError } : {}) }, + }), +})); + +const manage = await import("../src/services/deployments/manage"); +const testDir = await mkdtemp(join(tmpdir(), "opencma-deployments-")); +const storePath = join(testDir, "deployments.json"); +process.env.AGENTS_DEPLOYMENTS_PATH = storePath; + +function input(name: string) { + return { name, playbookId: "base", prompt: "test", expression: "0 9 * * *", timezone: "Asia/Shanghai" }; +} + +async function stored() { + try { + return JSON.parse(await readFile(storePath, "utf8")) as { + deployments: Array<{ id: string; name: string; provider?: string }>; + }; + } catch { + return { deployments: [] }; + } +} + +beforeEach(async () => { + await rm(storePath, { force: true }); + activeProvider = "qoder"; + executionStatus = "success"; + executionGate = undefined; + missingRemoteIds.clear(); + unavailableProviders.clear(); + runError = undefined; + globalThis.__deploymentDeleteId = undefined; +}); + +afterAll(async () => { + delete process.env.AGENTS_DEPLOYMENTS_PATH; + await rm(testDir, { recursive: true, force: true }); +}); + +describe("managed deployments consistency", () => { + test("does not persist a deployment when provider apply returns a failed result", async () => { + executionStatus = "failed"; + await expect(manage.createManagedDeployment(input("failed"))).rejects.toThrow("provider failed"); + expect((await stored()).deployments).toHaveLength(0); + }); + + test("retains the local record when provider delete returns a failed result", async () => { + const created = await manage.createManagedDeployment(input("keep-me")); + globalThis.__deploymentDeleteId = created.id; + executionStatus = "failed"; + await expect(manage.deleteManagedDeployment(created.id)).rejects.toThrow("provider failed"); + expect((await stored()).deployments.map((item) => item.id)).toEqual([created.id]); + }); + + test("keeps using the provider that owned the deployment after the active provider changes", async () => { + await manage.createManagedDeployment(input("qoder-owned")); + activeProvider = "claude"; + const [deployment] = await manage.listManagedDeployments(); + expect(deployment?.provider).toBe("qoder"); + }); + + test("concurrent creates retain both records", async () => { + let release!: () => void; + executionGate = new Promise((resolve) => { + release = resolve; + }); + const first = manage.createManagedDeployment(input("first")); + const second = manage.createManagedDeployment(input("second")); + await Bun.sleep(10); + release(); + await Promise.all([first, second]); + expect(new Set((await stored()).deployments.map((item) => item.name))).toEqual(new Set(["first", "second"])); + }); + + test("one missing remote deployment does not make the whole list fail", async () => { + const missing = await manage.createManagedDeployment(input("missing")); + await manage.createManagedDeployment(input("healthy")); + missingRemoteIds.add(missing.id); + const deployments = await manage.listManagedDeployments(); + expect(deployments).toHaveLength(2); + expect(deployments.find((item) => item.id === missing.id)?.status).toBe("unavailable"); + expect(deployments.find((item) => item.name === "healthy")?.status).toBe("active"); + }); + + test("one provider with unavailable credentials does not make the whole list fail", async () => { + await manage.createManagedDeployment(input("qoder")); + unavailableProviders.add("qoder"); + const deployments = await manage.listManagedDeployments(); + expect(deployments).toHaveLength(1); + expect(deployments[0]?.status).toBe("unavailable"); + }); + + test("surfaces a provider-reported deployment run error", async () => { + const created = await manage.createManagedDeployment(input("run-error")); + runError = { type: "capacity", message: "provider rejected the run" }; + await expect(manage.runManagedDeployment(created.id)).rejects.toThrow("provider rejected the run"); + }); +}); + +declare global { + var __deploymentDeleteId: string | undefined; +} diff --git a/apps/webui/src/App.tsx b/apps/webui/src/App.tsx index aa4361c..1dec630 100644 --- a/apps/webui/src/App.tsx +++ b/apps/webui/src/App.tsx @@ -8,6 +8,7 @@ import PromptDialog from "@/components/PromptDialog"; import { PromptEditorProvider } from "@/components/prompt-editor/PromptEditorProvider"; import RoleCards from "@/components/RoleCards"; import ResourceCenter from "@/components/resource-center"; +import ScheduleCenter from "@/components/ScheduleCenter"; import SettingsDialog from "@/components/SettingsDialog"; import Showcase from "@/components/Showcase"; import TopBar from "@/components/TopBar"; @@ -18,6 +19,7 @@ import { useAgentsConfigReady } from "@/lib/hooks/useAgentsConfigReady"; import { getRoleCards } from "@/lib/playbooks"; import type { RoleCard } from "@/lib/playbooks/types"; import { isPlaygroundMode } from "@/lib/runtime-mode"; +import type { SchedulePreset } from "@/lib/schedule"; import { useProviderConfigRevision } from "@/lib/store/provider-config-store"; import { useTopBarView } from "@/lib/use-topbar-view"; @@ -73,6 +75,12 @@ export default function Home() { const [models, setModels] = useState([]); const [selectedModelsByAgent, setSelectedModelsByAgent] = useState>({}); const [warmProgress, setWarmProgress] = useState(null); + const [scheduleDraft, setScheduleDraft] = useState<{ + key: number; + preset?: SchedulePreset; + prompt: string; + playbookId: string; + }>({ key: 0, prompt: "", playbookId: "" }); // Only read inside handlers (top composer vs. bottom bar routing), never rendered — a ref avoids // re-rendering the whole page each time the bar scrolls in or out of view. const bottomBarVisibleRef = useRef(false); @@ -177,10 +185,24 @@ export default function Home() { [activeAgentSlug], ); + const handleOpenSchedule = useCallback( + (preset?: SchedulePreset) => { + setScheduleDraft((current) => ({ + key: current.key + 1, + preset, + prompt: inputValue, + playbookId: activeAgentSlug, + })); + setView("schedule"); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, + [activeAgentSlug, inputValue, setView], + ); + return ( - {view === "resources" ? ( + {view === "resources" || view === "schedule" ? ( <>
@@ -191,7 +213,7 @@ export default function Home() { settingsOpen={settingsOpen} onOpenSettings={() => setSettingsOpen(true)} /> - + {view === "resources" ? : }
@@ -239,6 +261,7 @@ export default function Home() { models={models} onModelChange={handleModelChange} onMakeSame={handleMakeSame} + onOpenSchedule={handleOpenSchedule} canSubmit={canSubmit} /> @@ -259,6 +282,7 @@ export default function Home() { composerRef={composerRef as React.RefObject} onVisibilityChange={handleBottomBarVisibility} onMakeSame={handleMakeSame} + onOpenSchedule={handleOpenSchedule} canSubmit={canSubmit} /> diff --git a/apps/webui/src/app/schedule.css b/apps/webui/src/app/schedule.css new file mode 100644 index 0000000..24d79d1 --- /dev/null +++ b/apps/webui/src/app/schedule.css @@ -0,0 +1,408 @@ +.schedule-trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--ink); + font-size: 13px; + font-weight: 600; + box-shadow: none; + transition: + background 0.18s ease, + border-color 0.18s ease; +} + +.schedule-trigger:hover, +.schedule-trigger.active { + background: var(--soft); + border-color: var(--line-2); +} + +.schedule-menu-wrap { + position: relative; + z-index: 25; + display: inline-flex; +} + +.schedule-menu { + position: absolute; + left: 0; + bottom: calc(100% + 12px); + width: 260px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + box-shadow: 0 18px 50px rgba(11, 12, 15, 0.14); +} + +.schedule-menu::after { + content: ""; + position: absolute; + left: 22px; + bottom: -7px; + width: 12px; + height: 12px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #ffffff; + transform: rotate(45deg); +} + +.composer .schedule-menu { + top: 100%; + bottom: auto; +} + +.composer .schedule-menu::after { + top: -7px; + bottom: auto; + border: 0; + border-left: 1px solid var(--line); + border-top: 1px solid var(--line); +} + +.schedule-menu-primary, +.schedule-menu-item { + width: 100%; + height: 40px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink); + display: flex; + align-items: center; + gap: 14px; + padding: 0 14px; + font-size: 15px; + font-weight: 650; + text-align: left; +} + +.schedule-menu-item { + font-weight: 600; +} + +.schedule-menu-primary:hover, +.schedule-menu-item:hover { + background: var(--soft); +} + +.schedule-menu-divider { + height: 1px; + margin: 4px 0; + background: var(--line); +} + +.schedule-page { + width: min(100%, 1120px); + margin: 0 auto; + padding: 54px 28px 80px; +} + +.schedule-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; +} + +.schedule-kicker { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.schedule-hero h1 { + margin: 0; + font-size: 42px; + line-height: 1.08; + letter-spacing: 0; +} + +.schedule-subtitle { + margin: 12px 0 0; + max-width: 560px; + color: var(--muted); + font-size: 15px; + line-height: 1.7; +} + +.schedule-create-btn { + height: 40px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: var(--cta); + color: #ffffff; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + white-space: nowrap; +} + +.schedule-builder, +.schedule-list { + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + box-shadow: var(--shadow); +} + +.schedule-builder { + padding: 20px; + margin-bottom: 22px; +} + +.schedule-feedback, +.schedule-empty { + margin: 0 0 16px; + padding: 12px 14px; + border-radius: 10px; + background: var(--surface-soft); + color: var(--muted); + font-size: 14px; +} + +.builder-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 14px; + margin-top: 14px; +} + +.builder-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.builder-field label { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.builder-field textarea, +.builder-field input, +.builder-field select { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--soft); + color: var(--ink); + font: inherit; + outline: none; + transition: + background 0.18s ease, + border-color 0.18s ease; +} + +.builder-field textarea:focus, +.builder-field input:focus, +.builder-field select:focus { + background: #ffffff; + border-color: var(--ink); +} + +.builder-field textarea { + min-height: 98px; + padding: 14px; + resize: vertical; + line-height: 1.6; +} + +.builder-field input, +.builder-field select { + height: 42px; + padding: 0 12px; +} + +.schedule-preset-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.schedule-preset { + height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--ink); + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 650; +} + +.schedule-preset:hover { + background: var(--soft); +} + +.schedule-list { + overflow: hidden; +} + +.schedule-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.schedule-list-head h2 { + margin: 0; + font-size: 18px; + line-height: 1.2; +} + +.schedule-list-head span { + color: var(--muted); + font-size: 13px; +} + +.schedule-table { + display: flex; + flex-direction: column; +} + +.schedule-row { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr) auto; + align-items: center; + gap: 18px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.schedule-row:last-child { + border-bottom: 0; +} + +.schedule-row-title { + display: flex; + align-items: center; + gap: 8px; +} + +.schedule-row-title svg { + color: var(--green); + flex-shrink: 0; +} + +.schedule-row-title h3 { + margin: 0; + font-size: 15px; + line-height: 1.3; +} + +.schedule-row-main p { + margin: 8px 0 0 25px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.schedule-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.schedule-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.schedule-actions button { + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--muted); + display: inline-grid; + place-items: center; +} + +.schedule-actions button:hover { + background: var(--soft); + color: var(--ink); +} + +@media (max-width: 720px) { + .schedule-trigger span { + display: inline; + } + + .schedule-menu { + left: 0; + width: min(280px, calc(100vw - 32px)); + } + + .schedule-page { + padding: 24px 16px calc(120px + env(safe-area-inset-bottom)); + } + + .schedule-hero { + align-items: flex-start; + flex-direction: column; + gap: 16px; + } + + .schedule-hero h1 { + font-size: 32px; + } + + .schedule-create-btn { + width: 100%; + justify-content: center; + } + + .builder-grid { + grid-template-columns: 1fr; + } + + .schedule-row { + grid-template-columns: 1fr; + align-items: stretch; + gap: 12px; + } + + .schedule-row-main p { + margin-left: 0; + } + + .schedule-meta { + grid-template-columns: 1fr; + } + + .schedule-actions { + justify-content: flex-end; + } +} diff --git a/apps/webui/src/components/BottomBar.tsx b/apps/webui/src/components/BottomBar.tsx index 8b871de..5f3acab 100644 --- a/apps/webui/src/components/BottomBar.tsx +++ b/apps/webui/src/components/BottomBar.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useImperativeHandle, useReducer, useRef, useSta import { usePromptEditor } from "@/components/prompt-editor/PromptEditorProvider"; import suggestions from "@/data/suggestions.json"; import type { UiModel } from "@/lib/domain/model-api"; +import type { SchedulePreset } from "@/lib/schedule"; import { AttachFilesButton, ComposerFeeNotice, @@ -12,6 +13,7 @@ import { useFilePickerModal, } from "./ComposerInputShared"; import ModelSelector from "./ModelSelector"; +import ScheduleButton from "./ScheduleButton"; import { useSubmitTask } from "./useSubmitTask"; export interface BottomBarHandle { @@ -29,6 +31,7 @@ interface BottomBarProps { onVisibilityChange: (visible: boolean) => void; onMakeSame?: (input: { prompt: string; agentId?: string }) => void; canSubmit?: boolean; + onOpenSchedule?: (preset?: SchedulePreset) => void; ref?: React.Ref; } @@ -60,6 +63,7 @@ export default function BottomBar({ onModelChange, composerRef, onVisibilityChange, + onOpenSchedule, ref, canSubmit = true, }: BottomBarProps) { @@ -212,6 +216,7 @@ export default function BottomBar({
+
diff --git a/apps/webui/src/components/Composer.tsx b/apps/webui/src/components/Composer.tsx index db48a8c..f7fe485 100644 --- a/apps/webui/src/components/Composer.tsx +++ b/apps/webui/src/components/Composer.tsx @@ -2,6 +2,7 @@ import { useEffect, useImperativeHandle, useRef, useState } from "react"; import { usePromptEditor } from "@/components/prompt-editor/PromptEditorProvider"; import type { UiModel } from "@/lib/domain/model-api"; import type { RoleCard } from "@/lib/playbooks/types"; +import type { SchedulePreset } from "@/lib/schedule"; import { AttachFilesButton, ComposerFeeNotice, @@ -11,6 +12,7 @@ import { useFilePickerModal, } from "./ComposerInputShared"; import ModelSelector from "./ModelSelector"; +import ScheduleButton from "./ScheduleButton"; import TaskBox from "./TaskBox"; import TaskRuntime from "./TaskRuntime"; import { useSubmitTask } from "./useSubmitTask"; @@ -30,6 +32,7 @@ interface ComposerProps { models: UiModel[]; onModelChange: (modelId: string) => void; onMakeSame?: (input: { prompt: string; agentId?: string }) => void; + onOpenSchedule?: (preset?: SchedulePreset) => void; canSubmit?: boolean; ref?: React.Ref; } @@ -44,6 +47,7 @@ export default function Composer({ models, onModelChange, onMakeSame, + onOpenSchedule, canSubmit = true, ref, }: ComposerProps) { @@ -136,6 +140,7 @@ export default function Composer({
+
diff --git a/apps/webui/src/components/ScheduleButton.tsx b/apps/webui/src/components/ScheduleButton.tsx new file mode 100644 index 0000000..6c835ef --- /dev/null +++ b/apps/webui/src/components/ScheduleButton.tsx @@ -0,0 +1,69 @@ +import { CalendarClock, CalendarDays, Repeat } from "lucide-react"; +import { useState } from "react"; +import type { SchedulePreset } from "@/lib/schedule"; + +interface ScheduleButtonProps { + onOpenSchedule?: (preset?: SchedulePreset) => void; +} + +const quickOptions = [ + { icon: Repeat, label: "每天 9:00", value: "daily" }, + { icon: CalendarDays, label: "工作日 9:00", value: "weekdays" }, + { icon: CalendarClock, label: "每周一 9:00", value: "weekly" }, +] satisfies Array<{ icon: typeof CalendarClock; label: string; value: SchedulePreset }>; + +export default function ScheduleButton({ onOpenSchedule }: ScheduleButtonProps) { + const [open, setOpen] = useState(false); + + const chooseQuickOption = (preset: SchedulePreset) => { + setOpen(false); + onOpenSchedule?.(preset); + }; + + return ( +
+ + {open ? ( +
+ +
+ {quickOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/apps/webui/src/components/ScheduleCenter.tsx b/apps/webui/src/components/ScheduleCenter.tsx new file mode 100644 index 0000000..93b53ac --- /dev/null +++ b/apps/webui/src/components/ScheduleCenter.tsx @@ -0,0 +1,263 @@ +import { CalendarDays, CheckCircle2, Clock3, PauseCircle, PlayCircle, Plus, Repeat2, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + createApiDeployment, + deleteApiDeployment, + listApiDeployments, + type ManagedDeployment, + runApiDeployment, + setApiDeploymentPaused, +} from "@/lib/api/client"; +import { getRoleCards } from "@/lib/playbooks"; +import type { RoleCard } from "@/lib/playbooks/types"; +import { cronLabel, type SchedulePreset, schedulePresetValue, toCron } from "@/lib/schedule"; + +const presets = [ + { label: "每天 9:00", icon: Repeat2, value: "daily" }, + { label: "工作日 9:00", icon: CalendarDays, value: "weekdays" }, + { label: "每周一 9:00", icon: CalendarDays, value: "weekly" }, +] satisfies Array<{ label: string; icon: typeof CalendarDays; value: SchedulePreset }>; + +interface ScheduleCenterProps { + draft?: { key: number; preset?: SchedulePreset; prompt: string; playbookId: string }; +} + +export default function ScheduleCenter({ draft }: ScheduleCenterProps) { + const defaultSchedule = useMemo(() => schedulePresetValue("daily"), []); + const [items, setItems] = useState([]); + const [roles, setRoles] = useState([]); + const [name, setName] = useState(""); + const [prompt, setPrompt] = useState(""); + const [playbookId, setPlaybookId] = useState(""); + const [time, setTime] = useState(defaultSchedule.time); + const [repeat, setRepeat] = useState(defaultSchedule.repeat); + const [busy, setBusy] = useState("load"); + const [message, setMessage] = useState(null); + + const reload = useCallback(async () => { + setBusy("load"); + const result = await listApiDeployments(); + setBusy(null); + if (result.error) return setMessage(result.error.error.message ?? "加载定时任务失败"); + setItems(result.data?.deployments ?? []); + }, []); + + useEffect(() => { + void reload(); + void getRoleCards().then((cards) => { + setRoles(cards); + setPlaybookId((current) => current || cards[0]?.slug || ""); + }); + }, [reload]); + + useEffect(() => { + if (!draft?.key) return; + setPrompt(draft.prompt); + if (draft.playbookId) setPlaybookId(draft.playbookId); + if (draft.preset) { + const value = schedulePresetValue(draft.preset); + setTime(value.time); + setRepeat(value.repeat); + } + }, [draft]); + + async function create() { + if (!name.trim() || !prompt.trim() || !playbookId) return setMessage("请填写名称、任务内容并选择执行角色"); + setBusy("create"); + setMessage(null); + let expression: string; + try { + expression = toCron(time, repeat); + } catch (error) { + setBusy(null); + return setMessage((error as Error).message); + } + const result = await createApiDeployment({ + body: { + name: name.trim(), + playbookId, + prompt: prompt.trim(), + expression, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai", + }, + }); + setBusy(null); + if (result.error) return setMessage(result.error.error.message ?? "创建失败"); + setName(""); + setPrompt(""); + setMessage("已创建并同步到真实 deployment 服务"); + await reload(); + } + + async function mutate( + id: string, + operation: () => Promise<{ error?: { error: { message?: string } } }>, + success: string, + ) { + setBusy(id); + setMessage(null); + const result = await operation(); + setBusy(null); + if (result.error) return setMessage(result.error.error.message ?? "操作失败"); + setMessage(success); + await reload(); + } + + return ( +
+
+
+

真实服务自动执行

+

定时

+

通过 OpenCMA server 创建 Qoder / Claude 原生 Deployment,到点自动运行。

+
+ +
+ {message && ( +

+ {message} +

+ )} +
+
+
+ + setName(e.target.value)} + placeholder="每日增长数据复盘" + /> +
+
+ + +
+
+
+ +