UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

262 lines (261 loc) 9.52 kB
import { randomUUID } from "node:crypto"; import { resolveTaskAdapter } from "./adapters.js"; function contextSummary(value) { if (typeof value === "string") { return { type: "string", length: value.length }; } if (value && typeof value === "object") { return { type: "object", keys: Object.keys(value).sort() }; } return { type: "none" }; } function stepConfig(step) { return { model: typeof step.taskSpec?.init?.model === "string" ? step.taskSpec.init.model : null, skills: step.taskSpec?.init?.skills ?? [], mcps: step.taskSpec?.init?.mcps ?? [], systemPrompts: step.taskSpec?.init?.systemPrompts ?? [], contextSummary: contextSummary(step.taskSpec?.init?.context), }; } function cloneSnapshot(snapshot) { return { ...snapshot, objectives: [...snapshot.objectives], waitingForApproval: snapshot.waitingForApproval ? { ...snapshot.waitingForApproval, preview: snapshot.waitingForApproval.preview ? { ...snapshot.waitingForApproval.preview, items: snapshot.waitingForApproval.preview.items.map((item) => ({ ...item })), } : null, } : null, steps: snapshot.steps.map((step) => ({ ...step })), }; } function cloneStepDetails(stepDetails) { return stepDetails.map((detail) => ({ ...detail, dependsOn: [...detail.dependsOn], config: { ...detail.config, skills: [...detail.config.skills], mcps: [...detail.config.mcps], systemPrompts: [...detail.config.systemPrompts], contextSummary: { ...detail.config.contextSummary, keys: detail.config.contextSummary.keys ? [...detail.config.contextSummary.keys] : undefined, }, }, lastExecution: { ...detail.lastExecution }, })); } function envelopeFromEvent(event) { return { id: event.id, sequence: event.sequenceNumber, type: event.type, runId: event.runId, stepKey: event.stepRunId, occurredAt: event.occurredAt, data: { ...event.payload }, }; } export class RunnerSessionStore { workflow; subscribers = new Set(); maxLogChunks = 2000; snapshotState; stepDetailsState; eventHistory = []; logHistory = []; sessionState; pendingApproval = null; constructor(options) { this.workflow = options.workflow; const startedAt = options.startedAt ?? new Date().toISOString(); const host = options.host ?? "127.0.0.1"; const port = options.port ?? 0; const attachToken = options.attachToken ?? randomUUID(); const steps = options.workflow.steps.map((step) => ({ stepKey: step.key, status: "pending", attempt: 0, confirmed: false, adapter: step.kind === "task" ? resolveTaskAdapter(step.taskSpec?.adapterKey) : "approval", startedAt: null, updatedAt: startedAt, finishedAt: null, })); const emptyExecution = { executionStatus: null, qaAction: null, feedbackReason: null, contextMetrics: null, }; this.stepDetailsState = options.workflow.steps.map((step, index) => ({ ...steps[index], kind: step.kind, objective: step.objective ?? step.title ?? null, dependsOn: step.dependsOn ?? [], config: stepConfig(step), lastExecution: { ...emptyExecution }, })); this.snapshotState = { runId: options.runId, workflowKey: options.workflow.key, workflowTitle: options.workflow.title, status: "queued", currentStepKey: null, startedAt: null, updatedAt: startedAt, endedAt: null, objective: options.objective, objectives: [...options.objectives], waitingForApproval: null, steps, }; this.sessionState = { sessionId: options.sessionId ?? randomUUID(), pid: process.pid, host, port, baseUrl: `http://${host}:${port}`, attachToken, startedAt, run: { runId: options.runId, workflowKey: options.workflow.key, workflowTitle: options.workflow.title, status: "queued", }, }; } setBinding(host, port) { this.sessionState.host = host; this.sessionState.port = port; this.sessionState.baseUrl = `http://${host}:${port}`; } sessionInfo() { return { ...this.sessionState, run: { ...this.sessionState.run }, }; } attachToken() { return this.sessionState.attachToken; } runId() { return this.snapshotState.runId; } snapshot() { return cloneSnapshot(this.snapshotState); } stepDetail(stepKey) { const detail = this.stepDetailsState.find((step) => step.stepKey === stepKey); if (!detail) { return null; } return cloneStepDetails([detail])[0] ?? null; } listLogs(stepKey, limit = 200, cursor) { const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 200; const filtered = stepKey ? this.logHistory.filter((log) => log.stepKey === stepKey) : this.logHistory; const offset = cursor ? Math.max(0, Number.parseInt(cursor, 10) || 0) : 0; const items = filtered.slice(offset, offset + parsedLimit).map((item) => ({ ...item })); const nextCursor = offset + parsedLimit < filtered.length ? String(offset + parsedLimit) : null; return { items, nextCursor }; } events(sinceSequence, includeLogs = true) { return this.eventHistory .filter((event) => (sinceSequence ? event.sequence > sinceSequence : true)) .filter((event) => includeLogs || (event.type !== "agent.stdout" && event.type !== "agent.stderr")) .map((event) => ({ ...event, data: { ...event.data } })); } subscribe(listener) { this.subscribers.add(listener); return () => { this.subscribers.delete(listener); }; } onEvent(event) { const envelope = envelopeFromEvent(event); this.eventHistory.push(envelope); for (const subscriber of this.subscribers) { subscriber(envelope); } } onSnapshot(snapshot, stepDetails) { this.snapshotState = cloneSnapshot(snapshot); this.stepDetailsState = cloneStepDetails(stepDetails); this.sessionState.run.status = snapshot.status; } onLog(log) { this.logHistory.push({ ...log }); if (this.logHistory.length > this.maxLogChunks) { this.logHistory.splice(0, this.logHistory.length - this.maxLogChunks); } } publicSession() { const { attachToken: _attachToken, ...publicInfo } = this.sessionInfo(); return publicInfo; } isKnownRun(runId) { return this.snapshotState.runId === runId; } workflowDefinition() { return this.workflow; } waitForDecision(request) { if (this.pendingApproval?.stepKey === request.stepKey) { return this.pendingApproval.promise; } let resolveDecision; const promise = new Promise((resolve) => { resolveDecision = resolve; }); this.pendingApproval = { ...request, promise, resolve: resolveDecision, }; return promise; } approve(stepKey, metadata = {}) { return this.resolvePendingApproval("approved", stepKey, metadata, "human"); } resume(stepKey, metadata = {}) { return this.resolvePendingApproval("approved", stepKey, metadata, "external"); } cancel(stepKey, metadata = {}) { return this.resolvePendingApproval("cancelled", stepKey, metadata); } resolvePendingApproval(decision, stepKey, metadata = {}, expectedValidation) { if (!this.pendingApproval) { return { ok: false, reason: "No step is waiting for approval" }; } if (stepKey && this.pendingApproval.stepKey !== stepKey) { return { ok: false, reason: `Step ${stepKey} is not currently waiting for approval`, }; } // Waits with validation "none" (or unset) declare no expected resolution verb, // so both approve and resume must be able to release them. const pendingValidation = this.pendingApproval.validation ?? "none"; if (expectedValidation && pendingValidation !== "none" && pendingValidation !== expectedValidation) { const action = expectedValidation === "external" ? "resume" : "approve"; return { ok: false, reason: `Cannot ${action} ${pendingValidation} validation for ${this.pendingApproval.stepKey}`, }; } const pending = this.pendingApproval; this.pendingApproval = null; pending.resolve({ decision, ...metadata }); return { ok: true, stepKey: pending.stepKey }; } }