|
| 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 | +} |
0 commit comments