import { randomUUID } from "node:crypto"; import { hostname } from "node:os"; import { PI_WEB_HOST_PI_SESSIONS_CAPABILITY, PI_WEB_HOST_PI_SESSION_EVENTS_CAPABILITY, } from "@jmfederico/pi-web/server-plugin-api"; import type { JsonObject, JsonValue, PiWebHostPiSessionEventsV1, PiWebHostPiSessionsV1, PiWebServerPlugin, ServerPluginActivationContext, ServerPluginPeerRequestContext, } from "@jmfederico/pi-web/server-plugin-api"; import { Engine, errorText, type AgentRequest, type AgentResult, type ExecRequest, type NotifyRequest, type Runtime } from "./core/engine.js"; import { execPortable, runHeadlessAgent, scheduledPrompt } from "./core/nodeRuntime.js"; import { SchedulerService } from "./core/service.js"; import { defaultStoreRoot, JobStore, type Binding } from "./core/store.js"; import type { JobInput } from "./core/validate.js"; import { AGENT_REPLY, AGENT_REQUEST, summarizeRun, type AgentReplyMessage, type Overview } from "./browser/protocol.js"; interface Settings { storeRoot?: string; tickMs?: number; keepRuns?: number; allowedExecutables?: string[]; piCommand?: string; } function readSettings(raw: JsonObject): Settings { const s: Settings = {}; if (typeof raw.storeRoot === "string" && raw.storeRoot.trim()) s.storeRoot = raw.storeRoot.trim(); if (typeof raw.tickMs === "number" && raw.tickMs >= 5_000) s.tickMs = raw.tickMs; if (typeof raw.keepRuns === "number" && raw.keepRuns >= 1) s.keepRuns = Math.floor(raw.keepRuns); if (Array.isArray(raw.allowedExecutables)) s.allowedExecutables = raw.allowedExecutables.filter((x): x is string => typeof x === "string"); if (typeof raw.piCommand === "string" && raw.piCommand.trim()) s.piCommand = raw.piCommand.trim(); return s; } /** Runtime inside the PI WEB session daemon: hosted agent sessions when the cwd is bound. */ class PiWebRuntime implements Runtime { readonly hostName = `piweb@${hostname()}`; sessions?: PiWebHostPiSessionsV1; events?: PiWebHostPiSessionEventsV1; constructor(private readonly host: ServerPluginActivationContext, private readonly store: JobStore, private readonly settings: Settings) {} exec(req: ExecRequest) { return execPortable(req); } async agent(req: AgentRequest): Promise { const mode = req.step.mode ?? "auto"; const binding = (await this.store.getBindings())[req.job.cwd]; if (mode === "headless" || (mode === "auto" && (!binding || !this.sessions || !this.events))) { return runHeadlessAgent(req, this.store.root, this.settings.piCommand ?? "pi"); } if (!binding) return { ok: false, text: "", error: `cwd '${req.job.cwd}' non associato a un workspace PI WEB: apri il pannello Scheduler in quel workspace oppure usa mode=headless` }; return this.hostedAgent(req, binding); } private async hostedAgent(req: AgentRequest, binding: Binding): Promise { if (!this.sessions || !this.events) return { ok: false, text: "", error: "host session capabilities unavailable" }; const selection = { projectId: binding.projectId, workspaceId: binding.workspaceId }; const { sessionId } = await this.sessions.create(selection); const connection = await this.events.connect({ ...selection, sessionId }); const requestId = randomUUID(); const signal = AbortSignal.any([req.signal, connection.signal, this.host.lifetimeSignal]); let unsubscribe: (() => void) | undefined; let onAbort: (() => void) | undefined; let receiptTimer: ReturnType | undefined; try { const text = await new Promise((resolve, reject) => { onAbort = () => reject(new Error("agent run interrupted (timeout, cancel or connection closed); the session may still be working")); signal.addEventListener("abort", onAbort, { once: true }); receiptTimer = setTimeout(() => reject(new Error("companion extension did not acknowledge within 15s: is the pi-web-scheduler package enabled for pi sessions?")), 15_000); unsubscribe = connection.on(AGENT_REPLY, (data) => { const msg = data as AgentReplyMessage; if (typeof msg !== "object" || msg === null || msg.requestId !== requestId) return; if (msg.status === "accepted") { clearTimeout(receiptTimer); return; } if (msg.status === "completed") resolve(msg.text); else reject(new Error(msg.error)); }); connection.emit(AGENT_REQUEST, { requestId, prompt: scheduledPrompt(req), tools: req.step.tools ?? [] }); }); return { ok: true, text, sessionId }; } catch (e) { return { ok: false, text: "", sessionId, error: errorText(e) }; } finally { clearTimeout(receiptTimer); if (onAbort) signal.removeEventListener("abort", onAbort); unsubscribe?.(); connection.close(); } } async notify(req: NotifyRequest): Promise { const message = `[Scheduler] ${req.job.name}: ${req.message}`.slice(0, 4000); this.host.logger[req.severity === "error" ? "error" : req.severity === "warning" ? "warn" : "info"](message, { jobId: req.job.id, runId: req.run.id }); const binding = (await this.store.getBindings())[req.job.cwd]; try { this.host.notices?.record({ severity: req.severity, message, ...(binding ? { scope: { projectId: binding.projectId, workspaceId: binding.workspaceId } } : {}), context: { jobId: req.job.id, runId: req.run.id }, }); } catch (e) { this.host.logger.warn(`notice failed: ${errorText(e)}`); } } } function asObject(input: JsonValue): Record { if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("input must be an object"); return input as Record; } function reqString(o: Record, key: string): string { const v = o[key]; if (typeof v !== "string" || !v) throw new Error(`${key} is required`); return v; } const plugin = { apiVersion: 3, name: "Scheduler", requires: [PI_WEB_HOST_PI_SESSIONS_CAPABILITY, PI_WEB_HOST_PI_SESSION_EVENTS_CAPABILITY], activate(host: ServerPluginActivationContext) { const settings = readSettings(host.settings); const store = new JobStore(settings.storeRoot ?? defaultStoreRoot()); const runtime = new PiWebRuntime(host, store, settings); const engine = new Engine(store, runtime, { ...(settings.tickMs ? { tickMs: settings.tickMs } : {}), ...(settings.keepRuns ? { keepRuns: settings.keepRuns } : {}), ...(settings.allowedExecutables ? { allowedExecutables: settings.allowedExecutables } : {}), log: (level, message) => host.logger[level](message), }); const service = new SchedulerService(store, engine); async function bind(context: ServerPluginPeerRequestContext | { project: { id: string }; workspace: { id: string; path: string; label: string } }): Promise { await store.setBinding(context.workspace.path, { projectId: context.project.id, workspaceId: context.workspace.id, label: context.workspace.label, updatedAt: new Date().toISOString(), }); } async function handle(context: ServerPluginPeerRequestContext): Promise { context.signal.throwIfAborted(); await bind(context); const input = context.input; const cwd = context.workspace.path; switch (context.operation) { case "overview": { const all = asObject(input ?? {}).scope === "all"; const filter = all ? undefined : { cwd }; const overview: Overview = { host: runtime.hostName, storeRoot: store.root, now: new Date().toISOString(), workspacePath: cwd, jobs: await service.listJobs(filter), recentRuns: await service.recentRuns(30, filter), }; return overview as unknown as JsonValue; } case "job.get": return (await service.detail(reqString(asObject(input), "id"))) as unknown as JsonValue; case "job.upsert": { const o = asObject(input); const jobInput = { cwd, ...(o.job as object) } as JobInput; const job = await service.upsert(jobInput, { mustExist: o.mode === "update", mustNotExist: o.mode === "create" }); void engine.tick(); return job as unknown as JsonValue; } case "job.setEnabled": { const o = asObject(input); return (await service.setEnabled(reqString(o, "id"), o.enabled === true)) as unknown as JsonValue; } case "job.reschedule": { const o = asObject(input); return (await service.reschedule(reqString(o, "id"), o.schedule as JobInput["schedule"])) as unknown as JsonValue; } case "job.delete": { const o = asObject(input); return { deleted: await service.remove(reqString(o, "id"), o.withRuns === true) }; } case "job.runNow": return (await service.runNow(reqString(asObject(input), "id"))) as unknown as JsonValue; case "job.misfire": { const o = asObject(input); return (await service.resolveMisfire(reqString(o, "id"), o.decision === "run" ? "run" : "skip")) as unknown as JsonValue; } case "run.get": { const o = asObject(input); return (await service.getRun(reqString(o, "jobId"), reqString(o, "runId"))) as unknown as JsonValue; } case "run.cancel": return { cancelled: service.cancelRun(reqString(asObject(input), "runId")) }; case "schedule.preview": return service.previewSchedule(asObject(input).schedule as JobInput["schedule"]); default: throw new Error(`unknown operation '${context.operation}'`); } } return { async start({ capabilities }) { runtime.sessions = capabilities.resolve(PI_WEB_HOST_PI_SESSIONS_CAPABILITY); runtime.events = capabilities.resolve(PI_WEB_HOST_PI_SESSION_EVENTS_CAPABILITY); await engine.start(host.lifetimeSignal); host.logger.info(`scheduler started (store ${store.root})`); }, peer: { request: handle, async openChannel(context) { await bind(context); // Live events for the console: run started/finished, job updated. const off = engine.on((event) => { try { context.send(event as unknown as JsonValue); } catch { /* channel closing */ } }); return { receive() { /* browser frames ignored */ }, close() { off(); } }; }, }, async dispose() { await engine.stop(); }, health() { return { status: "healthy" as const, message: `${engine.activeRuns().length} run attivi`, details: { storeRoot: store.root } }; }, }; }, } satisfies PiWebServerPlugin; export default plugin; export { summarizeRun };