Skip to content

Commit c8e257d

Browse files
authored
Connect Schedule UI to native deployments (#51)
* Complete deployment API lifecycle support Change-Id: Id64addfb4d90b3f879d37951c9099b7225644051 * Connect schedule UI to native deployments Change-Id: If67249ef2946589a4456738b79f57671e6b9ed4d
1 parent 6292561 commit c8e257d

24 files changed

Lines changed: 2350 additions & 12 deletions

apps/server/openapi.json

Lines changed: 495 additions & 0 deletions
Large diffs are not rendered by default.

apps/server/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cors } from "hono/cors";
33
import { jsonError } from "@/lib/http-error";
44
import { agentsRoute } from "@/routes/agents";
55
import { configRoute } from "@/routes/config";
6+
import { deploymentsRoute } from "@/routes/deployments";
67
import { environmentsRoute } from "@/routes/environments";
78
import { filesRoute } from "@/routes/files";
89
import { modelsRoute } from "@/routes/models";
@@ -25,6 +26,7 @@ app.use(
2526

2627
// Routes
2728
app.route("/api", configRoute);
29+
app.route("/api", deploymentsRoute);
2830
app.route("/api", agentsRoute);
2931
app.route("/api", environmentsRoute);
3032
app.route("/api", vaultsRoute);

apps/server/src/lib/build-runtime-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export function resolveRuntimeProvider(): string {
2323
* resolved from env via the SDK's central provider→env mapping so the var list lives in
2424
* one place (the registry) rather than being duplicated here.
2525
*/
26-
export async function buildRuntimeConfig(): Promise<LoadedProjectConfig> {
27-
const provider = resolveRuntimeProvider();
26+
export async function buildRuntimeConfig(providerOverride?: string): Promise<LoadedProjectConfig> {
27+
const provider = providerOverride?.trim() || resolveRuntimeProvider();
2828
const providerConfig = resolveProviderConfigFromEnv(provider);
2929

3030
const environment = getEnvironmentProfile(provider);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2+
import { errorResponses } from "@/schemas/common";
3+
import {
4+
CreateDeploymentBodySchema,
5+
DeleteDeploymentSchema,
6+
DeploymentListSchema,
7+
DeploymentRunSchema,
8+
DeploymentStatusSchema,
9+
SetDeploymentPausedBodySchema,
10+
} from "@/schemas/deployments";
11+
import {
12+
createManagedDeployment,
13+
deleteManagedDeployment,
14+
listManagedDeployments,
15+
runManagedDeployment,
16+
setManagedDeploymentPaused,
17+
} from "@/services/deployments/manage";
18+
19+
export const deploymentsRoute = new OpenAPIHono();
20+
const idParam = z.object({ id: z.string().min(1) });
21+
22+
const listRoute = createRoute({
23+
method: "get",
24+
path: "/deployments",
25+
responses: {
26+
200: { description: "List managed deployments", content: { "application/json": { schema: DeploymentListSchema } } },
27+
...errorResponses,
28+
},
29+
});
30+
deploymentsRoute.openapi(listRoute, async (c) => c.json({ deployments: await listManagedDeployments() }, 200));
31+
32+
const createRouteDef = createRoute({
33+
method: "post",
34+
path: "/deployments",
35+
request: { body: { content: { "application/json": { schema: CreateDeploymentBodySchema } } } },
36+
responses: {
37+
201: {
38+
description: "Create a native deployment",
39+
content: { "application/json": { schema: DeploymentStatusSchema } },
40+
},
41+
...errorResponses,
42+
},
43+
});
44+
deploymentsRoute.openapi(createRouteDef, async (c) => c.json(await createManagedDeployment(c.req.valid("json")), 201));
45+
46+
const pauseRoute = createRoute({
47+
method: "put",
48+
path: "/deployments/{id}/paused",
49+
request: { params: idParam, body: { content: { "application/json": { schema: SetDeploymentPausedBodySchema } } } },
50+
responses: {
51+
200: {
52+
description: "Pause or resume a deployment",
53+
content: {
54+
"application/json": { schema: z.object({ id: z.string().nullable(), status: z.string() }).passthrough() },
55+
},
56+
},
57+
...errorResponses,
58+
},
59+
});
60+
deploymentsRoute.openapi(pauseRoute, async (c) =>
61+
c.json(await setManagedDeploymentPaused(c.req.valid("param").id, c.req.valid("json").paused), 200),
62+
);
63+
64+
const runRoute = createRoute({
65+
method: "post",
66+
path: "/deployments/{id}/runs",
67+
request: { params: idParam },
68+
responses: {
69+
201: { description: "Trigger a deployment run", content: { "application/json": { schema: DeploymentRunSchema } } },
70+
...errorResponses,
71+
},
72+
});
73+
deploymentsRoute.openapi(runRoute, async (c) => c.json(await runManagedDeployment(c.req.valid("param").id), 201));
74+
75+
const deleteRoute = createRoute({
76+
method: "delete",
77+
path: "/deployments/{id}",
78+
request: { params: idParam },
79+
responses: {
80+
200: { description: "Delete a deployment", content: { "application/json": { schema: DeleteDeploymentSchema } } },
81+
...errorResponses,
82+
},
83+
});
84+
deploymentsRoute.openapi(deleteRoute, async (c) => c.json(await deleteManagedDeployment(c.req.valid("param").id), 200));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from "@hono/zod-openapi";
2+
3+
export const DeploymentStatusSchema = z.object({
4+
id: z.string(),
5+
name: z.string(),
6+
playbookId: z.string(),
7+
provider: z.string(),
8+
prompt: z.string(),
9+
schedule: z.object({ expression: z.string(), timezone: z.string() }),
10+
status: z.string(),
11+
remoteId: z.string().nullable(),
12+
});
13+
14+
export const DeploymentListSchema = z.object({ deployments: z.array(DeploymentStatusSchema) });
15+
16+
export const CreateDeploymentBodySchema = z.object({
17+
name: z.string().min(1),
18+
playbookId: z.string().min(1),
19+
prompt: z.string().min(1),
20+
expression: z.string().min(1),
21+
timezone: z.string().min(1).default("Asia/Shanghai"),
22+
});
23+
24+
export const SetDeploymentPausedBodySchema = z.object({ paused: z.boolean() });
25+
26+
export const DeploymentRunSchema = z.object({
27+
name: z.string(),
28+
provider: z.string(),
29+
result: z.object({
30+
run_id: z.string().optional(),
31+
session_id: z.string().nullable(),
32+
error: z.object({ type: z.string(), message: z.string() }).optional(),
33+
}),
34+
});
35+
36+
export const DeleteDeploymentSchema = z.object({ deleted: z.boolean() });
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2+
import { homedir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import {
5+
executePlannedProject,
6+
getDeploymentDetailsForContext,
7+
pauseDeploymentForContext,
8+
planProjectContext,
9+
type ResolvedProjectConfig,
10+
runDeploymentForContext,
11+
syncAgentResourcesWithStateBackend,
12+
UserError,
13+
writeProjectRuntime,
14+
} from "@openagentpack/sdk";
15+
import { loadCompiledRuntimeInput } from "@/services/runtime-factory";
16+
17+
export interface StoredDeployment {
18+
id: string;
19+
name: string;
20+
playbookId: string;
21+
prompt: string;
22+
expression: string;
23+
timezone: string;
24+
provider: "qoder" | "claude";
25+
}
26+
27+
const storePath = () =>
28+
process.env.AGENTS_DEPLOYMENTS_PATH?.trim() || join(homedir(), ".agents", "playground.deployments.json");
29+
30+
async function readStore(): Promise<StoredDeployment[]> {
31+
try {
32+
const parsed = JSON.parse(await readFile(storePath(), "utf8")) as { deployments?: StoredDeployment[] };
33+
return Array.isArray(parsed.deployments)
34+
? parsed.deployments.map((item) => ({
35+
...item,
36+
// Legacy records predate provider ownership. The active provider is the only
37+
// recoverable source for those records; every new record persists it explicitly.
38+
provider: item.provider ?? process.env.AGENTS_PROVIDER,
39+
}))
40+
: [];
41+
} catch (error) {
42+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
43+
throw error;
44+
}
45+
}
46+
47+
async function writeStore(deployments: StoredDeployment[]): Promise<void> {
48+
const path = storePath();
49+
await mkdir(dirname(path), { recursive: true });
50+
const temp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
51+
await writeFile(temp, `${JSON.stringify({ deployments }, null, 2)}\n`, { mode: 0o600 });
52+
await rename(temp, path);
53+
}
54+
55+
function attachDeployment(input: Awaited<ReturnType<typeof loadCompiledRuntimeInput>>, item: StoredDeployment) {
56+
const config = JSON.parse(JSON.stringify(input.config)) as ResolvedProjectConfig;
57+
config.deployments = {
58+
...(config.deployments ?? {}),
59+
[item.id]: {
60+
agent: input.compiled.agentId,
61+
environment: Object.keys(config.environments ?? {})[0],
62+
initial_events: [{ type: "user.message", content: item.prompt }],
63+
schedule: { expression: item.expression, timezone: item.timezone },
64+
description: item.name,
65+
provider: config.defaults?.provider,
66+
metadata: { "openagentpack.webui": "schedule", "openagentpack.title": item.name },
67+
},
68+
};
69+
return { ...input, config, providers: config.providers };
70+
}
71+
72+
async function runtimeFor(item: StoredDeployment) {
73+
return attachDeployment(await loadCompiledRuntimeInput(item.playbookId, item.provider), item);
74+
}
75+
76+
function assertNativeProvider(provider: string | undefined): asserts provider is "qoder" | "claude" {
77+
if (provider !== "qoder" && provider !== "claude") {
78+
throw new UserError(
79+
`WebUI schedules require a native deployment provider (qoder or claude), got '${provider ?? "unknown"}'.`,
80+
);
81+
}
82+
}
83+
84+
async function executeOnlyDeployment(input: Awaited<ReturnType<typeof runtimeFor>>, id: string, deleting = false) {
85+
return writeProjectRuntime(input, async (ctx) => {
86+
const planned = await planProjectContext(ctx, { provider: input.config.defaults?.provider, quiet: true });
87+
const actions = planned.plan.actions.filter(
88+
(action) =>
89+
action.address.type === "deployment" && action.address.name === id && (!deleting || action.action === "delete"),
90+
);
91+
if (actions.length === 0)
92+
throw new UserError(`Deployment '${id}' produced no ${deleting ? "delete" : "apply"} action.`);
93+
const execution = await executePlannedProject(
94+
{
95+
...planned,
96+
plan: { ...planned.plan, actions },
97+
destructiveActions: actions.filter((a) => a.action === "delete"),
98+
},
99+
{ policy: deleting ? "force" : "block" },
100+
);
101+
const failed = execution.results.find((result) => result.status !== "success");
102+
if (failed) {
103+
throw new UserError(failed.error ?? `Deployment '${id}' ${failed.status}.`);
104+
}
105+
return execution;
106+
});
107+
}
108+
109+
let storeMutationQueue: Promise<void> = Promise.resolve();
110+
111+
function serializeStoreMutation<T>(mutation: () => Promise<T>): Promise<T> {
112+
const result = storeMutationQueue.then(mutation, mutation);
113+
storeMutationQueue = result.then(
114+
() => undefined,
115+
() => undefined,
116+
);
117+
return result;
118+
}
119+
120+
export async function listManagedDeployments() {
121+
const records = await readStore();
122+
return Promise.all(
123+
records.map(async (item) => {
124+
try {
125+
const input = await runtimeFor(item);
126+
assertNativeProvider(input.config.defaults?.provider);
127+
return await writeProjectRuntime(input, async (ctx) => {
128+
const detail = await getDeploymentDetailsForContext(ctx, item.id);
129+
return {
130+
...item,
131+
provider: detail.provider,
132+
schedule: { expression: item.expression, timezone: item.timezone },
133+
status: detail.info.status,
134+
remoteId: detail.info.id,
135+
};
136+
});
137+
} catch {
138+
return {
139+
...item,
140+
schedule: { expression: item.expression, timezone: item.timezone },
141+
status: "unavailable",
142+
remoteId: null,
143+
};
144+
}
145+
}),
146+
);
147+
}
148+
149+
export async function createManagedDeployment(input: Omit<StoredDeployment, "id" | "provider">) {
150+
return serializeStoreMutation(async () => {
151+
const records = await readStore();
152+
const id = `schedule-${crypto.randomUUID()}`;
153+
const base = await loadCompiledRuntimeInput(input.playbookId);
154+
assertNativeProvider(base.config.defaults?.provider);
155+
const item: StoredDeployment = { ...input, id, provider: base.config.defaults.provider };
156+
const runtime = attachDeployment(base, item);
157+
const agentRun = await syncAgentResourcesWithStateBackend(runtime, runtime.compiled.agentId);
158+
if (agentRun.status !== "completed") throw new UserError(agentRun.error ?? "Failed to provision schedule agent.");
159+
await executeOnlyDeployment(runtime, id);
160+
await writeStore([...records, item]);
161+
return (await listManagedDeployments()).find((deployment) => deployment.id === id)!;
162+
});
163+
}
164+
165+
async function requireStored(id: string) {
166+
const records = await readStore();
167+
const item = records.find((record) => record.id === id);
168+
if (!item) throw new UserError(`Deployment '${id}' not found.`);
169+
return { item, records };
170+
}
171+
172+
export async function setManagedDeploymentPaused(id: string, paused: boolean) {
173+
const { item } = await requireStored(id);
174+
const input = await runtimeFor(item);
175+
return writeProjectRuntime(input, (ctx) => pauseDeploymentForContext(ctx, id, paused));
176+
}
177+
178+
export async function runManagedDeployment(id: string) {
179+
const { item } = await requireStored(id);
180+
const input = await runtimeFor(item);
181+
const run = await writeProjectRuntime(input, (ctx) => runDeploymentForContext(ctx, id));
182+
if (run.result.error) {
183+
throw new UserError(`Deployment run failed: ${run.result.error.type} - ${run.result.error.message}`);
184+
}
185+
return run;
186+
}
187+
188+
export async function deleteManagedDeployment(id: string) {
189+
return serializeStoreMutation(async () => {
190+
const { item, records } = await requireStored(id);
191+
const input = await runtimeFor(item);
192+
input.config.deployments = {};
193+
await executeOnlyDeployment(input, id, true);
194+
await writeStore(records.filter((record) => record.id !== id));
195+
return { deleted: true };
196+
});
197+
}

apps/server/src/services/runtime-factory.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export type AgentRuntimeInput = ServerRuntimeInput & {
1616
* BackendRuntimeInput that core's scoped entries
1717
* (readProjectRuntime / writeProjectRuntime) consume directly.
1818
*/
19-
export async function loadServerRuntimeConfig(): Promise<ServerRuntimeInput> {
20-
const { configPath, projectName, config } = await buildRuntimeConfig();
19+
export async function loadServerRuntimeConfig(providerOverride?: string): Promise<ServerRuntimeInput> {
20+
const { configPath, projectName, config } = await buildRuntimeConfig(providerOverride);
2121
const stateScope = deriveWebUiStateScope();
2222
const stateBackend = createWebUiStateBackend();
2323
const statePath = stateBackend.getStatePath(stateScope);
@@ -37,8 +37,12 @@ export async function loadServerRuntimeConfig(): Promise<ServerRuntimeInput> {
3737
* and keep the scoped backend. Consumed by the *WithStateBackend plan/sync flows.
3838
* A `modelOverride` recompiles the agent decl with a switched model so a sync applies it.
3939
*/
40-
export async function loadAgentRuntimeInput(agentId: string, modelOverride?: string): Promise<AgentRuntimeInput> {
41-
const base = await loadServerRuntimeConfig();
40+
export async function loadAgentRuntimeInput(
41+
agentId: string,
42+
modelOverride?: string,
43+
providerOverride?: string,
44+
): Promise<AgentRuntimeInput> {
45+
const base = await loadServerRuntimeConfig(providerOverride);
4246
const compiled = compileAgentRuntime(agentId, base.config, modelOverride);
4347
return {
4448
...base,
@@ -49,6 +53,11 @@ export async function loadAgentRuntimeInput(agentId: string, modelOverride?: str
4953
};
5054
}
5155

56+
/** Build a runtime input whose config contains a compiled playbook plus caller-owned resources. */
57+
export async function loadCompiledRuntimeInput(agentId: string, provider?: string): Promise<AgentRuntimeInput> {
58+
return loadAgentRuntimeInput(agentId, undefined, provider);
59+
}
60+
5261
/**
5362
* Run a function within an agent-scoped runtime context. Reads through the core
5463
* scoped entry, which hands back a cloned in-memory snapshot.

0 commit comments

Comments
 (0)