Refactor UI modules and harden run/API behavior
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
import { resolve } from "node:path";
|
||||
import { loadConfig, type AppConfig } from "../config.js";
|
||||
import { parseEnvFile, writeEnvFileUpdates } from "./env-store.js";
|
||||
|
||||
export type RuntimeNotificationSettings = {
|
||||
webhookUrl: string;
|
||||
minSeverity: "info" | "warning" | "critical";
|
||||
alwaysNotifyTypes: string[];
|
||||
};
|
||||
|
||||
export type SecurityPolicySettings = {
|
||||
violationMode: "hard_abort" | "validation_fail";
|
||||
allowedBinaries: string[];
|
||||
commandTimeoutMs: number;
|
||||
inheritedEnv: string[];
|
||||
scrubbedEnv: string[];
|
||||
};
|
||||
|
||||
export type LimitSettings = {
|
||||
maxConcurrent: number;
|
||||
maxSession: number;
|
||||
maxRecursiveDepth: number;
|
||||
topologyMaxDepth: number;
|
||||
topologyMaxRetries: number;
|
||||
relationshipMaxChildren: number;
|
||||
portBase: number;
|
||||
portBlockSize: number;
|
||||
portBlockCount: number;
|
||||
portPrimaryOffset: number;
|
||||
};
|
||||
|
||||
export type UiConfigSnapshot = {
|
||||
envFilePath: string;
|
||||
runtimeEvents: RuntimeNotificationSettings;
|
||||
security: SecurityPolicySettings;
|
||||
limits: LimitSettings;
|
||||
paths: {
|
||||
stateRoot: string;
|
||||
projectContextPath: string;
|
||||
runtimeEventLogPath: string;
|
||||
securityAuditLogPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toCsv(values: readonly string[]): string {
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function sanitizeCsv(values: readonly string[]): string[] {
|
||||
const output: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
output.push(normalized);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function toRuntimeEvents(config: Readonly<AppConfig>): RuntimeNotificationSettings {
|
||||
return {
|
||||
webhookUrl: config.runtimeEvents.discordWebhookUrl ?? "",
|
||||
minSeverity: config.runtimeEvents.discordMinSeverity,
|
||||
alwaysNotifyTypes: [...config.runtimeEvents.discordAlwaysNotifyTypes],
|
||||
};
|
||||
}
|
||||
|
||||
function toSecurity(config: Readonly<AppConfig>): SecurityPolicySettings {
|
||||
return {
|
||||
violationMode: config.security.violationHandling,
|
||||
allowedBinaries: [...config.security.shellAllowedBinaries],
|
||||
commandTimeoutMs: config.security.commandTimeoutMs,
|
||||
inheritedEnv: [...config.security.inheritedEnvVars],
|
||||
scrubbedEnv: [...config.security.scrubbedEnvVars],
|
||||
};
|
||||
}
|
||||
|
||||
function toLimits(config: Readonly<AppConfig>): LimitSettings {
|
||||
return {
|
||||
maxConcurrent: config.agentManager.maxConcurrentAgents,
|
||||
maxSession: config.agentManager.maxSessionAgents,
|
||||
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
|
||||
topologyMaxDepth: config.orchestration.maxDepth,
|
||||
topologyMaxRetries: config.orchestration.maxRetries,
|
||||
relationshipMaxChildren: config.orchestration.maxChildren,
|
||||
portBase: config.provisioning.portRange.basePort,
|
||||
portBlockSize: config.provisioning.portRange.blockSize,
|
||||
portBlockCount: config.provisioning.portRange.blockCount,
|
||||
portPrimaryOffset: config.provisioning.portRange.primaryPortOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function toSnapshot(config: Readonly<AppConfig>, envFilePath: string): UiConfigSnapshot {
|
||||
return {
|
||||
envFilePath,
|
||||
runtimeEvents: toRuntimeEvents(config),
|
||||
security: toSecurity(config),
|
||||
limits: toLimits(config),
|
||||
paths: {
|
||||
stateRoot: config.orchestration.stateRoot,
|
||||
projectContextPath: config.orchestration.projectContextPath,
|
||||
runtimeEventLogPath: config.runtimeEvents.logPath,
|
||||
securityAuditLogPath: config.security.auditLogPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeEnv(fileValues: Record<string, string>): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
...fileValues,
|
||||
};
|
||||
}
|
||||
|
||||
export class UiConfigStore {
|
||||
private readonly envFilePath: string;
|
||||
|
||||
constructor(input: { workspaceRoot: string; envFilePath?: string }) {
|
||||
this.envFilePath = resolve(input.workspaceRoot, input.envFilePath ?? ".env");
|
||||
}
|
||||
|
||||
getEnvFilePath(): string {
|
||||
return this.envFilePath;
|
||||
}
|
||||
|
||||
async readSnapshot(): Promise<UiConfigSnapshot> {
|
||||
const parsed = await parseEnvFile(this.envFilePath);
|
||||
const config = loadConfig(mergeEnv(parsed.values));
|
||||
return toSnapshot(config, this.envFilePath);
|
||||
}
|
||||
|
||||
async updateRuntimeEvents(input: RuntimeNotificationSettings): Promise<UiConfigSnapshot> {
|
||||
const alwaysNotifyTypes = sanitizeCsv(input.alwaysNotifyTypes);
|
||||
|
||||
const updates: Record<string, string> = {
|
||||
AGENT_RUNTIME_DISCORD_WEBHOOK_URL: input.webhookUrl.trim(),
|
||||
AGENT_RUNTIME_DISCORD_MIN_SEVERITY: input.minSeverity,
|
||||
AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES: toCsv(alwaysNotifyTypes),
|
||||
};
|
||||
|
||||
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
|
||||
const config = loadConfig(mergeEnv(parsed.values));
|
||||
return toSnapshot(config, this.envFilePath);
|
||||
}
|
||||
|
||||
async updateSecurityPolicy(input: SecurityPolicySettings): Promise<UiConfigSnapshot> {
|
||||
const updates: Record<string, string> = {
|
||||
AGENT_SECURITY_VIOLATION_MODE: input.violationMode,
|
||||
AGENT_SECURITY_ALLOWED_BINARIES: toCsv(sanitizeCsv(input.allowedBinaries)),
|
||||
AGENT_SECURITY_COMMAND_TIMEOUT_MS: String(input.commandTimeoutMs),
|
||||
AGENT_SECURITY_ENV_INHERIT: toCsv(sanitizeCsv(input.inheritedEnv)),
|
||||
AGENT_SECURITY_ENV_SCRUB: toCsv(sanitizeCsv(input.scrubbedEnv)),
|
||||
};
|
||||
|
||||
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
|
||||
const config = loadConfig(mergeEnv(parsed.values));
|
||||
return toSnapshot(config, this.envFilePath);
|
||||
}
|
||||
|
||||
async updateLimits(input: LimitSettings): Promise<UiConfigSnapshot> {
|
||||
const updates: Record<string, string> = {
|
||||
AGENT_MAX_CONCURRENT: String(input.maxConcurrent),
|
||||
AGENT_MAX_SESSION: String(input.maxSession),
|
||||
AGENT_MAX_RECURSIVE_DEPTH: String(input.maxRecursiveDepth),
|
||||
AGENT_TOPOLOGY_MAX_DEPTH: String(input.topologyMaxDepth),
|
||||
AGENT_TOPOLOGY_MAX_RETRIES: String(input.topologyMaxRetries),
|
||||
AGENT_RELATIONSHIP_MAX_CHILDREN: String(input.relationshipMaxChildren),
|
||||
AGENT_PORT_BASE: String(input.portBase),
|
||||
AGENT_PORT_BLOCK_SIZE: String(input.portBlockSize),
|
||||
AGENT_PORT_BLOCK_COUNT: String(input.portBlockCount),
|
||||
AGENT_PORT_PRIMARY_OFFSET: String(input.portPrimaryOffset),
|
||||
};
|
||||
|
||||
const parsed = await writeEnvFileUpdates(this.envFilePath, updates);
|
||||
const config = loadConfig(mergeEnv(parsed.values));
|
||||
return toSnapshot(config, this.envFilePath);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
type AssignmentLine = {
|
||||
kind: "assignment";
|
||||
key: string;
|
||||
value: string;
|
||||
leading: string;
|
||||
};
|
||||
|
||||
type RawLine = {
|
||||
kind: "raw";
|
||||
raw: string;
|
||||
};
|
||||
|
||||
type EnvLine = AssignmentLine | RawLine;
|
||||
|
||||
export type ParsedEnvFile = {
|
||||
filePath: string;
|
||||
lines: EnvLine[];
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
const ASSIGNMENT_PATTERN = /^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
||||
|
||||
function unquoteValue(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length >= 2) {
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatValue(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (/\s|#|"|'/.test(value)) {
|
||||
const escaped = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function parseEnvFile(filePath: string): Promise<ParsedEnvFile> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
let content = "";
|
||||
|
||||
try {
|
||||
content = await readFile(resolvedPath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = content.length > 0 ? content.split(/\r?\n/) : [];
|
||||
const parsedLines: EnvLine[] = [];
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(ASSIGNMENT_PATTERN);
|
||||
if (!match) {
|
||||
parsedLines.push({ kind: "raw", raw: line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const leading = match[1] ?? "";
|
||||
const key = match[2] ?? "";
|
||||
const rawValue = match[3] ?? "";
|
||||
const value = unquoteValue(rawValue);
|
||||
|
||||
parsedLines.push({
|
||||
kind: "assignment",
|
||||
key,
|
||||
value,
|
||||
leading,
|
||||
});
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: resolvedPath,
|
||||
lines: parsedLines,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeEnvFileUpdates(
|
||||
filePath: string,
|
||||
updates: Record<string, string>,
|
||||
): Promise<ParsedEnvFile> {
|
||||
const parsed = await parseEnvFile(filePath);
|
||||
const keysToApply = new Set(Object.keys(updates));
|
||||
|
||||
const renderedLines = parsed.lines.map((line) => {
|
||||
if (line.kind !== "assignment") {
|
||||
return line.raw;
|
||||
}
|
||||
|
||||
if (!keysToApply.has(line.key)) {
|
||||
return `${line.leading}${line.key}=${formatValue(line.value)}`;
|
||||
}
|
||||
|
||||
keysToApply.delete(line.key);
|
||||
const nextValue = updates[line.key] ?? "";
|
||||
return `${line.leading}${line.key}=${formatValue(nextValue)}`;
|
||||
});
|
||||
|
||||
for (const key of keysToApply) {
|
||||
const nextValue = updates[key] ?? "";
|
||||
renderedLines.push(`${key}=${formatValue(nextValue)}`);
|
||||
}
|
||||
|
||||
const output = `${renderedLines.join("\n").replace(/\n+$/u, "")}\n`;
|
||||
const resolvedPath = resolve(filePath);
|
||||
|
||||
await mkdir(dirname(resolvedPath), { recursive: true });
|
||||
await writeFile(resolvedPath, output, "utf8");
|
||||
|
||||
return parseEnvFile(resolvedPath);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
export function sendJson(response: ServerResponse, statusCode: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
export function sendText(response: ServerResponse, statusCode: number, body: string): void {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
export async function parseJsonBody<T>(request: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
await new Promise<void>((resolveBody, rejectBody) => {
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => resolveBody());
|
||||
request.on("error", rejectBody);
|
||||
});
|
||||
|
||||
const body = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!body) {
|
||||
throw new Error("Request body is required.");
|
||||
}
|
||||
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
export function methodNotAllowed(response: ServerResponse): void {
|
||||
sendJson(response, 405, {
|
||||
ok: false,
|
||||
error: "Method not allowed.",
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(response: ServerResponse): void {
|
||||
sendJson(response, 404, {
|
||||
ok: false,
|
||||
error: "Not found.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function serveStaticFile(input: {
|
||||
response: ServerResponse;
|
||||
filePath: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const absolutePath = resolve(input.filePath);
|
||||
const fileStats = await stat(absolutePath);
|
||||
if (!fileStats.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = extname(absolutePath).toLowerCase();
|
||||
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
|
||||
input.response.statusCode = 200;
|
||||
input.response.setHeader("Content-Type", contentType);
|
||||
|
||||
await new Promise<void>((resolveStream, rejectStream) => {
|
||||
const stream = createReadStream(absolutePath);
|
||||
stream.on("error", rejectStream);
|
||||
stream.on("end", () => resolveStream());
|
||||
stream.pipe(input.response);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
|
||||
|
||||
export type ManifestRecord = {
|
||||
path: string;
|
||||
manifest: AgentManifest;
|
||||
source: unknown;
|
||||
};
|
||||
|
||||
export type ManifestListing = {
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
async function walkJsonFiles(root: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = resolve(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
output.push(...(await walkJsonFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && extname(entry.name).toLowerCase() === ".json") {
|
||||
output.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function assertWorkspacePath(workspaceRoot: string, inputPath: string): string {
|
||||
const resolved = isAbsolute(inputPath)
|
||||
? resolve(inputPath)
|
||||
: resolve(workspaceRoot, inputPath);
|
||||
const rel = relative(workspaceRoot, resolved);
|
||||
|
||||
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
||||
throw new Error("Path is outside workspace root.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function toRelativePath(workspaceRoot: string, absolutePath: string): string {
|
||||
return relative(workspaceRoot, absolutePath) || ".";
|
||||
}
|
||||
|
||||
export class ManifestStore {
|
||||
private readonly workspaceRoot: string;
|
||||
private readonly manifestDirectory: string;
|
||||
|
||||
constructor(input: { workspaceRoot: string; manifestDirectory?: string }) {
|
||||
this.workspaceRoot = resolve(input.workspaceRoot);
|
||||
this.manifestDirectory = assertWorkspacePath(
|
||||
this.workspaceRoot,
|
||||
input.manifestDirectory ?? ".ai_ops/manifests",
|
||||
);
|
||||
}
|
||||
|
||||
getManifestDirectory(): string {
|
||||
return this.manifestDirectory;
|
||||
}
|
||||
|
||||
async list(): Promise<ManifestListing> {
|
||||
try {
|
||||
const files = await walkJsonFiles(this.manifestDirectory);
|
||||
const relPaths = files
|
||||
.map((filePath) => toRelativePath(this.workspaceRoot, filePath))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
return {
|
||||
paths: relPaths,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
paths: [],
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async read(pathInput: string): Promise<ManifestRecord> {
|
||||
const absolutePath = assertWorkspacePath(this.workspaceRoot, pathInput);
|
||||
const sourceText = await readFile(absolutePath, "utf8");
|
||||
const source = JSON.parse(sourceText) as unknown;
|
||||
const manifest = parseAgentManifest(source);
|
||||
|
||||
return {
|
||||
path: toRelativePath(this.workspaceRoot, absolutePath),
|
||||
manifest,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
async validate(source: unknown): Promise<AgentManifest> {
|
||||
return parseAgentManifest(source);
|
||||
}
|
||||
|
||||
async save(pathInput: string, source: unknown): Promise<ManifestRecord> {
|
||||
const manifest = parseAgentManifest(source);
|
||||
const absolutePath = assertWorkspacePath(this.workspaceRoot, pathInput);
|
||||
await mkdir(dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
|
||||
return {
|
||||
path: toRelativePath(this.workspaceRoot, absolutePath),
|
||||
manifest,
|
||||
source: manifest,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
import { Codex } from "@openai/codex-sdk";
|
||||
import { query, type Options, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
buildClaudeAuthEnv,
|
||||
resolveAnthropicToken,
|
||||
resolveOpenAiApiKey,
|
||||
type AppConfig,
|
||||
} from "../config.js";
|
||||
import { isDomainEventType, type DomainEventEmission } from "../agents/domain-events.js";
|
||||
import type { ActorExecutionInput, ActorExecutionResult, ActorExecutor } from "../agents/pipeline.js";
|
||||
import { isRecord, type JsonObject, type JsonValue } from "../agents/types.js";
|
||||
import { createSessionContext, type SessionContext } from "../examples/session-context.js";
|
||||
|
||||
export type RunProvider = "codex" | "claude";
|
||||
|
||||
export type ProviderRunRuntime = {
|
||||
provider: RunProvider;
|
||||
config: Readonly<AppConfig>;
|
||||
sessionContext: SessionContext;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ProviderUsage = {
|
||||
tokenInput?: number;
|
||||
tokenOutput?: number;
|
||||
tokenTotal?: number;
|
||||
durationMs?: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
const ACTOR_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["success", "validation_fail", "failure"],
|
||||
},
|
||||
payload: {
|
||||
type: "object",
|
||||
},
|
||||
stateFlags: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
stateMetadata: {
|
||||
type: "object",
|
||||
},
|
||||
events: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
failureKind: {
|
||||
type: "string",
|
||||
enum: ["soft", "hard"],
|
||||
},
|
||||
failureCode: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
|
||||
const CLAUDE_OUTPUT_FORMAT = {
|
||||
type: "json_schema",
|
||||
name: "actor_execution_result",
|
||||
schema: ACTOR_RESPONSE_SCHEMA,
|
||||
} as const;
|
||||
|
||||
const CLAUDE_PROVIDER_MAX_TURNS = 2;
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): JsonValue {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
}
|
||||
|
||||
function toJsonObject(value: unknown): JsonObject | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const cloned = toJsonValue(value);
|
||||
if (!isRecord(cloned)) {
|
||||
return undefined;
|
||||
}
|
||||
return cloned as JsonObject;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toBooleanRecord(value: unknown): Record<string, boolean> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const output: Record<string, boolean> = {};
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
if (typeof candidate === "boolean") {
|
||||
output[key] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(output).length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
function toEventEmissions(value: unknown): DomainEventEmission[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const output: DomainEventEmission[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = item.type;
|
||||
if (typeof type !== "string" || !isDomainEventType(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = toJsonObject(item.payload);
|
||||
output.push({
|
||||
type,
|
||||
...(payload ? { payload } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
function extractJsonFromFencedBlock(text: string): unknown {
|
||||
const matches = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (!matches || !matches[1]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(matches[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractFirstBalancedJsonObject(text: string): unknown {
|
||||
const start = text.indexOf("{");
|
||||
if (start < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (!character) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const candidate = text.slice(start, index + 1);
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tryParseResponseObject(rawText: string, structuredOutput?: unknown): unknown {
|
||||
if (structuredOutput !== undefined) {
|
||||
return structuredOutput;
|
||||
}
|
||||
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const fenced = extractJsonFromFencedBlock(trimmed);
|
||||
if (fenced !== undefined) {
|
||||
return fenced;
|
||||
}
|
||||
|
||||
return extractFirstBalancedJsonObject(trimmed);
|
||||
}
|
||||
|
||||
function ensureUsageMetadata(input: {
|
||||
result: ActorExecutionResult;
|
||||
providerUsage: ProviderUsage;
|
||||
}): ActorExecutionResult {
|
||||
const stateMetadata = toJsonObject(input.result.stateMetadata) ?? {};
|
||||
const existingUsage = toJsonObject(stateMetadata.usage) ?? {};
|
||||
|
||||
const usage: JsonObject = {
|
||||
...existingUsage,
|
||||
...(typeof input.providerUsage.tokenInput === "number"
|
||||
? { tokenInput: input.providerUsage.tokenInput }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.tokenOutput === "number"
|
||||
? { tokenOutput: input.providerUsage.tokenOutput }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.tokenTotal === "number"
|
||||
? { tokenTotal: input.providerUsage.tokenTotal }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.durationMs === "number"
|
||||
? { durationMs: input.providerUsage.durationMs }
|
||||
: {}),
|
||||
...(typeof input.providerUsage.costUsd === "number"
|
||||
? { costUsd: input.providerUsage.costUsd }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...input.result,
|
||||
stateMetadata: {
|
||||
...stateMetadata,
|
||||
usage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseActorExecutionResultFromModelOutput(input: {
|
||||
rawText: string;
|
||||
structuredOutput?: unknown;
|
||||
}): ActorExecutionResult {
|
||||
const parsed = tryParseResponseObject(input.rawText, input.structuredOutput);
|
||||
if (!isRecord(parsed)) {
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
assistantResponse: input.rawText.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const status = parsed.status;
|
||||
if (status !== "success" && status !== "validation_fail" && status !== "failure") {
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
assistantResponse: input.rawText.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const payload = toJsonObject(parsed.payload) ?? {
|
||||
assistantResponse: input.rawText.trim(),
|
||||
};
|
||||
const stateMetadata = toJsonObject(parsed.stateMetadata);
|
||||
const stateFlags = toBooleanRecord(parsed.stateFlags);
|
||||
const events = toEventEmissions(parsed.events);
|
||||
const failureKind = parsed.failureKind === "soft" || parsed.failureKind === "hard"
|
||||
? parsed.failureKind
|
||||
: undefined;
|
||||
const failureCode = typeof parsed.failureCode === "string"
|
||||
? parsed.failureCode
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status,
|
||||
payload,
|
||||
...(stateFlags ? { stateFlags } : {}),
|
||||
...(stateMetadata ? { stateMetadata } : {}),
|
||||
...(events ? { events } : {}),
|
||||
...(failureKind ? { failureKind } : {}),
|
||||
...(failureCode ? { failureCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildActorPrompt(input: ActorExecutionInput): string {
|
||||
const recentHistory = input.context.state.history.slice(-15);
|
||||
|
||||
return [
|
||||
"You are executing one orchestration node in a schema-driven DAG runtime.",
|
||||
"Return ONLY JSON with this object shape:",
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "success | validation_fail | failure",
|
||||
payload: {},
|
||||
stateFlags: {
|
||||
optional_boolean_flag: true,
|
||||
},
|
||||
stateMetadata: {
|
||||
optional_metadata: "value",
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "requirements_defined | tasks_planned | code_committed | task_blocked | validation_passed | validation_failed | branch_merged",
|
||||
payload: {
|
||||
summary: "optional",
|
||||
details: {},
|
||||
errorCode: "optional",
|
||||
artifactPointer: "optional",
|
||||
},
|
||||
},
|
||||
],
|
||||
failureKind: "soft | hard",
|
||||
failureCode: "optional",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"Do not include markdown or extra explanation outside JSON.",
|
||||
`Node Prompt:\n${input.prompt}`,
|
||||
`Execution Context:\n${JSON.stringify(input.executionContext, null, 2)}`,
|
||||
`Current Handoff Payload:\n${JSON.stringify(input.context.handoff.payload, null, 2)}`,
|
||||
`Session Flags:\n${JSON.stringify(input.context.state.flags, null, 2)}`,
|
||||
`Recent Domain History:\n${JSON.stringify(recentHistory, null, 2)}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function runCodexActor(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Promise<ActorExecutionResult> {
|
||||
const { runtime, actorInput } = input;
|
||||
const prompt = buildActorPrompt(actorInput);
|
||||
const startedAt = Date.now();
|
||||
const apiKey = resolveOpenAiApiKey(runtime.config.provider);
|
||||
|
||||
const codex = new Codex({
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(runtime.config.provider.openAiBaseUrl
|
||||
? { baseUrl: runtime.config.provider.openAiBaseUrl }
|
||||
: {}),
|
||||
...(actorInput.mcp.resolvedConfig.codexConfig
|
||||
? { config: actorInput.mcp.resolvedConfig.codexConfig }
|
||||
: {}),
|
||||
env: runtime.sessionContext.runtimeInjection.env,
|
||||
});
|
||||
|
||||
const thread = codex.startThread({
|
||||
workingDirectory: runtime.sessionContext.runtimeInjection.workingDirectory,
|
||||
skipGitRepoCheck: runtime.config.provider.codexSkipGitCheck,
|
||||
});
|
||||
|
||||
const turn = await runtime.sessionContext.runInSession(() =>
|
||||
thread.run(prompt, {
|
||||
signal: actorInput.signal,
|
||||
outputSchema: ACTOR_RESPONSE_SCHEMA,
|
||||
}),
|
||||
);
|
||||
|
||||
const usage: ProviderUsage = {
|
||||
...(turn.usage
|
||||
? {
|
||||
tokenInput: turn.usage.input_tokens + turn.usage.cached_input_tokens,
|
||||
tokenOutput: turn.usage.output_tokens,
|
||||
tokenTotal: turn.usage.input_tokens + turn.usage.cached_input_tokens + turn.usage.output_tokens,
|
||||
}
|
||||
: {}),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
|
||||
const parsed = parseActorExecutionResultFromModelOutput({
|
||||
rawText: turn.finalResponse,
|
||||
});
|
||||
|
||||
return ensureUsageMetadata({
|
||||
result: parsed,
|
||||
providerUsage: usage,
|
||||
});
|
||||
}
|
||||
|
||||
type ClaudeTurnResult = {
|
||||
text: string;
|
||||
structuredOutput?: unknown;
|
||||
usage: ProviderUsage;
|
||||
};
|
||||
|
||||
function buildClaudeOptions(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Options {
|
||||
const { runtime, actorInput } = input;
|
||||
|
||||
const authOptionOverrides = runtime.config.provider.anthropicOauthToken
|
||||
? { authToken: runtime.config.provider.anthropicOauthToken }
|
||||
: (() => {
|
||||
const token = resolveAnthropicToken(runtime.config.provider);
|
||||
return token ? { apiKey: token } : {};
|
||||
})();
|
||||
|
||||
const runtimeEnv = {
|
||||
...runtime.sessionContext.runtimeInjection.env,
|
||||
...buildClaudeAuthEnv(runtime.config.provider),
|
||||
};
|
||||
|
||||
return {
|
||||
maxTurns: CLAUDE_PROVIDER_MAX_TURNS,
|
||||
...(runtime.config.provider.claudeModel
|
||||
? { model: runtime.config.provider.claudeModel }
|
||||
: {}),
|
||||
...(runtime.config.provider.claudeCodePath
|
||||
? { pathToClaudeCodeExecutable: runtime.config.provider.claudeCodePath }
|
||||
: {}),
|
||||
...authOptionOverrides,
|
||||
...(actorInput.mcp.resolvedConfig.claudeMcpServers
|
||||
? { mcpServers: actorInput.mcp.resolvedConfig.claudeMcpServers as Options["mcpServers"] }
|
||||
: {}),
|
||||
canUseTool: actorInput.mcp.createClaudeCanUseTool(),
|
||||
cwd: runtime.sessionContext.runtimeInjection.workingDirectory,
|
||||
env: runtimeEnv,
|
||||
outputFormat: CLAUDE_OUTPUT_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeTurn(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
prompt: string;
|
||||
}): Promise<ClaudeTurnResult> {
|
||||
const options = buildClaudeOptions({
|
||||
runtime: input.runtime,
|
||||
actorInput: input.actorInput,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
const stream = query({
|
||||
prompt: input.prompt,
|
||||
options,
|
||||
});
|
||||
|
||||
let resultText = "";
|
||||
let structuredOutput: unknown;
|
||||
let usage: ProviderUsage = {};
|
||||
|
||||
const onAbort = (): void => {
|
||||
stream.close();
|
||||
};
|
||||
|
||||
input.actorInput.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
for await (const message of stream as AsyncIterable<SDKMessage>) {
|
||||
if (message.type !== "result") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.subtype !== "success") {
|
||||
const detail = message.errors.join("; ");
|
||||
throw new Error(
|
||||
`Claude query failed (${message.subtype})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
resultText = message.result.trim();
|
||||
structuredOutput = message.structured_output;
|
||||
usage = {
|
||||
tokenInput: message.usage.input_tokens,
|
||||
tokenOutput: message.usage.output_tokens,
|
||||
tokenTotal: message.usage.input_tokens + message.usage.output_tokens,
|
||||
durationMs: message.duration_ms,
|
||||
costUsd: message.total_cost_usd,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
input.actorInput.signal.removeEventListener("abort", onAbort);
|
||||
stream.close();
|
||||
}
|
||||
|
||||
if (!resultText && structuredOutput !== undefined) {
|
||||
resultText = JSON.stringify(structuredOutput);
|
||||
}
|
||||
|
||||
if (!resultText) {
|
||||
throw new Error("Claude run completed without a final result.");
|
||||
}
|
||||
|
||||
return {
|
||||
text: resultText,
|
||||
structuredOutput,
|
||||
usage: {
|
||||
...usage,
|
||||
durationMs: usage.durationMs ?? Date.now() - startedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaudeActor(input: {
|
||||
runtime: ProviderRunRuntime;
|
||||
actorInput: ActorExecutionInput;
|
||||
}): Promise<ActorExecutionResult> {
|
||||
const prompt = buildActorPrompt(input.actorInput);
|
||||
const turn = await input.runtime.sessionContext.runInSession(() =>
|
||||
runClaudeTurn({
|
||||
runtime: input.runtime,
|
||||
actorInput: input.actorInput,
|
||||
prompt,
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = parseActorExecutionResultFromModelOutput({
|
||||
rawText: turn.text,
|
||||
structuredOutput: turn.structuredOutput,
|
||||
});
|
||||
|
||||
return ensureUsageMetadata({
|
||||
result: parsed,
|
||||
providerUsage: turn.usage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProviderRunRuntime(input: {
|
||||
provider: RunProvider;
|
||||
initialPrompt: string;
|
||||
config: Readonly<AppConfig>;
|
||||
}): Promise<ProviderRunRuntime> {
|
||||
const sessionContext = await createSessionContext(input.provider, {
|
||||
prompt: input.initialPrompt,
|
||||
config: input.config,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
config: input.config,
|
||||
sessionContext,
|
||||
close: async () => {
|
||||
await sessionContext.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createProviderActorExecutor(runtime: ProviderRunRuntime): ActorExecutor {
|
||||
return async (actorInput) => {
|
||||
try {
|
||||
if (runtime.provider === "codex") {
|
||||
return await runCodexActor({
|
||||
runtime,
|
||||
actorInput,
|
||||
});
|
||||
}
|
||||
|
||||
return await runClaudeActor({
|
||||
runtime,
|
||||
actorInput,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failure",
|
||||
payload: {
|
||||
error: toErrorMessage(error),
|
||||
},
|
||||
failureKind: "hard",
|
||||
failureCode: `provider_${runtime.provider}_execution_error`,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { SchemaDrivenExecutionEngine } from "../agents/orchestration.js";
|
||||
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
|
||||
import type {
|
||||
ActorExecutionResult,
|
||||
ActorExecutor,
|
||||
PipelineAggregateStatus,
|
||||
} from "../agents/pipeline.js";
|
||||
import { loadConfig, type AppConfig } from "../config.js";
|
||||
import { parseEnvFile } from "./env-store.js";
|
||||
import {
|
||||
createProviderActorExecutor,
|
||||
createProviderRunRuntime,
|
||||
type RunProvider,
|
||||
} from "./provider-executor.js";
|
||||
|
||||
const RUN_META_FILE_NAME = "ui-run-meta.json";
|
||||
|
||||
export type RunStatus = "running" | "success" | "failure" | "cancelled";
|
||||
export type RunExecutionMode = "mock" | "provider";
|
||||
|
||||
export type StartRunInput = {
|
||||
prompt: string;
|
||||
manifest: unknown;
|
||||
sessionId?: string;
|
||||
manifestPath?: string;
|
||||
topologyHint?: string;
|
||||
initialFlags?: Record<string, boolean>;
|
||||
runtimeContextOverrides?: Record<string, string | number | boolean>;
|
||||
simulateValidationNodeIds?: string[];
|
||||
executionMode?: RunExecutionMode;
|
||||
provider?: RunProvider;
|
||||
};
|
||||
|
||||
export type RunRecord = {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
status: RunStatus;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
manifestPath?: string;
|
||||
topologyHint?: string;
|
||||
executionMode: RunExecutionMode;
|
||||
provider: RunProvider;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function toRunStatus(status: PipelineAggregateStatus): Extract<RunStatus, "success" | "failure"> {
|
||||
return status === "success" ? "success" : "failure";
|
||||
}
|
||||
|
||||
type ActiveRun = {
|
||||
controller: AbortController;
|
||||
record: RunRecord;
|
||||
promise: Promise<void>;
|
||||
};
|
||||
|
||||
function toSessionId(): string {
|
||||
return `ui-session-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: readonly string[]): string[] {
|
||||
const output: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function waitWithSignal(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason ?? "Run aborted."));
|
||||
}
|
||||
|
||||
await new Promise<void>((resolveWait, rejectWait) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolveWait();
|
||||
}, ms);
|
||||
|
||||
function onAbort(): void {
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
rejectWait(
|
||||
signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason ?? "Run aborted.")),
|
||||
);
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function estimateUsage(prompt: string, toolCount: number): {
|
||||
tokenInput: number;
|
||||
tokenOutput: number;
|
||||
durationMs: number;
|
||||
costUsd: number;
|
||||
} {
|
||||
const tokenInput = Math.max(1, Math.ceil(prompt.length / 4));
|
||||
const tokenOutput = Math.max(16, Math.ceil(tokenInput * 0.7));
|
||||
const durationMs = 220 + (tokenInput % 11) * 35 + toolCount * 20;
|
||||
const tokenTotal = tokenInput + tokenOutput;
|
||||
const costUsd = Number((tokenTotal * 0.000002).toFixed(6));
|
||||
|
||||
return {
|
||||
tokenInput,
|
||||
tokenOutput,
|
||||
durationMs,
|
||||
costUsd,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSubtasks(prompt: string): string[] {
|
||||
const sentences = prompt
|
||||
.split(/[.!?\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.slice(0, 3);
|
||||
|
||||
if (sentences.length > 0) {
|
||||
return sentences;
|
||||
}
|
||||
|
||||
const words = prompt.split(/\s+/).filter((word) => word.length > 0).slice(0, 3);
|
||||
return words.length > 0 ? [words.join(" ")] : [];
|
||||
}
|
||||
|
||||
function createMockActorExecutors(
|
||||
manifest: AgentManifest,
|
||||
input: {
|
||||
prompt: string;
|
||||
topologyHint?: string;
|
||||
simulateValidationNodeIds: Set<string>;
|
||||
},
|
||||
): Record<string, ActorExecutor> {
|
||||
const attemptsByNode = new Map<string, number>();
|
||||
const uniqueActorIds = dedupeStrings(manifest.pipeline.nodes.map((node) => node.actorId));
|
||||
|
||||
const execute: ActorExecutor = async (actorInput) => {
|
||||
const attempt = (attemptsByNode.get(actorInput.node.id) ?? 0) + 1;
|
||||
attemptsByNode.set(actorInput.node.id, attempt);
|
||||
|
||||
const shouldValidationFail =
|
||||
attempt === 1 && input.simulateValidationNodeIds.has(actorInput.node.id);
|
||||
|
||||
const usage = estimateUsage(actorInput.prompt, actorInput.executionContext.allowedTools.length);
|
||||
await waitWithSignal(Math.min(usage.durationMs, 900), actorInput.signal);
|
||||
|
||||
if (shouldValidationFail) {
|
||||
const failure: ActorExecutionResult = {
|
||||
status: "validation_fail",
|
||||
payload: {
|
||||
summary: `Node ${actorInput.node.id} requires remediation on first pass.`,
|
||||
subtasks: extractSubtasks(input.prompt),
|
||||
security_violation: false,
|
||||
},
|
||||
stateMetadata: {
|
||||
usage: {
|
||||
...usage,
|
||||
tokenTotal: usage.tokenInput + usage.tokenOutput,
|
||||
toolCalls: actorInput.executionContext.allowedTools.length,
|
||||
},
|
||||
topologyHint: input.topologyHint ?? "manifest-default",
|
||||
},
|
||||
failureKind: "soft",
|
||||
failureCode: "ui_mock_validation_required",
|
||||
};
|
||||
|
||||
return failure;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
summary: `Node ${actorInput.node.id} completed in mock mode.`,
|
||||
prompt: input.prompt,
|
||||
subtasks: extractSubtasks(input.prompt),
|
||||
},
|
||||
stateMetadata: {
|
||||
usage: {
|
||||
...usage,
|
||||
tokenTotal: usage.tokenInput + usage.tokenOutput,
|
||||
toolCalls: actorInput.executionContext.allowedTools.length,
|
||||
},
|
||||
topologyHint: input.topologyHint ?? "manifest-default",
|
||||
},
|
||||
stateFlags: {
|
||||
[`${actorInput.node.id}_completed`]: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const executors: Record<string, ActorExecutor> = {};
|
||||
for (const actorId of uniqueActorIds) {
|
||||
executors[actorId] = execute;
|
||||
}
|
||||
return executors;
|
||||
}
|
||||
|
||||
function createSingleExecutorMap(manifest: AgentManifest, executor: ActorExecutor): Record<string, ActorExecutor> {
|
||||
const uniqueActorIds = dedupeStrings(manifest.pipeline.nodes.map((node) => node.actorId));
|
||||
const executors: Record<string, ActorExecutor> = {};
|
||||
for (const actorId of uniqueActorIds) {
|
||||
executors[actorId] = executor;
|
||||
}
|
||||
return executors;
|
||||
}
|
||||
|
||||
function toAbortErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isAbort(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return error.name === "AbortError" || error.message.toLowerCase().includes("abort");
|
||||
}
|
||||
|
||||
async function loadRuntimeConfig(envPath: string): Promise<Readonly<AppConfig>> {
|
||||
const parsed = await parseEnvFile(envPath);
|
||||
return loadConfig({
|
||||
...process.env,
|
||||
...parsed.values,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeRunMeta(input: {
|
||||
stateRoot: string;
|
||||
sessionId: string;
|
||||
run: RunRecord;
|
||||
}): Promise<void> {
|
||||
const sessionDirectory = resolve(input.stateRoot, input.sessionId);
|
||||
await mkdir(sessionDirectory, { recursive: true });
|
||||
const path = resolve(sessionDirectory, RUN_META_FILE_NAME);
|
||||
await writeFile(path, `${JSON.stringify(input.run, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function readRunMetaBySession(input: {
|
||||
stateRoot: string;
|
||||
sessionId: string;
|
||||
}): Promise<RunRecord | undefined> {
|
||||
const path = resolve(input.stateRoot, input.sessionId, RUN_META_FILE_NAME);
|
||||
|
||||
try {
|
||||
const content = await readFile(path, "utf8");
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = parsed as Partial<RunRecord>;
|
||||
if (
|
||||
typeof record.runId !== "string" ||
|
||||
typeof record.sessionId !== "string" ||
|
||||
typeof record.status !== "string" ||
|
||||
typeof record.startedAt !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: RunRecord = {
|
||||
runId: record.runId,
|
||||
sessionId: record.sessionId,
|
||||
status:
|
||||
record.status === "running" ||
|
||||
record.status === "success" ||
|
||||
record.status === "failure" ||
|
||||
record.status === "cancelled"
|
||||
? record.status
|
||||
: "failure",
|
||||
startedAt: record.startedAt,
|
||||
executionMode:
|
||||
record.executionMode === "provider" || record.executionMode === "mock"
|
||||
? record.executionMode
|
||||
: "mock",
|
||||
provider: record.provider === "claude" || record.provider === "codex" ? record.provider : "codex",
|
||||
...(typeof record.endedAt === "string" ? { endedAt: record.endedAt } : {}),
|
||||
...(typeof record.manifestPath === "string" ? { manifestPath: record.manifestPath } : {}),
|
||||
...(typeof record.topologyHint === "string" ? { topologyHint: record.topologyHint } : {}),
|
||||
...(typeof record.error === "string" ? { error: record.error } : {}),
|
||||
};
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class UiRunService {
|
||||
private readonly workspaceRoot: string;
|
||||
private readonly envFilePath: string;
|
||||
private readonly activeRuns = new Map<string, ActiveRun>();
|
||||
private readonly runHistory = new Map<string, RunRecord>();
|
||||
|
||||
constructor(input: {
|
||||
workspaceRoot: string;
|
||||
envFilePath?: string;
|
||||
}) {
|
||||
this.workspaceRoot = resolve(input.workspaceRoot);
|
||||
this.envFilePath = resolve(this.workspaceRoot, input.envFilePath ?? ".env");
|
||||
}
|
||||
|
||||
listRuns(): RunRecord[] {
|
||||
const output = [...this.runHistory.values()].sort((left, right) => {
|
||||
return right.startedAt.localeCompare(left.startedAt);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
getRun(runId: string): RunRecord | undefined {
|
||||
return this.runHistory.get(runId);
|
||||
}
|
||||
|
||||
async startRun(input: StartRunInput): Promise<RunRecord> {
|
||||
const config = await loadRuntimeConfig(this.envFilePath);
|
||||
const manifest = parseAgentManifest(input.manifest);
|
||||
const executionMode = input.executionMode ?? "mock";
|
||||
const provider = input.provider ?? "codex";
|
||||
const sessionId = input.sessionId?.trim() || toSessionId();
|
||||
const runId = randomUUID();
|
||||
const controller = new AbortController();
|
||||
|
||||
const record: RunRecord = {
|
||||
runId,
|
||||
sessionId,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
executionMode,
|
||||
provider,
|
||||
...(input.manifestPath ? { manifestPath: input.manifestPath } : {}),
|
||||
...(input.topologyHint ? { topologyHint: input.topologyHint } : {}),
|
||||
};
|
||||
this.runHistory.set(runId, record);
|
||||
|
||||
const runPromise = (async () => {
|
||||
let providerRuntime: Awaited<ReturnType<typeof createProviderRunRuntime>> | undefined;
|
||||
try {
|
||||
if (executionMode === "provider") {
|
||||
providerRuntime = await createProviderRunRuntime({
|
||||
provider,
|
||||
initialPrompt: input.prompt,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
const actorExecutors =
|
||||
executionMode === "provider" && providerRuntime
|
||||
? createSingleExecutorMap(manifest, createProviderActorExecutor(providerRuntime))
|
||||
: createMockActorExecutors(manifest, {
|
||||
prompt: input.prompt,
|
||||
topologyHint: input.topologyHint,
|
||||
simulateValidationNodeIds: new Set(input.simulateValidationNodeIds ?? []),
|
||||
});
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
actorExecutors,
|
||||
settings: {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
stateRoot: config.orchestration.stateRoot,
|
||||
projectContextPath: config.orchestration.projectContextPath,
|
||||
runtimeContext: {
|
||||
ui_mode: executionMode,
|
||||
run_provider: provider,
|
||||
...(input.runtimeContextOverrides ?? {}),
|
||||
},
|
||||
},
|
||||
config,
|
||||
});
|
||||
|
||||
await writeRunMeta({
|
||||
stateRoot: config.orchestration.stateRoot,
|
||||
sessionId,
|
||||
run: record,
|
||||
});
|
||||
|
||||
const summary = await engine.runSession({
|
||||
sessionId,
|
||||
initialPayload: {
|
||||
prompt: input.prompt,
|
||||
},
|
||||
initialState: {
|
||||
flags: {
|
||||
...(input.initialFlags ?? {}),
|
||||
},
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const completedRecord = this.runHistory.get(runId);
|
||||
if (!completedRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next: RunRecord = {
|
||||
...completedRecord,
|
||||
status: toRunStatus(summary.status),
|
||||
endedAt: new Date().toISOString(),
|
||||
};
|
||||
this.runHistory.set(runId, next);
|
||||
|
||||
await writeRunMeta({
|
||||
stateRoot: config.orchestration.stateRoot,
|
||||
sessionId,
|
||||
run: next,
|
||||
});
|
||||
} catch (error) {
|
||||
const current = this.runHistory.get(runId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cancelled = controller.signal.aborted || isAbort(error);
|
||||
const next: RunRecord = {
|
||||
...current,
|
||||
status: cancelled ? "cancelled" : "failure",
|
||||
endedAt: new Date().toISOString(),
|
||||
error: toAbortErrorMessage(error),
|
||||
};
|
||||
this.runHistory.set(runId, next);
|
||||
|
||||
await writeRunMeta({
|
||||
stateRoot: config.orchestration.stateRoot,
|
||||
sessionId,
|
||||
run: next,
|
||||
});
|
||||
} finally {
|
||||
if (providerRuntime) {
|
||||
await providerRuntime.close();
|
||||
}
|
||||
this.activeRuns.delete(runId);
|
||||
}
|
||||
})();
|
||||
|
||||
this.activeRuns.set(runId, {
|
||||
controller,
|
||||
record,
|
||||
promise: runPromise,
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async cancelRun(runId: string): Promise<RunRecord> {
|
||||
const active = this.activeRuns.get(runId);
|
||||
if (!active) {
|
||||
const existing = this.runHistory.get(runId);
|
||||
if (!existing) {
|
||||
throw new Error(`Run \"${runId}\" does not exist.`);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
active.controller.abort(new Error("Cancelled by operator from UI kill switch."));
|
||||
await active.promise;
|
||||
|
||||
const finalRecord = this.runHistory.get(runId);
|
||||
if (!finalRecord) {
|
||||
throw new Error(`Run \"${runId}\" cancellation did not produce a final record.`);
|
||||
}
|
||||
|
||||
return finalRecord;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { RuntimeEvent } from "../telemetry/runtime-events.js";
|
||||
|
||||
type RuntimeEventFilter = {
|
||||
sessionId?: string;
|
||||
types?: string[];
|
||||
severities?: Array<RuntimeEvent["severity"]>;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function safeParseLine(line: string): RuntimeEvent | undefined {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = parsed as Partial<RuntimeEvent>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.timestamp !== "string" ||
|
||||
typeof record.type !== "string" ||
|
||||
typeof record.severity !== "string" ||
|
||||
typeof record.message !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return record as RuntimeEvent;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRuntimeEvents(logPath: string): Promise<RuntimeEvent[]> {
|
||||
const absolutePath = resolve(logPath);
|
||||
let content = "";
|
||||
|
||||
try {
|
||||
content = await readFile(absolutePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsed: RuntimeEvent[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const event = safeParseLine(line);
|
||||
if (event) {
|
||||
parsed.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function filterRuntimeEvents(
|
||||
events: readonly RuntimeEvent[],
|
||||
filter: RuntimeEventFilter,
|
||||
): RuntimeEvent[] {
|
||||
const filtered: RuntimeEvent[] = [];
|
||||
const types = filter.types ? new Set(filter.types) : undefined;
|
||||
const severities = filter.severities ? new Set(filter.severities) : undefined;
|
||||
|
||||
for (const event of events) {
|
||||
if (filter.sessionId && event.sessionId !== filter.sessionId) {
|
||||
continue;
|
||||
}
|
||||
if (types && !types.has(event.type)) {
|
||||
continue;
|
||||
}
|
||||
if (severities && !severities.has(event.severity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push(event);
|
||||
}
|
||||
|
||||
if (!filter.limit || filter.limit < 1 || filtered.length <= filter.limit) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return filtered.slice(-filter.limit);
|
||||
}
|
||||
654
src/ui/server.ts
654
src/ui/server.ts
@@ -1,27 +1,14 @@
|
||||
import "dotenv/config";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { buildSessionGraphInsight, buildSessionSummaries } from "./session-insights.js";
|
||||
import { UiConfigStore, type LimitSettings, type RuntimeNotificationSettings, type SecurityPolicySettings } from "./config-store.js";
|
||||
import { ManifestStore } from "./manifest-store.js";
|
||||
import { filterRuntimeEvents, readRuntimeEvents } from "./runtime-events-store.js";
|
||||
import { parseJsonBody, sendJson, methodNotAllowed, notFound, serveStaticFile } from "./http-utils.js";
|
||||
import { readRunMetaBySession, UiRunService, type RunExecutionMode } from "./run-service.js";
|
||||
import type { RunProvider } from "./provider-executor.js";
|
||||
|
||||
type StartRunRequest = {
|
||||
prompt: string;
|
||||
manifestPath?: string;
|
||||
manifest?: unknown;
|
||||
sessionId?: string;
|
||||
topologyHint?: string;
|
||||
initialFlags?: Record<string, boolean>;
|
||||
runtimeContextOverrides?: Record<string, string | number | boolean>;
|
||||
simulateValidationNodeIds?: string[];
|
||||
executionMode?: RunExecutionMode;
|
||||
provider?: RunProvider;
|
||||
};
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { z } from "zod";
|
||||
import { buildSessionGraphInsight, buildSessionSummaries } from "../telemetry/session-insights.js";
|
||||
import { UiConfigStore, type LimitSettings, type RuntimeNotificationSettings, type SecurityPolicySettings } from "../store/config-store.js";
|
||||
import { ManifestStore } from "../agents/manifest-store.js";
|
||||
import { filterRuntimeEvents, readRuntimeEvents } from "../telemetry/runtime-events-store.js";
|
||||
import { readRunMetaBySession, UiRunService } from "../runs/run-service.js";
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
const parsed = Number(value ?? "4317");
|
||||
@@ -31,7 +18,7 @@ function parsePort(value: string | undefined): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseLimit(value: string | null, fallback: number): number {
|
||||
function parseLimit(value: string | null | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -42,66 +29,6 @@ function parseLimit(value: string | null, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toRelativePathFromApi(urlPath: string): string {
|
||||
return decodeURIComponent(urlPath);
|
||||
}
|
||||
|
||||
function ensureBooleanRecord(value: unknown): Record<string, boolean> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const output: Record<string, boolean> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof raw === "boolean") {
|
||||
output[key] = raw;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function ensureStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = item.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
output.push(normalized);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function ensureRuntimeContext(value: unknown): Record<string, string | number | boolean> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const output: Record<string, string | number | boolean> = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
|
||||
output[key] = raw;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function ensureExecutionMode(value: unknown): RunExecutionMode {
|
||||
return value === "provider" ? "provider" : "mock";
|
||||
}
|
||||
|
||||
function ensureProvider(value: unknown): RunProvider {
|
||||
return value === "claude" ? "claude" : "codex";
|
||||
}
|
||||
|
||||
async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{
|
||||
stateRoot: string;
|
||||
runtimeEventLogPath: string;
|
||||
@@ -113,237 +40,144 @@ async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: strin
|
||||
};
|
||||
}
|
||||
|
||||
async function handleApiRequest(input: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
const StartRunSchema = z.object({
|
||||
prompt: z.string().min(1, 'Field "prompt" is required.'),
|
||||
manifestPath: z.string().optional(),
|
||||
manifest: z.unknown().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
topologyHint: z.string().optional(),
|
||||
initialFlags: z.record(z.string(), z.boolean()).optional(),
|
||||
runtimeContextOverrides: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||
simulateValidationNodeIds: z.array(z.string()).optional(),
|
||||
executionMode: z.enum(["mock", "provider"]).optional(),
|
||||
provider: z.enum(["claude", "codex"]).optional(),
|
||||
});
|
||||
|
||||
export async function startUiServer(input: {
|
||||
workspaceRoot: string;
|
||||
configStore: UiConfigStore;
|
||||
manifestStore: ManifestStore;
|
||||
runService: UiRunService;
|
||||
}): Promise<boolean> {
|
||||
const { request, response, workspaceRoot, configStore, manifestStore, runService } = input;
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
||||
const { pathname } = requestUrl;
|
||||
const method = request.method ?? "GET";
|
||||
port?: number;
|
||||
host?: string;
|
||||
}): Promise<{
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const workspaceRoot = resolve(input.workspaceRoot);
|
||||
const staticRoot = resolve(workspaceRoot, "ui/dist");
|
||||
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
const configStore = new UiConfigStore({ workspaceRoot });
|
||||
const manifestStore = new ManifestStore({ workspaceRoot });
|
||||
const runService = new UiRunService({ workspaceRoot });
|
||||
|
||||
try {
|
||||
if (pathname === "/api/health") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
if (pathname === "/api/config") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ ok: true, now: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get("/api/config", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.readSnapshot();
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
config: snapshot,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (pathname === "/api/config/runtime-events") {
|
||||
if (method !== "PUT") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
app.put("/api/config/runtime-events", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateRuntimeEvents(req.body as RuntimeNotificationSettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
const body = await parseJsonBody<RuntimeNotificationSettings>(request);
|
||||
const snapshot = await configStore.updateRuntimeEvents(body);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
config: snapshot,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
app.put("/api/config/security", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateSecurityPolicy(req.body as SecurityPolicySettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (pathname === "/api/config/security") {
|
||||
if (method !== "PUT") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await parseJsonBody<SecurityPolicySettings>(request);
|
||||
const snapshot = await configStore.updateSecurityPolicy(body);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
config: snapshot,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/config/limits") {
|
||||
if (method !== "PUT") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await parseJsonBody<LimitSettings>(request);
|
||||
const snapshot = await configStore.updateLimits(body);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
config: snapshot,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/manifests") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
app.put("/api/config/limits", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateLimits(req.body as LimitSettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/manifests", async (req, res, next) => {
|
||||
try {
|
||||
const listing = await manifestStore.list();
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
manifests: listing.paths,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
res.json({ ok: true, manifests: listing.paths });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (pathname === "/api/manifests/read") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const manifestPath = requestUrl.searchParams.get("path");
|
||||
app.get("/api/manifests/read", async (req, res, next) => {
|
||||
try {
|
||||
const manifestPath = req.query.path as string;
|
||||
if (!manifestPath) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: 'Query parameter "path" is required.',
|
||||
});
|
||||
return true;
|
||||
res.status(400).json({ ok: false, error: 'Query parameter "path" is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await manifestStore.read(manifestPath);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
manifest: record,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
res.json({ ok: true, manifest: record });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (pathname === "/api/manifests/validate") {
|
||||
if (method !== "POST") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await parseJsonBody<{ manifest: unknown }>(request);
|
||||
const manifest = await manifestStore.validate(body.manifest);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
manifest,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/manifests/save") {
|
||||
if (method !== "PUT") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await parseJsonBody<{ path: string; manifest: unknown }>(request);
|
||||
if (!body.path || typeof body.path !== "string") {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: 'Field "path" is required.',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const record = await manifestStore.save(body.path, body.manifest);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
manifest: record,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/runtime-events") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
app.post("/api/manifests/validate", async (req, res, next) => {
|
||||
try {
|
||||
const manifest = await manifestStore.validate(req.body.manifest);
|
||||
res.json({ ok: true, manifest });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.put("/api/manifests/save", async (req, res, next) => {
|
||||
try {
|
||||
const { path, manifest } = req.body;
|
||||
if (!path || typeof path !== "string") {
|
||||
res.status(400).json({ ok: false, error: 'Field "path" is required.' });
|
||||
return;
|
||||
}
|
||||
const record = await manifestStore.save(path, manifest);
|
||||
res.json({ ok: true, manifest: record });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/runtime-events", async (req, res, next) => {
|
||||
try {
|
||||
const { runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||
const limit = parseLimit(requestUrl.searchParams.get("limit"), 200);
|
||||
const sessionId = requestUrl.searchParams.get("sessionId") ?? undefined;
|
||||
const limit = parseLimit(req.query.limit as string | undefined, 200);
|
||||
const sessionId = (req.query.sessionId as string) || undefined;
|
||||
const events = filterRuntimeEvents(await readRuntimeEvents(runtimeEventLogPath), {
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
limit,
|
||||
});
|
||||
res.json({ ok: true, events });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
events,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/sessions") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
app.get("/api/sessions", async (req, res, next) => {
|
||||
try {
|
||||
const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||
const sessions = await buildSessionSummaries({
|
||||
stateRoot,
|
||||
runtimeEventLogPath,
|
||||
});
|
||||
const sessions = await buildSessionSummaries({ stateRoot, runtimeEventLogPath });
|
||||
res.json({ ok: true, sessions, runs: runService.listRuns() });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
sessions,
|
||||
runs: runService.listRuns(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/sessions/graph") {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionId = requestUrl.searchParams.get("sessionId") ?? "";
|
||||
app.get("/api/sessions/graph", async (req, res, next) => {
|
||||
try {
|
||||
const sessionId = (req.query.sessionId as string) ?? "";
|
||||
if (!sessionId) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: 'Query parameter "sessionId" is required.',
|
||||
});
|
||||
return true;
|
||||
res.status(400).json({ ok: false, error: 'Query parameter "sessionId" is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||
const explicitManifestPath = requestUrl.searchParams.get("manifestPath");
|
||||
const explicitManifestPath = req.query.manifestPath as string | undefined;
|
||||
const runMeta = await readRunMetaBySession({ stateRoot, sessionId });
|
||||
const manifestPath = explicitManifestPath ?? runMeta?.manifestPath;
|
||||
|
||||
if (!manifestPath) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: "No manifestPath available for this session. Provide one in query string.",
|
||||
});
|
||||
return true;
|
||||
res.status(400).json({ ok: false, error: "No manifestPath available for this session. Provide one in query string." });
|
||||
return;
|
||||
}
|
||||
|
||||
const manifestRecord = await manifestStore.read(manifestPath);
|
||||
@@ -354,212 +188,126 @@ async function handleApiRequest(input: {
|
||||
manifest: manifestRecord.manifest,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
graph,
|
||||
manifestPath: manifestRecord.path,
|
||||
res.json({ ok: true, graph, manifestPath: manifestRecord.path });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/runs", (req, res) => {
|
||||
res.json({ ok: true, runs: runService.listRuns() });
|
||||
});
|
||||
|
||||
app.post("/api/runs", async (req, res, next) => {
|
||||
try {
|
||||
const parseResult = StartRunSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message || 'Invalid body' });
|
||||
return;
|
||||
}
|
||||
const body = parseResult.data;
|
||||
|
||||
let manifest: unknown;
|
||||
if (body.manifest !== undefined) {
|
||||
manifest = body.manifest;
|
||||
} else if (body.manifestPath) {
|
||||
manifest = (await manifestStore.read(body.manifestPath)).source;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
res.status(400).json({ ok: false, error: "A manifest or manifestPath is required to start a run." });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await runService.startRun({
|
||||
prompt: body.prompt,
|
||||
manifest,
|
||||
manifestPath: body.manifestPath,
|
||||
sessionId: body.sessionId,
|
||||
topologyHint: body.topologyHint,
|
||||
initialFlags: body.initialFlags ?? {},
|
||||
runtimeContextOverrides: body.runtimeContextOverrides ?? {},
|
||||
simulateValidationNodeIds: body.simulateValidationNodeIds ?? [],
|
||||
executionMode: body.executionMode ?? "mock",
|
||||
provider: body.provider ?? "codex",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/runs") {
|
||||
if (method === "GET") {
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
runs: runService.listRuns(),
|
||||
});
|
||||
return true;
|
||||
res.status(202).json({ ok: true, run: record });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post("/api/runs/:runId/cancel", async (req, res, next) => {
|
||||
try {
|
||||
const runId = req.params.runId;
|
||||
if (!runId) {
|
||||
res.status(400).json({ ok: false, error: "runId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const body = await parseJsonBody<StartRunRequest>(request);
|
||||
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: 'Field "prompt" is required.',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const manifestSource = (() => {
|
||||
if (body.manifest !== undefined) {
|
||||
return body.manifest;
|
||||
}
|
||||
if (typeof body.manifestPath === "string" && body.manifestPath.trim().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const resolvedManifest = manifestSource ?? (() => {
|
||||
if (!body.manifestPath) {
|
||||
return undefined;
|
||||
}
|
||||
return body.manifestPath;
|
||||
})();
|
||||
|
||||
let manifest: unknown;
|
||||
if (typeof resolvedManifest === "string") {
|
||||
manifest = (await manifestStore.read(resolvedManifest)).source;
|
||||
} else if (resolvedManifest !== undefined) {
|
||||
manifest = resolvedManifest;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: "A manifest or manifestPath is required to start a run.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const record = await runService.startRun({
|
||||
prompt: body.prompt,
|
||||
manifest,
|
||||
manifestPath: body.manifestPath,
|
||||
sessionId: body.sessionId,
|
||||
topologyHint: body.topologyHint,
|
||||
initialFlags: ensureBooleanRecord(body.initialFlags),
|
||||
runtimeContextOverrides: ensureRuntimeContext(body.runtimeContextOverrides),
|
||||
simulateValidationNodeIds: ensureStringArray(body.simulateValidationNodeIds),
|
||||
executionMode: ensureExecutionMode(body.executionMode),
|
||||
provider: ensureProvider(body.provider),
|
||||
});
|
||||
|
||||
sendJson(response, 202, {
|
||||
ok: true,
|
||||
run: record,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/runs/") && pathname.endsWith("/cancel")) {
|
||||
if (method !== "POST") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const runId = toRelativePathFromApi(pathname.slice("/api/runs/".length, -"/cancel".length));
|
||||
const run = await runService.cancelRun(runId);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
run,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
res.json({ ok: true, run });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
if (pathname.startsWith("/api/runs/")) {
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
app.get("/api/runs/:runId", async (req, res, next) => {
|
||||
try {
|
||||
const runId = req.params.runId;
|
||||
if (!runId) {
|
||||
res.status(400).json({ ok: false, error: "runId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = toRelativePathFromApi(pathname.slice("/api/runs/".length));
|
||||
const run = runService.getRun(runId);
|
||||
if (!run) {
|
||||
sendJson(response, 404, {
|
||||
ok: false,
|
||||
error: `Run \"${runId}\" was not found.`,
|
||||
});
|
||||
return true;
|
||||
res.status(404).json({ ok: false, error: `Run "${runId}" was not found.` });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, run });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
run,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
notFound(response);
|
||||
return true;
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
app.use("/api", (req, res) => {
|
||||
res.status(404).json({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: `API route "${req.method} ${req.originalUrl}" was not found.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function startUiServer(input: {
|
||||
workspaceRoot: string;
|
||||
port?: number;
|
||||
host?: string;
|
||||
}): Promise<{
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const workspaceRoot = resolve(input.workspaceRoot);
|
||||
const staticRoot = resolve(workspaceRoot, "src/ui/public");
|
||||
|
||||
const configStore = new UiConfigStore({ workspaceRoot });
|
||||
const manifestStore = new ManifestStore({ workspaceRoot });
|
||||
const runService = new UiRunService({ workspaceRoot });
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const handledApi = await handleApiRequest({
|
||||
request,
|
||||
response,
|
||||
workspaceRoot,
|
||||
configStore,
|
||||
manifestStore,
|
||||
runService,
|
||||
// Default error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
res.status(400).json({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
|
||||
if (handledApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
||||
const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
|
||||
const cleanPath = pathname.replace(/^\//, "");
|
||||
if (cleanPath.includes("..")) {
|
||||
notFound(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const staticPath = resolve(staticRoot, cleanPath);
|
||||
const served = await serveStaticFile({
|
||||
response,
|
||||
filePath: staticPath,
|
||||
});
|
||||
|
||||
if (served) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackServed = await serveStaticFile({
|
||||
response,
|
||||
filePath: resolve(staticRoot, "index.html"),
|
||||
});
|
||||
|
||||
if (!fallbackServed) {
|
||||
notFound(response);
|
||||
}
|
||||
// Serve static files
|
||||
app.use(express.static(staticRoot));
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.sendFile(resolve(staticRoot, "index.html"));
|
||||
});
|
||||
|
||||
const host = input.host ?? "127.0.0.1";
|
||||
const port = input.port ?? parsePort(process.env.AGENT_UI_PORT);
|
||||
|
||||
const server = app.listen(port, host);
|
||||
await new Promise<void>((resolveReady, rejectReady) => {
|
||||
server.once("error", rejectReady);
|
||||
server.listen(port, host, () => {
|
||||
server.once("listening", () => {
|
||||
server.off("error", rejectReady);
|
||||
console.log(`AI Ops UI listening at http://${host}:${String(port)}`);
|
||||
resolveReady();
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`AI Ops UI listening at http://${host}:${String(port)}`);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ERR_SERVER_NOT_RUNNING") {
|
||||
resolveClose();
|
||||
return;
|
||||
}
|
||||
rejectClose(error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { PipelineEdge, PipelineNode, RouteCondition } from "../agents/manifest.js";
|
||||
import type { AgentManifest } from "../agents/manifest.js";
|
||||
import { isRecord, type JsonObject } from "../agents/types.js";
|
||||
import type { RuntimeEvent, RuntimeEventUsage } from "../telemetry/runtime-events.js";
|
||||
import { filterRuntimeEvents, readRuntimeEvents } from "./runtime-events-store.js";
|
||||
|
||||
export type SessionStatus = "success" | "failure" | "running" | "unknown";
|
||||
|
||||
export type SessionSummary = {
|
||||
sessionId: string;
|
||||
status: SessionStatus;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
nodeAttemptCount: number;
|
||||
distinctNodeCount: number;
|
||||
costUsd: number;
|
||||
durationMs: number;
|
||||
criticalEventCount: number;
|
||||
message?: string;
|
||||
aborted: boolean;
|
||||
};
|
||||
|
||||
export type NodeAttemptInsight = {
|
||||
timestamp: string;
|
||||
attempt: number;
|
||||
status: "success" | "validation_fail" | "failure";
|
||||
severity: RuntimeEvent["severity"];
|
||||
message: string;
|
||||
usage: RuntimeEventUsage;
|
||||
metadata: JsonObject;
|
||||
executionContext?: JsonObject;
|
||||
retrySpawned: boolean;
|
||||
securityViolation: boolean;
|
||||
subtasks: string[];
|
||||
};
|
||||
|
||||
export type NodeInsight = {
|
||||
nodeId: string;
|
||||
actorId: string;
|
||||
personaId: string;
|
||||
topology: string;
|
||||
fromNodeId?: string;
|
||||
attemptCount: number;
|
||||
lastStatus?: "success" | "validation_fail" | "failure";
|
||||
usage: RuntimeEventUsage;
|
||||
subtaskCount: number;
|
||||
securityViolationCount: number;
|
||||
attempts: NodeAttemptInsight[];
|
||||
domainEvents: RuntimeEvent[];
|
||||
sandboxPayload?: JsonObject;
|
||||
};
|
||||
|
||||
export type EdgeInsight = {
|
||||
from: string;
|
||||
to: string;
|
||||
trigger: string;
|
||||
conditionLabels: string[];
|
||||
visited: boolean;
|
||||
critical: boolean;
|
||||
};
|
||||
|
||||
export type SessionGraphInsight = {
|
||||
sessionId: string;
|
||||
status: SessionStatus;
|
||||
aborted: boolean;
|
||||
abortMessage?: string;
|
||||
nodes: NodeInsight[];
|
||||
edges: EdgeInsight[];
|
||||
runtimeEvents: RuntimeEvent[];
|
||||
criticalPathNodeIds: string[];
|
||||
};
|
||||
|
||||
type BuildSessionSummaryInput = {
|
||||
stateRoot: string;
|
||||
runtimeEventLogPath: string;
|
||||
};
|
||||
|
||||
type BuildSessionGraphInput = {
|
||||
stateRoot: string;
|
||||
runtimeEventLogPath: string;
|
||||
sessionId: string;
|
||||
manifest: AgentManifest;
|
||||
};
|
||||
|
||||
function toUsage(value: RuntimeEventUsage | undefined): RuntimeEventUsage {
|
||||
return {
|
||||
...(typeof value?.tokenInput === "number" ? { tokenInput: value.tokenInput } : {}),
|
||||
...(typeof value?.tokenOutput === "number" ? { tokenOutput: value.tokenOutput } : {}),
|
||||
...(typeof value?.tokenTotal === "number" ? { tokenTotal: value.tokenTotal } : {}),
|
||||
...(typeof value?.toolCalls === "number" ? { toolCalls: value.toolCalls } : {}),
|
||||
...(typeof value?.durationMs === "number" ? { durationMs: value.durationMs } : {}),
|
||||
...(typeof value?.costUsd === "number" ? { costUsd: value.costUsd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function addUsage(target: RuntimeEventUsage, source: RuntimeEventUsage): RuntimeEventUsage {
|
||||
return {
|
||||
tokenInput: (target.tokenInput ?? 0) + (source.tokenInput ?? 0),
|
||||
tokenOutput: (target.tokenOutput ?? 0) + (source.tokenOutput ?? 0),
|
||||
tokenTotal: (target.tokenTotal ?? 0) + (source.tokenTotal ?? 0),
|
||||
toolCalls: (target.toolCalls ?? 0) + (source.toolCalls ?? 0),
|
||||
durationMs: (target.durationMs ?? 0) + (source.durationMs ?? 0),
|
||||
costUsd: (target.costUsd ?? 0) + (source.costUsd ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function toConditionLabel(condition: RouteCondition): string {
|
||||
if (condition.type === "always") {
|
||||
return "always";
|
||||
}
|
||||
if (condition.type === "state_flag") {
|
||||
return `state_flag:${condition.key}=${String(condition.equals)}`;
|
||||
}
|
||||
if (condition.type === "history_has_event") {
|
||||
return `history_has_event:${condition.event}`;
|
||||
}
|
||||
return `file_exists:${condition.path}`;
|
||||
}
|
||||
|
||||
function toEdgeTrigger(edge: PipelineEdge): string {
|
||||
if (edge.event) {
|
||||
return `event:${edge.event}`;
|
||||
}
|
||||
return `on:${edge.on ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function toStatusFromAttemptEvent(event: RuntimeEvent): "success" | "validation_fail" | "failure" {
|
||||
const metadata = event.metadata;
|
||||
if (isRecord(metadata)) {
|
||||
const status = metadata.status;
|
||||
if (status === "success" || status === "validation_fail" || status === "failure") {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.severity === "critical") {
|
||||
return "failure";
|
||||
}
|
||||
if (event.severity === "warning") {
|
||||
return "validation_fail";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function toSubtasks(metadata: JsonObject): string[] {
|
||||
const raw = metadata.subtasks;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subtasks: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = item.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
subtasks.push(normalized);
|
||||
}
|
||||
|
||||
return subtasks;
|
||||
}
|
||||
|
||||
function readBoolean(record: JsonObject, key: string): boolean {
|
||||
return record[key] === true;
|
||||
}
|
||||
|
||||
function readExecutionContext(metadata: JsonObject): JsonObject | undefined {
|
||||
const raw = metadata.executionContext;
|
||||
return isRecord(raw) ? (raw as JsonObject) : undefined;
|
||||
}
|
||||
|
||||
function summarizeStatusForSession(events: readonly RuntimeEvent[]): {
|
||||
status: SessionStatus;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
message?: string;
|
||||
aborted: boolean;
|
||||
} {
|
||||
const started = events.find((event) => event.type === "session.started");
|
||||
const completed = [...events].reverse().find((event) => event.type === "session.completed");
|
||||
const failed = [...events].reverse().find((event) => event.type === "session.failed");
|
||||
|
||||
if (failed) {
|
||||
const message = failed.message;
|
||||
const lower = message.toLowerCase();
|
||||
return {
|
||||
status: "failure",
|
||||
startedAt: started?.timestamp,
|
||||
endedAt: failed.timestamp,
|
||||
message,
|
||||
aborted: lower.includes("abort"),
|
||||
};
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
const metadata = isRecord(completed.metadata) ? completed.metadata : undefined;
|
||||
const completedStatus = metadata?.status;
|
||||
const status = completedStatus === "success" ? "success" : "failure";
|
||||
|
||||
return {
|
||||
status,
|
||||
startedAt: started?.timestamp,
|
||||
endedAt: completed.timestamp,
|
||||
message: completed.message,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (started) {
|
||||
return {
|
||||
status: "running",
|
||||
startedAt: started.timestamp,
|
||||
message: started.message,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "unknown",
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSessionDirectories(stateRoot: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(resolve(stateRoot), { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSessionSummaries(
|
||||
input: BuildSessionSummaryInput,
|
||||
): Promise<SessionSummary[]> {
|
||||
const [sessionDirectories, allEvents] = await Promise.all([
|
||||
listSessionDirectories(input.stateRoot),
|
||||
readRuntimeEvents(input.runtimeEventLogPath),
|
||||
]);
|
||||
|
||||
const sessionIds = new Set<string>(sessionDirectories);
|
||||
for (const event of allEvents) {
|
||||
if (event.sessionId) {
|
||||
sessionIds.add(event.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: SessionSummary[] = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const sessionEvents = filterRuntimeEvents(allEvents, {
|
||||
sessionId,
|
||||
});
|
||||
const attempts = sessionEvents.filter((event) => event.type === "node.attempt.completed");
|
||||
const distinctNodeIds = new Set(attempts.map((event) => event.nodeId).filter(Boolean));
|
||||
|
||||
const totalUsage = attempts.reduce<RuntimeEventUsage>(
|
||||
(aggregate, event) => addUsage(aggregate, toUsage(event.usage)),
|
||||
{},
|
||||
);
|
||||
|
||||
const criticalEventCount = sessionEvents.filter((event) => event.severity === "critical").length;
|
||||
const statusInfo = summarizeStatusForSession(sessionEvents);
|
||||
|
||||
summaries.push({
|
||||
sessionId,
|
||||
status: statusInfo.status,
|
||||
startedAt: statusInfo.startedAt,
|
||||
endedAt: statusInfo.endedAt,
|
||||
nodeAttemptCount: attempts.length,
|
||||
distinctNodeCount: distinctNodeIds.size,
|
||||
costUsd: totalUsage.costUsd ?? 0,
|
||||
durationMs: totalUsage.durationMs ?? 0,
|
||||
criticalEventCount,
|
||||
message: statusInfo.message,
|
||||
aborted: statusInfo.aborted,
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((left, right) => {
|
||||
const leftTime = left.startedAt ?? left.endedAt ?? "";
|
||||
const rightTime = right.startedAt ?? right.endedAt ?? "";
|
||||
return rightTime.localeCompare(leftTime);
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function readHandoffParentByNode(
|
||||
stateRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<Record<string, string | undefined>> {
|
||||
const handoffDirectory = resolve(stateRoot, sessionId, "handoffs");
|
||||
const output: Record<string, string | undefined> = {};
|
||||
|
||||
try {
|
||||
const entries = await readdir(handoffDirectory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = resolve(handoffDirectory, entry.name);
|
||||
const content = await readFile(path, "utf8");
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeId = parsed.nodeId;
|
||||
if (typeof nodeId !== "string" || !nodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromNodeId = parsed.fromNodeId;
|
||||
output[nodeId] = typeof fromNodeId === "string" && fromNodeId
|
||||
? fromNodeId
|
||||
: undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return output;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildCriticalPath(input: {
|
||||
manifest: AgentManifest;
|
||||
nodes: readonly NodeInsight[];
|
||||
fromNodeByNode: Record<string, string | undefined>;
|
||||
status: SessionStatus;
|
||||
}): string[] {
|
||||
const nodeById = new Map(input.nodes.map((node) => [node.nodeId, node]));
|
||||
const failed = [...input.nodes]
|
||||
.reverse()
|
||||
.find((node) => node.lastStatus === "failure" || node.lastStatus === "validation_fail");
|
||||
|
||||
const targetNodeId = failed?.nodeId ?? (() => {
|
||||
if (input.status !== "success") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const executedNodeIds = new Set(input.nodes.filter((node) => node.attemptCount > 0).map((node) => node.nodeId));
|
||||
const terminalNode = [...executedNodeIds].find((nodeId) => {
|
||||
const outgoing = input.manifest.pipeline.edges.filter((edge) => edge.from === nodeId);
|
||||
return !outgoing.some((edge) => executedNodeIds.has(edge.to));
|
||||
});
|
||||
return terminalNode;
|
||||
})();
|
||||
|
||||
if (!targetNodeId || !nodeById.has(targetNodeId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const path: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: string | undefined = targetNodeId;
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
path.push(current);
|
||||
current = input.fromNodeByNode[current];
|
||||
}
|
||||
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
function toCriticalEdgeSet(pathNodeIds: readonly string[]): Set<string> {
|
||||
const output = new Set<string>();
|
||||
for (let index = 1; index < pathNodeIds.length; index += 1) {
|
||||
const from = pathNodeIds[index - 1];
|
||||
const to = pathNodeIds[index];
|
||||
if (from && to) {
|
||||
output.add(`${from}->${to}`);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function toAttemptInsight(event: RuntimeEvent): NodeAttemptInsight {
|
||||
const metadata = isRecord(event.metadata) ? (event.metadata as JsonObject) : {};
|
||||
|
||||
return {
|
||||
timestamp: event.timestamp,
|
||||
attempt: typeof event.attempt === "number" ? event.attempt : 1,
|
||||
status: toStatusFromAttemptEvent(event),
|
||||
severity: event.severity,
|
||||
message: event.message,
|
||||
usage: toUsage(event.usage),
|
||||
metadata,
|
||||
executionContext: readExecutionContext(metadata),
|
||||
retrySpawned: readBoolean(metadata, "retrySpawned"),
|
||||
securityViolation: readBoolean(metadata, "securityViolation"),
|
||||
subtasks: toSubtasks(metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function inferTopology(node: PipelineNode): string {
|
||||
return node.topology?.kind ?? "sequential";
|
||||
}
|
||||
|
||||
export async function buildSessionGraphInsight(
|
||||
input: BuildSessionGraphInput,
|
||||
): Promise<SessionGraphInsight> {
|
||||
const [allEvents, handoffParentByNode] = await Promise.all([
|
||||
readRuntimeEvents(input.runtimeEventLogPath),
|
||||
readHandoffParentByNode(input.stateRoot, input.sessionId),
|
||||
]);
|
||||
|
||||
const sessionEvents = filterRuntimeEvents(allEvents, {
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
|
||||
const statusInfo = summarizeStatusForSession(sessionEvents);
|
||||
|
||||
const attemptsByNode = new Map<string, NodeAttemptInsight[]>();
|
||||
const domainEventsByNode = new Map<string, RuntimeEvent[]>();
|
||||
|
||||
for (const event of sessionEvents) {
|
||||
if (event.type === "node.attempt.completed" && event.nodeId) {
|
||||
const attempt = toAttemptInsight(event);
|
||||
const list = attemptsByNode.get(event.nodeId);
|
||||
if (list) {
|
||||
list.push(attempt);
|
||||
} else {
|
||||
attemptsByNode.set(event.nodeId, [attempt]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type.startsWith("domain.") && event.nodeId) {
|
||||
const list = domainEventsByNode.get(event.nodeId);
|
||||
if (list) {
|
||||
list.push(event);
|
||||
} else {
|
||||
domainEventsByNode.set(event.nodeId, [event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: NodeInsight[] = input.manifest.pipeline.nodes.map((node) => {
|
||||
const attempts = [...(attemptsByNode.get(node.id) ?? [])].sort((left, right) => {
|
||||
if (left.attempt !== right.attempt) {
|
||||
return left.attempt - right.attempt;
|
||||
}
|
||||
return left.timestamp.localeCompare(right.timestamp);
|
||||
});
|
||||
|
||||
const usage = attempts.reduce<RuntimeEventUsage>((aggregate, attempt) => {
|
||||
return addUsage(aggregate, attempt.usage);
|
||||
}, {});
|
||||
|
||||
const last = attempts[attempts.length - 1];
|
||||
const sandboxPayload = [...attempts]
|
||||
.reverse()
|
||||
.map((attempt) => attempt.executionContext)
|
||||
.find((payload) => Boolean(payload));
|
||||
|
||||
const subtasks = attempts.flatMap((attempt) => attempt.subtasks);
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
actorId: node.actorId,
|
||||
personaId: node.personaId,
|
||||
topology: inferTopology(node),
|
||||
fromNodeId: handoffParentByNode[node.id],
|
||||
attemptCount: attempts.length,
|
||||
...(last ? { lastStatus: last.status } : {}),
|
||||
usage,
|
||||
subtaskCount: subtasks.length,
|
||||
securityViolationCount: attempts.filter((attempt) => attempt.securityViolation).length,
|
||||
attempts,
|
||||
domainEvents: [...(domainEventsByNode.get(node.id) ?? [])],
|
||||
...(sandboxPayload ? { sandboxPayload } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const criticalPathNodeIds = buildCriticalPath({
|
||||
manifest: input.manifest,
|
||||
nodes,
|
||||
fromNodeByNode: handoffParentByNode,
|
||||
status: statusInfo.status,
|
||||
});
|
||||
const criticalEdgeSet = toCriticalEdgeSet(criticalPathNodeIds);
|
||||
|
||||
const edges: EdgeInsight[] = input.manifest.pipeline.edges.map((edge) => {
|
||||
const visited = handoffParentByNode[edge.to] === edge.from;
|
||||
|
||||
return {
|
||||
from: edge.from,
|
||||
to: edge.to,
|
||||
trigger: toEdgeTrigger(edge),
|
||||
conditionLabels: (edge.when ?? []).map(toConditionLabel),
|
||||
visited,
|
||||
critical: criticalEdgeSet.has(`${edge.from}->${edge.to}`),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: input.sessionId,
|
||||
status: statusInfo.status,
|
||||
aborted: statusInfo.aborted,
|
||||
...(statusInfo.message ? { abortMessage: statusInfo.message } : {}),
|
||||
nodes,
|
||||
edges,
|
||||
runtimeEvents: sessionEvents,
|
||||
criticalPathNodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSessionUpdatedAt(stateRoot: string, sessionId: string): Promise<string | undefined> {
|
||||
const sessionPath = resolve(stateRoot, sessionId);
|
||||
try {
|
||||
const metadata = await stat(sessionPath);
|
||||
return metadata.mtime.toISOString();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user