feat(ui): add operator UI server, stores, and insights
This commit is contained in:
@@ -57,6 +57,10 @@ AGENT_RUNTIME_DISCORD_WEBHOOK_URL=
|
|||||||
AGENT_RUNTIME_DISCORD_MIN_SEVERITY=critical
|
AGENT_RUNTIME_DISCORD_MIN_SEVERITY=critical
|
||||||
AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES=session.started,session.completed,session.failed
|
AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES=session.started,session.completed,session.failed
|
||||||
|
|
||||||
|
# Local operator UI
|
||||||
|
AGENT_UI_HOST=127.0.0.1
|
||||||
|
AGENT_UI_PORT=4317
|
||||||
|
|
||||||
# Runtime-injected (do not set manually):
|
# Runtime-injected (do not set manually):
|
||||||
# AGENT_REPO_ROOT, AGENT_WORKTREE_PATH, AGENT_WORKTREE_BASE_REF,
|
# AGENT_REPO_ROOT, AGENT_WORKTREE_PATH, AGENT_WORKTREE_BASE_REF,
|
||||||
# AGENT_PORT_RANGE_START, AGENT_PORT_RANGE_END, AGENT_PORT_PRIMARY, AGENT_DISCOVERY_FILE
|
# AGENT_PORT_RANGE_START, AGENT_PORT_RANGE_END, AGENT_PORT_PRIMARY, AGENT_DISCOVERY_FILE
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -42,6 +42,7 @@ TypeScript runtime for deterministic multi-agent execution with:
|
|||||||
- `src/mcp`: MCP config types/conversion/handlers
|
- `src/mcp`: MCP config types/conversion/handlers
|
||||||
- `src/security`: shell AST parsing, rules engine, secure executor, and audit sinks
|
- `src/security`: shell AST parsing, rules engine, secure executor, and audit sinks
|
||||||
- `src/telemetry`: runtime event schema, sink fan-out, file sink, and Discord webhook sink
|
- `src/telemetry`: runtime event schema, sink fan-out, file sink, and Discord webhook sink
|
||||||
|
- `src/ui`: local operator UI server, API routes, run-control service, and graph/event aggregation
|
||||||
- `src/examples`: provider entrypoints (`codex.ts`, `claude.ts`)
|
- `src/examples`: provider entrypoints (`codex.ts`, `claude.ts`)
|
||||||
- `src/config.ts`: centralized env parsing/validation/defaulting
|
- `src/config.ts`: centralized env parsing/validation/defaulting
|
||||||
- `tests`: manager, manifest, pipeline/orchestration, state, provisioning, MCP
|
- `tests`: manager, manifest, pipeline/orchestration, state, provisioning, MCP
|
||||||
@@ -68,6 +69,35 @@ npm run dev -- codex "List potential improvements."
|
|||||||
npm run dev -- claude "List potential improvements."
|
npm run dev -- claude "List potential improvements."
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Operator UI
|
||||||
|
|
||||||
|
Start the local UI server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:4317` (default)
|
||||||
|
|
||||||
|
The UI provides:
|
||||||
|
|
||||||
|
- graph visualizer with topology/retry rendering, edge trigger labels, node economics (duration/cost/tokens), and critical-path highlighting
|
||||||
|
- node inspector with attempt metadata and injected `ResolvedExecutionContext` sandbox payload
|
||||||
|
- live runtime event feed from `AGENT_RUNTIME_EVENT_LOG_PATH` with severity coloring (including security mirror events)
|
||||||
|
- run trigger + kill switch backed by `SchemaDrivenExecutionEngine.runSession(...)`
|
||||||
|
- run mode selector: `provider` (real Codex/Claude execution) or `mock` (deterministic dry-run executor)
|
||||||
|
- provider selector: `codex` or `claude`
|
||||||
|
- run history from `AGENT_STATE_ROOT`
|
||||||
|
- forms for runtime Discord webhook settings, security policy, and manager/resource limits
|
||||||
|
- manifest editor/validator/saver for schema `"1"` manifests
|
||||||
|
|
||||||
|
Provider mode notes:
|
||||||
|
|
||||||
|
- `provider=codex` uses existing OpenAI/Codex auth settings (`OPENAI_AUTH_MODE`, `CODEX_API_KEY`, `OPENAI_API_KEY`).
|
||||||
|
- `provider=claude` uses Claude auth resolution (`CLAUDE_CODE_OAUTH_TOKEN` preferred, otherwise `ANTHROPIC_API_KEY`, or existing Claude Code login state).
|
||||||
|
|
||||||
## Manifest Semantics
|
## Manifest Semantics
|
||||||
|
|
||||||
`AgentManifest` (schema `"1"`) validates:
|
`AgentManifest` (schema `"1"`) validates:
|
||||||
@@ -121,6 +151,11 @@ Each runtime event is written as one NDJSON object with:
|
|||||||
- `message`
|
- `message`
|
||||||
- optional `usage` (`tokenInput`, `tokenOutput`, `tokenTotal`, `toolCalls`, `durationMs`, `costUsd`)
|
- optional `usage` (`tokenInput`, `tokenOutput`, `tokenTotal`, `toolCalls`, `durationMs`, `costUsd`)
|
||||||
- optional structured `metadata`
|
- optional structured `metadata`
|
||||||
|
- `node.attempt.completed` metadata includes:
|
||||||
|
- `executionContext` (resolved sandbox payload injected into executor)
|
||||||
|
- `topologyKind`
|
||||||
|
- `retrySpawned`
|
||||||
|
- optional `fromNodeId`, `subtasks`, `securityViolation`
|
||||||
|
|
||||||
### Runtime Event Setup
|
### Runtime Event Setup
|
||||||
|
|
||||||
@@ -257,6 +292,11 @@ jq -c 'select(.severity=="critical")' .ai_ops/events/runtime-events.ndjson
|
|||||||
- `AGENT_RUNTIME_DISCORD_MIN_SEVERITY` (`info`, `warning`, or `critical`)
|
- `AGENT_RUNTIME_DISCORD_MIN_SEVERITY` (`info`, `warning`, or `critical`)
|
||||||
- `AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES` (CSV event types such as `session.started,session.completed,session.failed`)
|
- `AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES` (CSV event types such as `session.started,session.completed,session.failed`)
|
||||||
|
|
||||||
|
### Operator UI
|
||||||
|
|
||||||
|
- `AGENT_UI_HOST` (default `127.0.0.1`)
|
||||||
|
- `AGENT_UI_PORT` (default `4317`)
|
||||||
|
|
||||||
### Runtime-Injected (Do Not Configure In `.env`)
|
### Runtime-Injected (Do Not Configure In `.env`)
|
||||||
|
|
||||||
- `AGENT_REPO_ROOT`
|
- `AGENT_REPO_ROOT`
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ Core emitted event types:
|
|||||||
- `session.failed`
|
- `session.failed`
|
||||||
- `security.<security_audit_event_type>` (mirrored from security audit engine)
|
- `security.<security_audit_event_type>` (mirrored from security audit engine)
|
||||||
|
|
||||||
|
`node.attempt.completed` metadata includes orchestration-debug fields used by the operator UI:
|
||||||
|
|
||||||
|
- `status`, optional `failureKind`, optional `failureCode`
|
||||||
|
- `executionContext` (`phase`, `modelConstraint`, `allowedTools`, security constraints)
|
||||||
|
- `topologyKind`, `retrySpawned`, optional `fromNodeId`
|
||||||
|
- optional `subtasks`, `securityViolation`
|
||||||
|
|
||||||
## Sinks
|
## Sinks
|
||||||
|
|
||||||
- File sink (`AGENT_RUNTIME_EVENT_LOG_PATH`)
|
- File sink (`AGENT_RUNTIME_EVENT_LOG_PATH`)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test": "node --import tsx/esm --test tests/**/*.test.ts",
|
"test": "node --import tsx/esm --test tests/**/*.test.ts",
|
||||||
"verify": "npm run check && npm run check:tests && npm run test && npm run build",
|
"verify": "npm run check && npm run check:tests && npm run test && npm run build",
|
||||||
"dev": "node --import tsx/esm src/index.ts",
|
"dev": "node --import tsx/esm src/index.ts",
|
||||||
|
"ui": "node --import tsx/esm src/ui/server.ts",
|
||||||
"codex": "node --import tsx/esm src/examples/codex.ts",
|
"codex": "node --import tsx/esm src/examples/codex.ts",
|
||||||
"claude": "node --import tsx/esm src/examples/claude.ts",
|
"claude": "node --import tsx/esm src/examples/claude.ts",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
type DomainEventType,
|
type DomainEventType,
|
||||||
} from "./domain-events.js";
|
} from "./domain-events.js";
|
||||||
import type { PipelineNode } from "./manifest.js";
|
import type { NodeTopologyKind, PipelineNode } from "./manifest.js";
|
||||||
import { type ProjectContextPatch, type FileSystemProjectContextStore } from "./project-context.js";
|
import { type ProjectContextPatch, type FileSystemProjectContextStore } from "./project-context.js";
|
||||||
import { PersonaRegistry } from "./persona-registry.js";
|
import { PersonaRegistry } from "./persona-registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,10 @@ export type PipelineNodeAttemptObservedEvent = {
|
|||||||
attempt: number;
|
attempt: number;
|
||||||
result: ActorExecutionResult;
|
result: ActorExecutionResult;
|
||||||
domainEvents: DomainEvent[];
|
domainEvents: DomainEvent[];
|
||||||
|
executionContext: JsonObject;
|
||||||
|
fromNodeId?: string;
|
||||||
|
retrySpawned: boolean;
|
||||||
|
topologyKind: NodeTopologyKind;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toBehaviorEvent(status: ActorResultStatus): "onTaskComplete" | "onValidationFail" | undefined {
|
function toBehaviorEvent(status: ActorResultStatus): "onTaskComplete" | "onValidationFail" | undefined {
|
||||||
@@ -149,6 +153,41 @@ function extractUsageMetrics(result: ActorExecutionResult): RuntimeEventUsage |
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSubtasks(result: ActorExecutionResult): string[] {
|
||||||
|
const candidates = [
|
||||||
|
result.payload?.subtasks,
|
||||||
|
result.stateMetadata?.subtasks,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!Array.isArray(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtasks: string[] = [];
|
||||||
|
for (const item of candidate) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = item.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
subtasks.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtasks.length > 0) {
|
||||||
|
return subtasks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSecurityViolation(result: ActorExecutionResult): boolean {
|
||||||
|
return isRecord(result.payload) && result.payload.security_violation === true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipelineLifecycleObserver {
|
export interface PipelineLifecycleObserver {
|
||||||
onNodeAttempt(event: PipelineNodeAttemptObservedEvent): Promise<void>;
|
onNodeAttempt(event: PipelineNodeAttemptObservedEvent): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -213,6 +252,12 @@ export class PersistenceLifecycleObserver implements PipelineLifecycleObserver {
|
|||||||
status: event.result.status,
|
status: event.result.status,
|
||||||
...(event.result.failureKind ? { failureKind: event.result.failureKind } : {}),
|
...(event.result.failureKind ? { failureKind: event.result.failureKind } : {}),
|
||||||
...(event.result.failureCode ? { failureCode: event.result.failureCode } : {}),
|
...(event.result.failureCode ? { failureCode: event.result.failureCode } : {}),
|
||||||
|
executionContext: event.executionContext,
|
||||||
|
topologyKind: event.topologyKind,
|
||||||
|
retrySpawned: event.retrySpawned,
|
||||||
|
...(event.fromNodeId ? { fromNodeId: event.fromNodeId } : {}),
|
||||||
|
subtasks: extractSubtasks(event.result),
|
||||||
|
securityViolation: hasSecurityViolation(event.result),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
PersistenceLifecycleObserver,
|
PersistenceLifecycleObserver,
|
||||||
type PipelineLifecycleObserver,
|
type PipelineLifecycleObserver,
|
||||||
} from "./lifecycle-observer.js";
|
} from "./lifecycle-observer.js";
|
||||||
import type { AgentManifest, PipelineEdge, PipelineNode, RouteCondition } from "./manifest.js";
|
import type {
|
||||||
|
AgentManifest,
|
||||||
|
NodeTopologyKind,
|
||||||
|
PipelineEdge,
|
||||||
|
PipelineNode,
|
||||||
|
RouteCondition,
|
||||||
|
} from "./manifest.js";
|
||||||
import type { AgentManager, RecursiveChildIntent } from "./manager.js";
|
import type { AgentManager, RecursiveChildIntent } from "./manager.js";
|
||||||
import type {
|
import type {
|
||||||
CodexConfigObject,
|
CodexConfigObject,
|
||||||
@@ -183,6 +189,10 @@ type NodeAttemptResult = {
|
|||||||
payloadForNext: JsonObject;
|
payloadForNext: JsonObject;
|
||||||
domainEvents: DomainEvent[];
|
domainEvents: DomainEvent[];
|
||||||
hardFailure: boolean;
|
hardFailure: boolean;
|
||||||
|
executionContext: ResolvedExecutionContext;
|
||||||
|
fromNodeId?: string;
|
||||||
|
retrySpawned: boolean;
|
||||||
|
topologyKind: NodeTopologyKind;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NodeExecutionOutcome = {
|
type NodeExecutionOutcome = {
|
||||||
@@ -854,6 +864,12 @@ export class PipelineExecutor {
|
|||||||
attempt,
|
attempt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const toolClearance = this.personaRegistry.getToolClearance(node.personaId);
|
||||||
|
const executionContext = this.resolveExecutionContext({
|
||||||
|
node,
|
||||||
|
toolClearance,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await this.invokeActorExecutor({
|
const result = await this.invokeActorExecutor({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -861,6 +877,7 @@ export class PipelineExecutor {
|
|||||||
prompt,
|
prompt,
|
||||||
context,
|
context,
|
||||||
signal: recursiveSignal,
|
signal: recursiveSignal,
|
||||||
|
executionContext,
|
||||||
executor,
|
executor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -871,6 +888,12 @@ export class PipelineExecutor {
|
|||||||
status: result.status,
|
status: result.status,
|
||||||
customEvents: result.events,
|
customEvents: result.events,
|
||||||
});
|
});
|
||||||
|
const topologyKind: NodeTopologyKind = node.topology?.kind ?? "sequential";
|
||||||
|
const payloadForNext = result.payload ?? context.handoff.payload;
|
||||||
|
const shouldRetry =
|
||||||
|
result.status === "validation_fail" &&
|
||||||
|
this.shouldRetryValidation(node) &&
|
||||||
|
attempt <= maxRetriesForNode;
|
||||||
|
|
||||||
await this.lifecycleObserver.onNodeAttempt({
|
await this.lifecycleObserver.onNodeAttempt({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -878,6 +901,10 @@ export class PipelineExecutor {
|
|||||||
attempt,
|
attempt,
|
||||||
result,
|
result,
|
||||||
domainEvents,
|
domainEvents,
|
||||||
|
executionContext,
|
||||||
|
fromNodeId: context.handoff.fromNodeId,
|
||||||
|
retrySpawned: shouldRetry,
|
||||||
|
topologyKind,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emittedEventTypes = domainEvents.map((event) => event.type);
|
const emittedEventTypes = domainEvents.map((event) => event.type);
|
||||||
@@ -893,12 +920,6 @@ export class PipelineExecutor {
|
|||||||
const hardFailure = this.failurePolicy.isHardFailure(result);
|
const hardFailure = this.failurePolicy.isHardFailure(result);
|
||||||
hardFailureAttempts.push(hardFailure);
|
hardFailureAttempts.push(hardFailure);
|
||||||
|
|
||||||
const payloadForNext = result.payload ?? context.handoff.payload;
|
|
||||||
const shouldRetry =
|
|
||||||
result.status === "validation_fail" &&
|
|
||||||
this.shouldRetryValidation(node) &&
|
|
||||||
attempt <= maxRetriesForNode;
|
|
||||||
|
|
||||||
if (!shouldRetry) {
|
if (!shouldRetry) {
|
||||||
return {
|
return {
|
||||||
type: "complete" as const,
|
type: "complete" as const,
|
||||||
@@ -909,6 +930,10 @@ export class PipelineExecutor {
|
|||||||
payloadForNext,
|
payloadForNext,
|
||||||
domainEvents,
|
domainEvents,
|
||||||
hardFailure,
|
hardFailure,
|
||||||
|
executionContext,
|
||||||
|
fromNodeId: context.handoff.fromNodeId,
|
||||||
|
retrySpawned: false,
|
||||||
|
topologyKind,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -935,7 +960,10 @@ export class PipelineExecutor {
|
|||||||
if (!first) {
|
if (!first) {
|
||||||
throw new Error(`Retry aggregation for node "${node.id}" did not receive child output.`);
|
throw new Error(`Retry aggregation for node "${node.id}" did not receive child output.`);
|
||||||
}
|
}
|
||||||
return first.output;
|
return {
|
||||||
|
...first.output,
|
||||||
|
retrySpawned: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -965,16 +993,11 @@ export class PipelineExecutor {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
context: NodeExecutionContext;
|
context: NodeExecutionContext;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
executionContext: ResolvedExecutionContext;
|
||||||
executor: ActorExecutor;
|
executor: ActorExecutor;
|
||||||
}): Promise<ActorExecutionResult> {
|
}): Promise<ActorExecutionResult> {
|
||||||
try {
|
try {
|
||||||
throwIfAborted(input.signal);
|
throwIfAborted(input.signal);
|
||||||
const toolClearance = this.personaRegistry.getToolClearance(input.node.personaId);
|
|
||||||
const executionContext = this.resolveExecutionContext({
|
|
||||||
node: input.node,
|
|
||||||
toolClearance,
|
|
||||||
prompt: input.prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await input.executor({
|
return await input.executor({
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
@@ -982,8 +1005,8 @@ export class PipelineExecutor {
|
|||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
executionContext,
|
executionContext: input.executionContext,
|
||||||
mcp: this.buildActorMcpContext(executionContext, input.prompt),
|
mcp: this.buildActorMcpContext(input.executionContext, input.prompt),
|
||||||
security: this.securityContext,
|
security: this.securityContext,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
183
src/ui/config-store.ts
Normal file
183
src/ui/config-store.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/ui/env-store.ts
Normal file
130
src/ui/env-store.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
90
src/ui/http-utils.ts
Normal file
90
src/ui/http-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/ui/manifest-store.ts
Normal file
116
src/ui/manifest-store.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
595
src/ui/provider-executor.ts
Normal file
595
src/ui/provider-executor.ts
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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: 1,
|
||||||
|
...(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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
475
src/ui/run-service.ts
Normal file
475
src/ui/run-service.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
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 } 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "success",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/ui/runtime-events-store.ts
Normal file
94
src/ui/runtime-events-store.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
587
src/ui/server.ts
Normal file
587
src/ui/server.ts
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePort(value: string | undefined): number {
|
||||||
|
const parsed = Number(value ?? "4317");
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
throw new Error("UI server port must be an integer between 1 and 65535.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLimit(value: string | null, fallback: number): number {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}> {
|
||||||
|
const snapshot = await configStore.readSnapshot();
|
||||||
|
return {
|
||||||
|
stateRoot: resolve(workspaceRoot, snapshot.paths.stateRoot),
|
||||||
|
runtimeEventLogPath: resolve(workspaceRoot, snapshot.paths.runtimeEventLogPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiRequest(input: {
|
||||||
|
request: IncomingMessage;
|
||||||
|
response: ServerResponse;
|
||||||
|
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";
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/api/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pathname === "/api/health") {
|
||||||
|
if (method !== "GET") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/config") {
|
||||||
|
if (method !== "GET") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await configStore.readSnapshot();
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
config: snapshot,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/config/runtime-events") {
|
||||||
|
if (method !== "PUT") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await parseJsonBody<RuntimeNotificationSettings>(request);
|
||||||
|
const snapshot = await configStore.updateRuntimeEvents(body);
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
config: snapshot,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listing = await manifestStore.list();
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
manifests: listing.paths,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/manifests/read") {
|
||||||
|
if (method !== "GET") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = requestUrl.searchParams.get("path");
|
||||||
|
if (!manifestPath) {
|
||||||
|
sendJson(response, 400, {
|
||||||
|
ok: false,
|
||||||
|
error: 'Query parameter "path" is required.',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await manifestStore.read(manifestPath);
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
manifest: record,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||||
|
const limit = parseLimit(requestUrl.searchParams.get("limit"), 200);
|
||||||
|
const sessionId = requestUrl.searchParams.get("sessionId") ?? undefined;
|
||||||
|
const events = filterRuntimeEvents(await readRuntimeEvents(runtimeEventLogPath), {
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/sessions") {
|
||||||
|
if (method !== "GET") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||||
|
const sessions = await buildSessionSummaries({
|
||||||
|
stateRoot,
|
||||||
|
runtimeEventLogPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
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") ?? "";
|
||||||
|
if (!sessionId) {
|
||||||
|
sendJson(response, 400, {
|
||||||
|
ok: false,
|
||||||
|
error: 'Query parameter "sessionId" is required.',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||||
|
const explicitManifestPath = requestUrl.searchParams.get("manifestPath");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestRecord = await manifestStore.read(manifestPath);
|
||||||
|
const graph = await buildSessionGraphInsight({
|
||||||
|
stateRoot,
|
||||||
|
runtimeEventLogPath,
|
||||||
|
sessionId,
|
||||||
|
manifest: manifestRecord.manifest,
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
graph,
|
||||||
|
manifestPath: manifestRecord.path,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/runs") {
|
||||||
|
if (method === "GET") {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
runs: runService.listRuns(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/runs/")) {
|
||||||
|
if (method !== "GET") {
|
||||||
|
methodNotAllowed(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound(response);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 400, {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = input.host ?? "127.0.0.1";
|
||||||
|
const port = input.port ?? parsePort(process.env.AGENT_UI_PORT);
|
||||||
|
|
||||||
|
await new Promise<void>((resolveReady, rejectReady) => {
|
||||||
|
server.once("error", rejectReady);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
server.off("error", rejectReady);
|
||||||
|
resolveReady();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`AI Ops UI listening at http://${host}:${String(port)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolveClose, rejectClose) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
rejectClose(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolveClose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const port = parsePort(process.env.AGENT_UI_PORT);
|
||||||
|
await startUiServer({
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
port,
|
||||||
|
host: process.env.AGENT_UI_HOST ?? "127.0.0.1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
537
src/ui/session-insights.ts
Normal file
537
src/ui/session-insights.ts
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tests/env-store.test.ts
Normal file
35
tests/env-store.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { parseEnvFile, writeEnvFileUpdates } from "../src/ui/env-store.js";
|
||||||
|
|
||||||
|
test("parseEnvFile handles missing files", async () => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-env-store-"));
|
||||||
|
const envPath = resolve(root, ".env");
|
||||||
|
|
||||||
|
const parsed = await parseEnvFile(envPath);
|
||||||
|
assert.deepEqual(parsed.values, {});
|
||||||
|
assert.deepEqual(parsed.lines, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writeEnvFileUpdates merges and appends keys", async () => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-env-store-"));
|
||||||
|
const envPath = resolve(root, ".env");
|
||||||
|
|
||||||
|
await writeFile(envPath, "FOO=bar\nAGENT_MAX_CONCURRENT=4\n", "utf8");
|
||||||
|
|
||||||
|
const updated = await writeEnvFileUpdates(envPath, {
|
||||||
|
AGENT_MAX_CONCURRENT: "9",
|
||||||
|
AGENT_RUNTIME_DISCORD_MIN_SEVERITY: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updated.values.FOO, "bar");
|
||||||
|
assert.equal(updated.values.AGENT_MAX_CONCURRENT, "9");
|
||||||
|
assert.equal(updated.values.AGENT_RUNTIME_DISCORD_MIN_SEVERITY, "warning");
|
||||||
|
|
||||||
|
const rendered = await readFile(envPath, "utf8");
|
||||||
|
assert.match(rendered, /AGENT_MAX_CONCURRENT=9/);
|
||||||
|
assert.match(rendered, /AGENT_RUNTIME_DISCORD_MIN_SEVERITY=warning/);
|
||||||
|
});
|
||||||
66
tests/provider-executor.test.ts
Normal file
66
tests/provider-executor.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseActorExecutionResultFromModelOutput } from "../src/ui/provider-executor.js";
|
||||||
|
|
||||||
|
test("parseActorExecutionResultFromModelOutput parses strict JSON payload", () => {
|
||||||
|
const parsed = parseActorExecutionResultFromModelOutput({
|
||||||
|
rawText: JSON.stringify({
|
||||||
|
status: "validation_fail",
|
||||||
|
payload: {
|
||||||
|
summary: "missing test",
|
||||||
|
},
|
||||||
|
stateFlags: {
|
||||||
|
needs_fix: true,
|
||||||
|
},
|
||||||
|
stateMetadata: {
|
||||||
|
stage: "qa",
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "validation_failed",
|
||||||
|
payload: {
|
||||||
|
summary: "failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureKind: "soft",
|
||||||
|
failureCode: "missing_test",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.status, "validation_fail");
|
||||||
|
assert.equal(parsed.payload?.summary, "missing test");
|
||||||
|
assert.equal(parsed.stateFlags?.needs_fix, true);
|
||||||
|
assert.equal(parsed.stateMetadata?.stage, "qa");
|
||||||
|
assert.equal(parsed.events?.[0]?.type, "validation_failed");
|
||||||
|
assert.equal(parsed.failureKind, "soft");
|
||||||
|
assert.equal(parsed.failureCode, "missing_test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseActorExecutionResultFromModelOutput parses fenced JSON", () => {
|
||||||
|
const parsed = parseActorExecutionResultFromModelOutput({
|
||||||
|
rawText: [
|
||||||
|
"Here is the result",
|
||||||
|
"```json",
|
||||||
|
JSON.stringify({
|
||||||
|
status: "success",
|
||||||
|
payload: {
|
||||||
|
code: "done",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"```",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.status, "success");
|
||||||
|
assert.equal(parsed.payload?.code, "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseActorExecutionResultFromModelOutput falls back when response is not JSON", () => {
|
||||||
|
const parsed = parseActorExecutionResultFromModelOutput({
|
||||||
|
rawText: "Implemented update successfully.",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.status, "success");
|
||||||
|
assert.equal(parsed.payload?.assistantResponse, "Implemented update successfully.");
|
||||||
|
});
|
||||||
207
tests/session-insights.test.ts
Normal file
207
tests/session-insights.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { buildSessionGraphInsight, buildSessionSummaries } from "../src/ui/session-insights.js";
|
||||||
|
import { parseAgentManifest } from "../src/agents/manifest.js";
|
||||||
|
|
||||||
|
function createManifest() {
|
||||||
|
return parseAgentManifest({
|
||||||
|
schemaVersion: "1",
|
||||||
|
topologies: ["sequential", "retry-unrolled"],
|
||||||
|
personas: [
|
||||||
|
{
|
||||||
|
id: "planner",
|
||||||
|
displayName: "Planner",
|
||||||
|
systemPromptTemplate: "Plan",
|
||||||
|
toolClearance: {
|
||||||
|
allowlist: ["read_file"],
|
||||||
|
banlist: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
topologyConstraints: {
|
||||||
|
maxDepth: 3,
|
||||||
|
maxRetries: 2,
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
entryNodeId: "n1",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "n1",
|
||||||
|
actorId: "a1",
|
||||||
|
personaId: "planner",
|
||||||
|
topology: { kind: "sequential" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n2",
|
||||||
|
actorId: "a2",
|
||||||
|
personaId: "planner",
|
||||||
|
topology: { kind: "retry-unrolled" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
from: "n1",
|
||||||
|
to: "n2",
|
||||||
|
event: "validation_failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("buildSessionGraphInsight maps attempts, edge visits, and sandbox payload", async () => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-insights-"));
|
||||||
|
const stateRoot = resolve(root, "state");
|
||||||
|
const sessionId = "session-1";
|
||||||
|
const handoffDir = resolve(stateRoot, sessionId, "handoffs");
|
||||||
|
const runtimeLogPath = resolve(root, "runtime-events.ndjson");
|
||||||
|
|
||||||
|
await mkdir(handoffDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
resolve(handoffDir, "n2.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
nodeId: "n2",
|
||||||
|
fromNodeId: "n1",
|
||||||
|
payload: {},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
type: "session.started",
|
||||||
|
severity: "info",
|
||||||
|
message: "started",
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
timestamp: "2026-01-01T00:00:01.000Z",
|
||||||
|
type: "node.attempt.completed",
|
||||||
|
severity: "info",
|
||||||
|
message: "n1 success",
|
||||||
|
sessionId,
|
||||||
|
nodeId: "n1",
|
||||||
|
attempt: 1,
|
||||||
|
usage: { durationMs: 100, costUsd: 0.001 },
|
||||||
|
metadata: {
|
||||||
|
status: "success",
|
||||||
|
executionContext: { phase: "n1", allowedTools: ["read_file"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
timestamp: "2026-01-01T00:00:02.000Z",
|
||||||
|
type: "node.attempt.completed",
|
||||||
|
severity: "warning",
|
||||||
|
message: "n2 validation",
|
||||||
|
sessionId,
|
||||||
|
nodeId: "n2",
|
||||||
|
attempt: 1,
|
||||||
|
usage: { durationMs: 140, costUsd: 0.002 },
|
||||||
|
metadata: {
|
||||||
|
status: "validation_fail",
|
||||||
|
retrySpawned: true,
|
||||||
|
subtasks: ["fix tests"],
|
||||||
|
executionContext: { phase: "n2", allowedTools: ["read_file"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
timestamp: "2026-01-01T00:00:03.000Z",
|
||||||
|
type: "node.attempt.completed",
|
||||||
|
severity: "info",
|
||||||
|
message: "n2 success",
|
||||||
|
sessionId,
|
||||||
|
nodeId: "n2",
|
||||||
|
attempt: 2,
|
||||||
|
usage: { durationMs: 120, costUsd: 0.0025 },
|
||||||
|
metadata: {
|
||||||
|
status: "success",
|
||||||
|
executionContext: { phase: "n2", allowedTools: ["read_file"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
timestamp: "2026-01-01T00:00:04.000Z",
|
||||||
|
type: "session.completed",
|
||||||
|
severity: "info",
|
||||||
|
message: "completed",
|
||||||
|
sessionId,
|
||||||
|
metadata: {
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await writeFile(runtimeLogPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
|
||||||
|
|
||||||
|
const manifest = createManifest();
|
||||||
|
const graph = await buildSessionGraphInsight({
|
||||||
|
stateRoot,
|
||||||
|
runtimeEventLogPath: runtimeLogPath,
|
||||||
|
sessionId,
|
||||||
|
manifest,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(graph.status, "success");
|
||||||
|
assert.equal(graph.nodes.length, 2);
|
||||||
|
|
||||||
|
const node2 = graph.nodes.find((node) => node.nodeId === "n2");
|
||||||
|
assert.ok(node2);
|
||||||
|
assert.equal(node2.attemptCount, 2);
|
||||||
|
assert.equal(node2.subtaskCount, 1);
|
||||||
|
assert.equal(node2.sandboxPayload?.phase, "n2");
|
||||||
|
|
||||||
|
const edge = graph.edges.find((entry) => entry.from === "n1" && entry.to === "n2");
|
||||||
|
assert.ok(edge);
|
||||||
|
assert.equal(edge.visited, true);
|
||||||
|
assert.equal(edge.trigger, "event:validation_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildSessionSummaries reflects aborted failed session", async () => {
|
||||||
|
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-insights-"));
|
||||||
|
const stateRoot = resolve(root, "state");
|
||||||
|
const sessionId = "session-abort";
|
||||||
|
const runtimeLogPath = resolve(root, "runtime-events.ndjson");
|
||||||
|
|
||||||
|
await mkdir(resolve(stateRoot, sessionId), { recursive: true });
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
type: "session.started",
|
||||||
|
severity: "info",
|
||||||
|
message: "started",
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
timestamp: "2026-01-01T00:00:01.000Z",
|
||||||
|
type: "session.failed",
|
||||||
|
severity: "critical",
|
||||||
|
message: "Pipeline aborted after hard failures.",
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await writeFile(runtimeLogPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8");
|
||||||
|
|
||||||
|
const sessions = await buildSessionSummaries({
|
||||||
|
stateRoot,
|
||||||
|
runtimeEventLogPath: runtimeLogPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sessions.length, 1);
|
||||||
|
assert.equal(sessions[0]?.status, "failure");
|
||||||
|
assert.equal(sessions[0]?.aborted, true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user