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="每日增长数据复盘" + /> +
+
+ + +
+
+
+ +