@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
262 lines (261 loc) • 9.52 kB
JavaScript
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 };
}
}