Merge origin/main with local UI refactor integration
This commit is contained in:
821
src/ui/claude-observability.ts
Normal file
821
src/ui/claude-observability.ts
Normal file
@@ -0,0 +1,821 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
ClaudeObservabilityMode,
|
||||
ClaudeObservabilityRuntimeConfig,
|
||||
ClaudeObservabilityVerbosity,
|
||||
} from "../config.js";
|
||||
import type { JsonObject, JsonValue } from "../agents/types.js";
|
||||
|
||||
const MAX_STRING_LENGTH = 320;
|
||||
const MAX_ARRAY_ITEMS = 20;
|
||||
const MAX_OBJECT_KEYS = 60;
|
||||
const MAX_DEPTH = 6;
|
||||
|
||||
const NON_SECRET_TOKEN_KEYS = new Set([
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"ephemeral_1h_input_tokens",
|
||||
"ephemeral_5m_input_tokens",
|
||||
"token_input",
|
||||
"token_output",
|
||||
"token_total",
|
||||
"tokencount",
|
||||
"token_count",
|
||||
"tool_use_id",
|
||||
"parent_tool_use_id",
|
||||
"task_id",
|
||||
"session_id",
|
||||
]);
|
||||
|
||||
type ClaudeTraceContext = {
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
attempt: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
type ClaudeTraceRecord = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: "claude_sdk";
|
||||
stage:
|
||||
| "query.started"
|
||||
| "query.message"
|
||||
| "query.stderr"
|
||||
| "query.completed"
|
||||
| "query.error";
|
||||
message: string;
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
attempt: number;
|
||||
depth: number;
|
||||
sdkSessionId?: string;
|
||||
sdkMessageType?: string;
|
||||
sdkMessageSubtype?: string;
|
||||
data?: JsonObject;
|
||||
};
|
||||
|
||||
function truncate(value: string, maxLength = MAX_STRING_LENGTH): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (NON_SECRET_TOKEN_KEYS.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/(api[_-]?key|secret|password|authorization|cookie)/i.test(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/(auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|oauth)/i.test(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalized === "token";
|
||||
}
|
||||
|
||||
function toJsonPrimitive(value: unknown): JsonValue {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return truncate(value);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : String(value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return truncate(String(value));
|
||||
}
|
||||
|
||||
function sanitizeJsonValue(value: unknown, depth = 0): JsonValue {
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return "[depth_limit]";
|
||||
}
|
||||
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint" ||
|
||||
typeof value === "undefined"
|
||||
) {
|
||||
return toJsonPrimitive(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const output = value.slice(0, MAX_ARRAY_ITEMS).map((entry) => sanitizeJsonValue(entry, depth + 1));
|
||||
if (value.length > MAX_ARRAY_ITEMS) {
|
||||
output.push(`[+${String(value.length - MAX_ARRAY_ITEMS)} more]`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const output: JsonObject = {};
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const limited = entries.slice(0, MAX_OBJECT_KEYS);
|
||||
for (const [key, entryValue] of limited) {
|
||||
if (isSensitiveKey(key)) {
|
||||
output[key] = "[redacted]";
|
||||
continue;
|
||||
}
|
||||
output[key] = sanitizeJsonValue(entryValue, depth + 1);
|
||||
}
|
||||
if (entries.length > MAX_OBJECT_KEYS) {
|
||||
output.__truncated_keys = entries.length - MAX_OBJECT_KEYS;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return truncate(String(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function toMessageRecord(message: SDKMessage): Record<string, unknown> {
|
||||
return message as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toMessageSubtype(message: SDKMessage): string | undefined {
|
||||
return readString(toMessageRecord(message).subtype);
|
||||
}
|
||||
|
||||
function toMessageSessionId(message: SDKMessage): string | undefined {
|
||||
return readString(toMessageRecord(message).session_id);
|
||||
}
|
||||
|
||||
function toTaskNotificationSummary(message: SDKMessage): {
|
||||
summary: string;
|
||||
data?: JsonObject;
|
||||
} {
|
||||
const raw = toMessageRecord(message);
|
||||
const status = readString(raw.status) ?? "unknown";
|
||||
const data: JsonObject = {
|
||||
status,
|
||||
};
|
||||
|
||||
const taskId = readString(raw.task_id);
|
||||
if (taskId) {
|
||||
data.taskId = taskId;
|
||||
}
|
||||
|
||||
const summaryText = readString(raw.summary);
|
||||
if (summaryText) {
|
||||
data.summary = truncate(summaryText);
|
||||
}
|
||||
|
||||
const outputFile = readString(raw.output_file);
|
||||
if (outputFile) {
|
||||
data.outputFile = outputFile;
|
||||
}
|
||||
|
||||
if (raw.usage !== undefined) {
|
||||
data.usage = sanitizeJsonValue(raw.usage);
|
||||
}
|
||||
|
||||
return {
|
||||
summary: `Task notification: ${status}.`,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function toTaskStartedSummary(message: SDKMessage): {
|
||||
summary: string;
|
||||
data?: JsonObject;
|
||||
} {
|
||||
const raw = toMessageRecord(message);
|
||||
const data: JsonObject = {};
|
||||
|
||||
const taskId = readString(raw.task_id);
|
||||
if (taskId) {
|
||||
data.taskId = taskId;
|
||||
}
|
||||
|
||||
const description = readString(raw.description);
|
||||
if (description) {
|
||||
data.description = truncate(description);
|
||||
}
|
||||
|
||||
const taskType = readString(raw.task_type);
|
||||
if (taskType) {
|
||||
data.taskType = taskType;
|
||||
}
|
||||
|
||||
const toolUseId = readString(raw.tool_use_id);
|
||||
if (toolUseId) {
|
||||
data.toolUseId = toolUseId;
|
||||
}
|
||||
|
||||
return {
|
||||
summary: "Task started.",
|
||||
...(Object.keys(data).length > 0 ? { data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageSummary(message: SDKMessage): {
|
||||
summary: string;
|
||||
data?: JsonObject;
|
||||
} {
|
||||
const subtype = toMessageSubtype(message);
|
||||
const raw = toMessageRecord(message);
|
||||
|
||||
if (message.type === "result") {
|
||||
if (message.subtype === "success") {
|
||||
return {
|
||||
summary: "Claude query result success.",
|
||||
data: {
|
||||
stopReason: message.stop_reason ?? null,
|
||||
numTurns: message.num_turns,
|
||||
usage: sanitizeJsonValue(message.usage) as JsonObject,
|
||||
totalCostUsd: message.total_cost_usd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: `Claude query result ${message.subtype}.`,
|
||||
data: {
|
||||
stopReason: message.stop_reason ?? null,
|
||||
numTurns: message.num_turns,
|
||||
usage: sanitizeJsonValue(message.usage) as JsonObject,
|
||||
totalCostUsd: message.total_cost_usd,
|
||||
errors: sanitizeJsonValue(message.errors),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "tool_progress") {
|
||||
return {
|
||||
summary: `Tool progress: ${message.tool_name}.`,
|
||||
data: {
|
||||
toolName: message.tool_name,
|
||||
toolUseId: message.tool_use_id,
|
||||
elapsedTimeSeconds: message.elapsed_time_seconds,
|
||||
parentToolUseId: message.parent_tool_use_id ?? null,
|
||||
...(message.task_id ? { taskId: message.task_id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "tool_use_summary") {
|
||||
return {
|
||||
summary: "Tool use summary emitted.",
|
||||
data: {
|
||||
summary: truncate(message.summary),
|
||||
precedingToolUseIds: sanitizeJsonValue(message.preceding_tool_use_ids),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "stream_event") {
|
||||
const data: JsonObject = {};
|
||||
const eventType = readString((raw.event as Record<string, unknown> | undefined)?.type);
|
||||
if (eventType) {
|
||||
data.eventType = eventType;
|
||||
}
|
||||
const parentToolUseId = readString(raw.parent_tool_use_id);
|
||||
if (parentToolUseId) {
|
||||
data.parentToolUseId = parentToolUseId;
|
||||
}
|
||||
return {
|
||||
summary: "Partial assistant stream event emitted.",
|
||||
...(Object.keys(data).length > 0 ? { data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "auth_status") {
|
||||
return {
|
||||
summary: message.isAuthenticating ? "Authentication in progress." : "Authentication status update.",
|
||||
data: {
|
||||
isAuthenticating: message.isAuthenticating,
|
||||
output: sanitizeJsonValue(message.output),
|
||||
...(message.error ? { error: truncate(message.error) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
return {
|
||||
summary: "Assistant message emitted.",
|
||||
data: {
|
||||
parentToolUseId: message.parent_tool_use_id ?? null,
|
||||
...(message.error ? { error: message.error } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "user") {
|
||||
const data: JsonObject = {
|
||||
parentToolUseId: (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? null,
|
||||
};
|
||||
const isSynthetic = readBoolean(raw.isSynthetic);
|
||||
if (isSynthetic !== undefined) {
|
||||
data.isSynthetic = isSynthetic;
|
||||
}
|
||||
const isReplay = readBoolean(raw.isReplay);
|
||||
if (isReplay !== undefined) {
|
||||
data.isReplay = isReplay;
|
||||
}
|
||||
return {
|
||||
summary: "User message emitted.",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
if (subtype === "task_notification") {
|
||||
return toTaskNotificationSummary(message);
|
||||
}
|
||||
|
||||
if (subtype === "task_started") {
|
||||
return toTaskStartedSummary(message);
|
||||
}
|
||||
|
||||
if (message.type === "system" && subtype === "files_persisted") {
|
||||
const files = Array.isArray(raw.files) ? raw.files : [];
|
||||
const failed = Array.isArray(raw.failed) ? raw.failed : [];
|
||||
return {
|
||||
summary: "System event: files_persisted.",
|
||||
data: {
|
||||
persistedFileCount: files.length,
|
||||
failedFileCount: failed.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "system" && subtype === "compact_boundary") {
|
||||
return {
|
||||
summary: "System event: compact_boundary.",
|
||||
data: {
|
||||
compactMetadata: sanitizeJsonValue(raw.compact_metadata),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "system" && subtype === "status") {
|
||||
const data: JsonObject = {
|
||||
status: readString(raw.status) ?? "none",
|
||||
};
|
||||
const permissionMode = readString(raw.permissionMode);
|
||||
if (permissionMode) {
|
||||
data.permissionMode = permissionMode;
|
||||
}
|
||||
return {
|
||||
summary: "System event: status.",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "system" && (subtype === "hook_started" || subtype === "hook_progress" || subtype === "hook_response")) {
|
||||
const data: JsonObject = {
|
||||
...(subtype ? { subtype } : {}),
|
||||
...(readString(raw.hook_id) ? { hookId: readString(raw.hook_id) } : {}),
|
||||
...(readString(raw.hook_name) ? { hookName: readString(raw.hook_name) } : {}),
|
||||
...(readString(raw.hook_event) ? { hookEvent: readString(raw.hook_event) } : {}),
|
||||
...(readString(raw.outcome) ? { outcome: readString(raw.outcome) } : {}),
|
||||
};
|
||||
if (raw.exit_code !== undefined) {
|
||||
data.exitCode = sanitizeJsonValue(raw.exit_code);
|
||||
}
|
||||
return {
|
||||
summary: `System event: ${subtype}.`,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "system") {
|
||||
return {
|
||||
summary: subtype ? `System event: ${subtype}.` : "System event emitted.",
|
||||
data: subtype ? { subtype } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "rate_limit") {
|
||||
return {
|
||||
summary: "Rate limit event emitted.",
|
||||
data: sanitizeJsonValue(raw) as JsonObject,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "prompt_suggestion") {
|
||||
const data: JsonObject = {
|
||||
...(readString(raw.prompt) ? { prompt: truncate(readString(raw.prompt) as string) } : {}),
|
||||
...(readString(raw.suggestion) ? { suggestion: truncate(readString(raw.suggestion) as string) } : {}),
|
||||
};
|
||||
return {
|
||||
summary: "Prompt suggestion emitted.",
|
||||
...(Object.keys(data).length > 0 ? { data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: `Claude SDK message received (${message.type}).`,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecord(input: {
|
||||
stage: ClaudeTraceRecord["stage"];
|
||||
message: string;
|
||||
context: ClaudeTraceContext;
|
||||
sdkMessageType?: string;
|
||||
sdkMessageSubtype?: string;
|
||||
sdkSessionId?: string;
|
||||
data?: JsonObject;
|
||||
}): ClaudeTraceRecord {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "claude_sdk",
|
||||
stage: input.stage,
|
||||
message: input.message,
|
||||
sessionId: input.context.sessionId,
|
||||
nodeId: input.context.nodeId,
|
||||
attempt: input.context.attempt,
|
||||
depth: input.context.depth,
|
||||
...(input.sdkMessageType ? { sdkMessageType: input.sdkMessageType } : {}),
|
||||
...(input.sdkMessageSubtype ? { sdkMessageSubtype: input.sdkMessageSubtype } : {}),
|
||||
...(input.sdkSessionId ? { sdkSessionId: input.sdkSessionId } : {}),
|
||||
...(input.data ? { data: input.data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeClaudeMessage(
|
||||
message: SDKMessage,
|
||||
verbosity: ClaudeObservabilityVerbosity,
|
||||
): {
|
||||
messageType: string;
|
||||
messageSubtype?: string;
|
||||
sdkSessionId?: string;
|
||||
summary: string;
|
||||
data?: JsonObject;
|
||||
} {
|
||||
const messageSubtype = toMessageSubtype(message);
|
||||
const sdkSessionId = toMessageSessionId(message);
|
||||
const summary = toMessageSummary(message);
|
||||
if (verbosity === "full") {
|
||||
return {
|
||||
messageType: message.type,
|
||||
...(messageSubtype ? { messageSubtype } : {}),
|
||||
...(sdkSessionId ? { sdkSessionId } : {}),
|
||||
summary: summary.summary,
|
||||
data: {
|
||||
message: sanitizeJsonValue(message) as JsonObject,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messageType: message.type,
|
||||
...(messageSubtype ? { messageSubtype } : {}),
|
||||
...(sdkSessionId ? { sdkSessionId } : {}),
|
||||
summary: summary.summary,
|
||||
...(summary.data ? { data: summary.data } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class ClaudeObservabilityLogger {
|
||||
private readonly mode: ClaudeObservabilityMode;
|
||||
private readonly verbosity: ClaudeObservabilityVerbosity;
|
||||
private readonly logPath: string;
|
||||
private readonly includePartialMessages: boolean;
|
||||
private readonly debug: boolean;
|
||||
private readonly debugLogPath?: string;
|
||||
private readonly pendingWrites = new Set<Promise<void>>();
|
||||
private readonly stdoutProgressByKey = new Map<string, {
|
||||
lastEmittedAt: number;
|
||||
suppressed: number;
|
||||
}>();
|
||||
private readonly fileProgressByKey = new Map<string, {
|
||||
lastEmittedAt: number;
|
||||
suppressed: number;
|
||||
}>();
|
||||
private readonly stdoutStreamByKey = new Map<string, {
|
||||
lastEmittedAt: number;
|
||||
suppressed: number;
|
||||
}>();
|
||||
private readonly fileStreamByKey = new Map<string, {
|
||||
lastEmittedAt: number;
|
||||
suppressed: number;
|
||||
}>();
|
||||
private fileWriteFailureCount = 0;
|
||||
|
||||
constructor(input: {
|
||||
workspaceRoot: string;
|
||||
config: ClaudeObservabilityRuntimeConfig;
|
||||
}) {
|
||||
this.mode = input.config.mode;
|
||||
this.verbosity = input.config.verbosity;
|
||||
this.logPath = resolve(input.workspaceRoot, input.config.logPath);
|
||||
this.includePartialMessages = input.config.includePartialMessages;
|
||||
this.debug = input.config.debug;
|
||||
this.debugLogPath = input.config.debugLogPath
|
||||
? resolve(input.workspaceRoot, input.config.debugLogPath)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.mode !== "off";
|
||||
}
|
||||
|
||||
toOptionOverrides(input: {
|
||||
context: ClaudeTraceContext;
|
||||
}): Pick<Options, "includePartialMessages" | "debug" | "debugFile" | "stderr"> {
|
||||
return {
|
||||
includePartialMessages: this.includePartialMessages,
|
||||
debug: this.debug || this.debugLogPath !== undefined,
|
||||
...(this.debugLogPath ? { debugFile: this.debugLogPath } : {}),
|
||||
stderr: (data: string): void => {
|
||||
this.record({
|
||||
stage: "query.stderr",
|
||||
message: "Claude SDK stderr output.",
|
||||
context: input.context,
|
||||
data: {
|
||||
stderr: sanitizeJsonValue(data),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
recordQueryStarted(input: {
|
||||
context: ClaudeTraceContext;
|
||||
data?: JsonObject;
|
||||
}): void {
|
||||
this.record({
|
||||
stage: "query.started",
|
||||
message: "Claude query started.",
|
||||
context: input.context,
|
||||
...(input.data ? { data: input.data } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
recordMessage(input: {
|
||||
context: ClaudeTraceContext;
|
||||
message: SDKMessage;
|
||||
}): void {
|
||||
const summarized = summarizeClaudeMessage(input.message, this.verbosity);
|
||||
this.record({
|
||||
stage: "query.message",
|
||||
message: summarized.summary,
|
||||
context: input.context,
|
||||
sdkMessageType: summarized.messageType,
|
||||
sdkMessageSubtype: summarized.messageSubtype,
|
||||
sdkSessionId: summarized.sdkSessionId,
|
||||
...(summarized.data ? { data: summarized.data } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
recordQueryCompleted(input: {
|
||||
context: ClaudeTraceContext;
|
||||
data?: JsonObject;
|
||||
}): void {
|
||||
this.record({
|
||||
stage: "query.completed",
|
||||
message: "Claude query completed.",
|
||||
context: input.context,
|
||||
...(input.data ? { data: input.data } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
recordQueryError(input: {
|
||||
context: ClaudeTraceContext;
|
||||
error: unknown;
|
||||
}): void {
|
||||
const errorMessage = input.error instanceof Error ? input.error.message : String(input.error);
|
||||
this.record({
|
||||
stage: "query.error",
|
||||
message: "Claude query failed.",
|
||||
context: input.context,
|
||||
data: {
|
||||
error: truncate(errorMessage),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([...this.pendingWrites]);
|
||||
}
|
||||
|
||||
private record(input: {
|
||||
stage: ClaudeTraceRecord["stage"];
|
||||
message: string;
|
||||
context: ClaudeTraceContext;
|
||||
sdkMessageType?: string;
|
||||
sdkMessageSubtype?: string;
|
||||
sdkSessionId?: string;
|
||||
data?: JsonObject;
|
||||
}): void {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = toRecord(input);
|
||||
|
||||
if (this.mode === "stdout" || this.mode === "both") {
|
||||
const stdoutRecord = this.toStdoutRecord(record);
|
||||
if (stdoutRecord) {
|
||||
console.log(`[claude-trace] ${JSON.stringify(stdoutRecord)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mode === "file" || this.mode === "both") {
|
||||
const fileRecord = this.toFileRecord(record);
|
||||
if (!fileRecord) {
|
||||
return;
|
||||
}
|
||||
const line = JSON.stringify(fileRecord);
|
||||
const write = mkdir(dirname(this.logPath), { recursive: true })
|
||||
.then(() => appendFile(this.logPath, `${line}\n`, "utf8"))
|
||||
.catch((error: unknown) => {
|
||||
this.reportFileWriteFailure(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingWrites.delete(write);
|
||||
});
|
||||
this.pendingWrites.add(write);
|
||||
}
|
||||
}
|
||||
|
||||
private toStdoutRecord(record: ClaudeTraceRecord): ClaudeTraceRecord | undefined {
|
||||
return this.toFilteredMessageRecord(record, "stdout");
|
||||
}
|
||||
|
||||
private toFileRecord(record: ClaudeTraceRecord): ClaudeTraceRecord | undefined {
|
||||
return this.toFilteredMessageRecord(record, "file");
|
||||
}
|
||||
|
||||
private toFilteredMessageRecord(
|
||||
record: ClaudeTraceRecord,
|
||||
destination: "stdout" | "file",
|
||||
): ClaudeTraceRecord | undefined {
|
||||
if (record.stage !== "query.message") {
|
||||
return record;
|
||||
}
|
||||
|
||||
if (!record.sdkMessageType) {
|
||||
return record;
|
||||
}
|
||||
|
||||
if (record.sdkMessageType === "tool_progress") {
|
||||
return this.toSampledToolProgressRecord(record, destination);
|
||||
}
|
||||
|
||||
if (record.sdkMessageType === "stream_event") {
|
||||
if (!this.includePartialMessages) {
|
||||
return undefined;
|
||||
}
|
||||
return this.toSampledStreamEventRecord(record, destination);
|
||||
}
|
||||
|
||||
if (record.sdkMessageType === "auth_status") {
|
||||
const data = record.data;
|
||||
const isAuthenticating = data?.isAuthenticating === true;
|
||||
const hasError = typeof data?.error === "string" && data.error.trim().length > 0;
|
||||
if (hasError || !isAuthenticating) {
|
||||
return record;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private toSampledToolProgressRecord(
|
||||
record: ClaudeTraceRecord,
|
||||
destination: "stdout" | "file",
|
||||
): ClaudeTraceRecord | undefined {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = destination === "stdout" ? 1000 : 2000;
|
||||
const rawToolUseId = record.data?.toolUseId;
|
||||
const toolUseId = typeof rawToolUseId === "string" ? rawToolUseId : "unknown";
|
||||
const key = `${destination}:${record.sessionId}:${record.nodeId}:${toolUseId}`;
|
||||
const progressByKey = destination === "stdout" ? this.stdoutProgressByKey : this.fileProgressByKey;
|
||||
const state = progressByKey.get(key);
|
||||
|
||||
if (!state) {
|
||||
progressByKey.set(key, {
|
||||
lastEmittedAt: now,
|
||||
suppressed: 0,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
if (now - state.lastEmittedAt < minIntervalMs) {
|
||||
state.suppressed += 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
state.lastEmittedAt = now;
|
||||
const suppressed = state.suppressed;
|
||||
state.suppressed = 0;
|
||||
|
||||
if (suppressed < 1) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const nextData: JsonObject = {
|
||||
...(record.data ?? {}),
|
||||
suppressedSinceLastEmit: suppressed,
|
||||
};
|
||||
|
||||
return {
|
||||
...record,
|
||||
data: nextData,
|
||||
};
|
||||
}
|
||||
|
||||
private toSampledStreamEventRecord(
|
||||
record: ClaudeTraceRecord,
|
||||
destination: "stdout" | "file",
|
||||
): ClaudeTraceRecord | undefined {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = destination === "stdout" ? 700 : 1200;
|
||||
const key = `${destination}:${record.sessionId}:${record.nodeId}:stream`;
|
||||
const streamByKey = destination === "stdout" ? this.stdoutStreamByKey : this.fileStreamByKey;
|
||||
const state = streamByKey.get(key);
|
||||
|
||||
if (!state) {
|
||||
streamByKey.set(key, {
|
||||
lastEmittedAt: now,
|
||||
suppressed: 0,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
if (now - state.lastEmittedAt < minIntervalMs) {
|
||||
state.suppressed += 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
state.lastEmittedAt = now;
|
||||
const suppressed = state.suppressed;
|
||||
state.suppressed = 0;
|
||||
|
||||
if (suppressed < 1) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const nextData: JsonObject = {
|
||||
...(record.data ?? {}),
|
||||
suppressedStreamEventsSinceLastEmit: suppressed,
|
||||
};
|
||||
|
||||
return {
|
||||
...record,
|
||||
data: nextData,
|
||||
};
|
||||
}
|
||||
|
||||
private reportFileWriteFailure(error: unknown): void {
|
||||
this.fileWriteFailureCount += 1;
|
||||
if (this.fileWriteFailureCount <= 5) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[claude-trace] failed to append trace log to ${this.logPath}: ${truncate(message, 180)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fileWriteFailureCount === 6) {
|
||||
console.warn("[claude-trace] additional trace-log write failures suppressed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/ui/claude-trace-store.ts
Normal file
85
src/ui/claude-trace-store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export type ClaudeTraceEvent = {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
stage?: string;
|
||||
sessionId?: string;
|
||||
sdkMessageType?: string;
|
||||
sdkMessageSubtype?: string;
|
||||
data?: unknown;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
type ClaudeTraceFilter = {
|
||||
sessionId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function safeParseLine(line: string): ClaudeTraceEvent | 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 Record<string, unknown>;
|
||||
if (typeof record.timestamp !== "string" || typeof record.message !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return record as ClaudeTraceEvent;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readClaudeTraceEvents(logPath: string): Promise<ClaudeTraceEvent[]> {
|
||||
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: ClaudeTraceEvent[] = [];
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const event = safeParseLine(line);
|
||||
if (event) {
|
||||
parsed.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function filterClaudeTraceEvents(
|
||||
events: readonly ClaudeTraceEvent[],
|
||||
filter: ClaudeTraceFilter,
|
||||
): ClaudeTraceEvent[] {
|
||||
const filtered: ClaudeTraceEvent[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (filter.sessionId && event.sessionId !== filter.sessionId) {
|
||||
continue;
|
||||
}
|
||||
filtered.push(event);
|
||||
}
|
||||
|
||||
if (!filter.limit || filter.limit < 1 || filtered.length <= filter.limit) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return filtered.slice(-filter.limit);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ const state = {
|
||||
config: null,
|
||||
manifests: [],
|
||||
sessions: [],
|
||||
sessionMetadata: [],
|
||||
runs: [],
|
||||
selectedSessionId: "",
|
||||
selectedManifestPath: "",
|
||||
@@ -25,13 +26,22 @@ const dom = {
|
||||
runProvider: document.querySelector("#run-provider"),
|
||||
runTopologyHint: document.querySelector("#run-topology-hint"),
|
||||
runFlags: document.querySelector("#run-flags"),
|
||||
runRuntimeContext: document.querySelector("#run-runtime-context"),
|
||||
runValidationNodes: document.querySelector("#run-validation-nodes"),
|
||||
killRun: document.querySelector("#kill-run"),
|
||||
runStatus: document.querySelector("#run-status"),
|
||||
sessionForm: document.querySelector("#session-form"),
|
||||
sessionProjectPath: document.querySelector("#session-project-path"),
|
||||
sessionCreate: document.querySelector("#session-create"),
|
||||
sessionClose: document.querySelector("#session-close"),
|
||||
sessionCloseMerge: document.querySelector("#session-close-merge"),
|
||||
nodeInspector: document.querySelector("#node-inspector"),
|
||||
eventsLimit: document.querySelector("#events-limit"),
|
||||
eventsRefresh: document.querySelector("#events-refresh"),
|
||||
eventFeed: document.querySelector("#event-feed"),
|
||||
claudeEventsLimit: document.querySelector("#claude-events-limit"),
|
||||
claudeEventsRefresh: document.querySelector("#claude-events-refresh"),
|
||||
claudeEventFeed: document.querySelector("#claude-event-feed"),
|
||||
historyRefresh: document.querySelector("#history-refresh"),
|
||||
historyBody: document.querySelector("#history-body"),
|
||||
notificationsForm: document.querySelector("#notifications-form"),
|
||||
@@ -77,6 +87,7 @@ const dom = {
|
||||
cfgTopologyDepth: document.querySelector("#cfg-topology-depth"),
|
||||
cfgTopologyRetries: document.querySelector("#cfg-topology-retries"),
|
||||
cfgRelationshipChildren: document.querySelector("#cfg-relationship-children"),
|
||||
cfgMergeConflictAttempts: document.querySelector("#cfg-merge-conflict-attempts"),
|
||||
cfgPortBase: document.querySelector("#cfg-port-base"),
|
||||
cfgPortBlockSize: document.querySelector("#cfg-port-block-size"),
|
||||
cfgPortBlockCount: document.querySelector("#cfg-port-block-count"),
|
||||
@@ -111,10 +122,15 @@ const MANIFEST_EVENT_TRIGGERS = [
|
||||
"requirements_defined",
|
||||
"tasks_planned",
|
||||
"code_committed",
|
||||
"task_ready_for_review",
|
||||
"task_blocked",
|
||||
"validation_passed",
|
||||
"validation_failed",
|
||||
"branch_merged",
|
||||
"merge_conflict_detected",
|
||||
"merge_conflict_resolved",
|
||||
"merge_conflict_unresolved",
|
||||
"merge_retry_started",
|
||||
];
|
||||
|
||||
const RUN_MANIFEST_EDITOR_VALUE = "__editor__";
|
||||
@@ -129,8 +145,12 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({
|
||||
"run-provider": "Choose which model provider backend handles provider-mode runs.",
|
||||
"run-topology-hint": "Optional hint that nudges orchestration toward a topology strategy.",
|
||||
"run-flags": "Optional JSON object passed in as initial run flags.",
|
||||
"run-runtime-context": "Optional JSON object of template values injected into persona prompts (for example repo or ticket).",
|
||||
"run-validation-nodes": "Optional comma-separated node IDs to simulate validation outcomes for.",
|
||||
"session-project-path": "Absolute project path used when creating an explicit managed session.",
|
||||
"session-close-merge": "When enabled, close will merge the session base branch back into the project branch.",
|
||||
"events-limit": "Set how many recent runtime events are loaded per refresh.",
|
||||
"claude-events-limit": "Set how many Claude SDK trace records are loaded per refresh.",
|
||||
"cfg-webhook-url": "Webhook endpoint that receives runtime event notifications.",
|
||||
"cfg-webhook-severity": "Minimum severity level that triggers webhook notifications.",
|
||||
"cfg-webhook-always": "Event types that should always notify, regardless of severity.",
|
||||
@@ -145,6 +165,7 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({
|
||||
"cfg-topology-depth": "Maximum orchestration graph depth permitted by topology rules.",
|
||||
"cfg-topology-retries": "Maximum retry expansions allowed by topology orchestration.",
|
||||
"cfg-relationship-children": "Maximum children each persona relationship can spawn.",
|
||||
"cfg-merge-conflict-attempts": "Maximum merge-conflict resolution attempts before emitting unresolved conflict events.",
|
||||
"cfg-port-base": "Starting port number for provisioning port allocations.",
|
||||
"cfg-port-block-size": "Number of ports reserved per allocated block.",
|
||||
"cfg-port-block-count": "Number of port blocks available for allocation.",
|
||||
@@ -1029,6 +1050,7 @@ async function loadConfig() {
|
||||
dom.cfgTopologyDepth.value = String(limits.topologyMaxDepth);
|
||||
dom.cfgTopologyRetries.value = String(limits.topologyMaxRetries);
|
||||
dom.cfgRelationshipChildren.value = String(limits.relationshipMaxChildren);
|
||||
dom.cfgMergeConflictAttempts.value = String(limits.mergeConflictMaxAttempts);
|
||||
dom.cfgPortBase.value = String(limits.portBase);
|
||||
dom.cfgPortBlockSize.value = String(limits.portBlockSize);
|
||||
dom.cfgPortBlockCount.value = String(limits.portBlockCount);
|
||||
@@ -1060,11 +1082,28 @@ function statusChipClass(status) {
|
||||
return `status-chip status-${status || "unknown"}`;
|
||||
}
|
||||
|
||||
function getSessionLifecycleStatus(sessionId) {
|
||||
const metadata = state.sessionMetadata.find((entry) => entry?.sessionId === sessionId);
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const status = metadata.sessionStatus;
|
||||
if (status === "active" || status === "suspended" || status === "closed" || status === "closed_with_conflicts") {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function renderRunsAndSessionsTable() {
|
||||
const rows = [];
|
||||
|
||||
for (const session of state.sessions) {
|
||||
const sessionStatus = session.status || "unknown";
|
||||
const lifecycleStatus = getSessionLifecycleStatus(session.sessionId);
|
||||
const sessionStatus =
|
||||
lifecycleStatus === "closed" || lifecycleStatus === "closed_with_conflicts"
|
||||
? lifecycleStatus
|
||||
: session.status || lifecycleStatus || "unknown";
|
||||
rows.push(`
|
||||
<tr data-session-id="${escapeHtml(session.sessionId)}">
|
||||
<td>${escapeHtml(session.sessionId)}</td>
|
||||
@@ -1092,6 +1131,7 @@ function renderRunsAndSessionsTable() {
|
||||
async function loadSessions() {
|
||||
const payload = await apiRequest("/api/sessions");
|
||||
state.sessions = payload.sessions || [];
|
||||
state.sessionMetadata = payload.sessionMetadata || [];
|
||||
state.runs = payload.runs || [];
|
||||
|
||||
if (!state.selectedSessionId && state.sessions.length > 0) {
|
||||
@@ -1457,6 +1497,43 @@ function renderEventFeed(events) {
|
||||
dom.eventFeed.innerHTML = rows || '<div class="event-row"><div class="event-time">-</div><div class="event-type">-</div><div>No runtime events.</div></div>';
|
||||
}
|
||||
|
||||
function toClaudeRowSeverity(event) {
|
||||
const stage = String(event?.stage || "");
|
||||
const type = String(event?.sdkMessageType || "");
|
||||
if (stage === "query.error") {
|
||||
return "critical";
|
||||
}
|
||||
if (stage === "query.stderr" || (type === "result" && String(event?.sdkMessageSubtype || "").startsWith("error_"))) {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function renderClaudeTraceFeed(events) {
|
||||
const rows = [...events]
|
||||
.reverse()
|
||||
.map((event) => {
|
||||
const ts = new Date(event.timestamp).toLocaleTimeString();
|
||||
const stage = String(event.stage || "query.message");
|
||||
const sdkMessageType = String(event.sdkMessageType || "");
|
||||
const sdkMessageSubtype = String(event.sdkMessageSubtype || "");
|
||||
const typeLabel = sdkMessageType
|
||||
? `${stage}/${sdkMessageType}${sdkMessageSubtype ? `:${sdkMessageSubtype}` : ""}`
|
||||
: stage;
|
||||
const message = typeof event.message === "string" ? event.message : JSON.stringify(event.message || "");
|
||||
return `
|
||||
<div class="event-row ${escapeHtml(toClaudeRowSeverity(event))}">
|
||||
<div class="event-time">${escapeHtml(ts)}</div>
|
||||
<div class="event-type">${escapeHtml(typeLabel)}</div>
|
||||
<div>${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
dom.claudeEventFeed.innerHTML = rows || '<div class="event-row"><div class="event-time">-</div><div class="event-type">-</div><div>No Claude trace events.</div></div>';
|
||||
}
|
||||
|
||||
async function refreshEvents() {
|
||||
const limit = Number(dom.eventsLimit.value || "150");
|
||||
const params = new URLSearchParams({
|
||||
@@ -1471,6 +1548,20 @@ async function refreshEvents() {
|
||||
renderEventFeed(payload.events || []);
|
||||
}
|
||||
|
||||
async function refreshClaudeTrace() {
|
||||
const limit = Number(dom.claudeEventsLimit.value || "150");
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
if (state.selectedSessionId) {
|
||||
params.set("sessionId", state.selectedSessionId);
|
||||
}
|
||||
|
||||
const payload = await apiRequest(`/api/claude-trace?${params.toString()}`);
|
||||
renderClaudeTraceFeed(payload.events || []);
|
||||
}
|
||||
|
||||
async function startRun(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1486,6 +1577,12 @@ async function startRun(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeContext = parseJsonSafe(dom.runRuntimeContext.value, {});
|
||||
if (typeof runtimeContext !== "object" || Array.isArray(runtimeContext) || !runtimeContext) {
|
||||
showRunStatus("Runtime Context Overrides must be a JSON object.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifestSelection = dom.runManifestSelect.value.trim();
|
||||
|
||||
const payload = {
|
||||
@@ -1494,9 +1591,21 @@ async function startRun(event) {
|
||||
provider: dom.runProvider.value,
|
||||
topologyHint: dom.runTopologyHint.value.trim() || undefined,
|
||||
initialFlags: flags,
|
||||
runtimeContextOverrides: runtimeContext,
|
||||
simulateValidationNodeIds: fromCsv(dom.runValidationNodes.value),
|
||||
};
|
||||
|
||||
const selectedSessionMetadata = state.sessionMetadata.find(
|
||||
(entry) => entry?.sessionId === state.selectedSessionId,
|
||||
);
|
||||
if (
|
||||
selectedSessionMetadata &&
|
||||
(selectedSessionMetadata.sessionStatus === "active" ||
|
||||
selectedSessionMetadata.sessionStatus === "suspended")
|
||||
) {
|
||||
payload.sessionId = selectedSessionMetadata.sessionId;
|
||||
}
|
||||
|
||||
if (manifestSelection === RUN_MANIFEST_EDITOR_VALUE) {
|
||||
const manifestFromEditor = parseJsonSafe(dom.manifestEditor.value, null);
|
||||
if (!manifestFromEditor) {
|
||||
@@ -1527,6 +1636,7 @@ async function startRun(event) {
|
||||
dom.sessionSelect.value = run.sessionId;
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
} catch (error) {
|
||||
showRunStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
@@ -1547,6 +1657,67 @@ async function cancelActiveRun() {
|
||||
await loadSessions();
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
} catch (error) {
|
||||
showRunStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSessionFromUi() {
|
||||
const projectPath = dom.sessionProjectPath.value.trim();
|
||||
if (!projectPath) {
|
||||
showRunStatus("Project path is required to create a session.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await apiRequest("/api/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
projectPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const created = payload.session;
|
||||
if (created?.sessionId) {
|
||||
state.selectedSessionId = created.sessionId;
|
||||
showRunStatus(`Session ${created.sessionId} created.`);
|
||||
} else {
|
||||
showRunStatus("Session created.");
|
||||
}
|
||||
await loadSessions();
|
||||
if (state.selectedSessionId) {
|
||||
dom.sessionSelect.value = state.selectedSessionId;
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
}
|
||||
} catch (error) {
|
||||
showRunStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSelectedSessionFromUi() {
|
||||
const sessionId = state.selectedSessionId || dom.sessionSelect.value;
|
||||
if (!sessionId) {
|
||||
showRunStatus("Select a session before closing.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await apiRequest(`/api/sessions/${encodeURIComponent(sessionId)}/close`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mergeToProject: dom.sessionCloseMerge.checked,
|
||||
}),
|
||||
});
|
||||
|
||||
const nextStatus = payload?.session?.sessionStatus || "closed";
|
||||
showRunStatus(`Session ${sessionId} closed with status ${nextStatus}.`);
|
||||
await loadSessions();
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
} catch (error) {
|
||||
showRunStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
@@ -1597,6 +1768,7 @@ async function saveLimits(event) {
|
||||
topologyMaxDepth: Number(dom.cfgTopologyDepth.value),
|
||||
topologyMaxRetries: Number(dom.cfgTopologyRetries.value),
|
||||
relationshipMaxChildren: Number(dom.cfgRelationshipChildren.value),
|
||||
mergeConflictMaxAttempts: Number(dom.cfgMergeConflictAttempts.value),
|
||||
portBase: Number(dom.cfgPortBase.value),
|
||||
portBlockSize: Number(dom.cfgPortBlockSize.value),
|
||||
portBlockCount: Number(dom.cfgPortBlockCount.value),
|
||||
@@ -1695,6 +1867,7 @@ function bindUiEvents() {
|
||||
state.selectedSessionId = dom.sessionSelect.value;
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
});
|
||||
|
||||
dom.graphManifestSelect.addEventListener("change", async () => {
|
||||
@@ -1714,15 +1887,26 @@ function bindUiEvents() {
|
||||
await refreshEvents();
|
||||
});
|
||||
|
||||
dom.claudeEventsRefresh.addEventListener("click", async () => {
|
||||
await refreshClaudeTrace();
|
||||
});
|
||||
|
||||
dom.historyRefresh.addEventListener("click", async () => {
|
||||
await loadSessions();
|
||||
await refreshGraph();
|
||||
await refreshClaudeTrace();
|
||||
});
|
||||
|
||||
dom.runForm.addEventListener("submit", startRun);
|
||||
dom.killRun.addEventListener("click", () => {
|
||||
void cancelActiveRun();
|
||||
});
|
||||
dom.sessionCreate.addEventListener("click", () => {
|
||||
void createSessionFromUi();
|
||||
});
|
||||
dom.sessionClose.addEventListener("click", () => {
|
||||
void closeSelectedSessionFromUi();
|
||||
});
|
||||
|
||||
dom.notificationsForm.addEventListener("submit", (event) => {
|
||||
void saveNotifications(event);
|
||||
@@ -1830,6 +2014,7 @@ async function refreshAll() {
|
||||
|
||||
await refreshGraph();
|
||||
await refreshEvents();
|
||||
await refreshClaudeTrace();
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
@@ -1860,6 +2045,10 @@ async function initialize() {
|
||||
void refreshEvents();
|
||||
}, 3000);
|
||||
|
||||
setInterval(() => {
|
||||
void refreshClaudeTrace();
|
||||
}, 3000);
|
||||
|
||||
setInterval(() => {
|
||||
void refreshGraph();
|
||||
}, 7000);
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
Initial Flags (JSON)
|
||||
<textarea id="run-flags" rows="3" placeholder='{"needs_bootstrap": true}'></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Runtime Context Overrides (JSON)
|
||||
<textarea id="run-runtime-context" rows="3" placeholder='{"repo":"ai_ops","ticket":"AIOPS-123"}'></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Simulate Validation Nodes (CSV)
|
||||
<input id="run-validation-nodes" type="text" placeholder="coder-1,qa-1" />
|
||||
@@ -86,6 +90,23 @@
|
||||
</form>
|
||||
<div id="run-status" class="subtle"></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h3>Session Controls</h3>
|
||||
<form id="session-form" class="stacked-form">
|
||||
<label>
|
||||
Project Path (absolute)
|
||||
<input id="session-project-path" type="text" placeholder="/abs/path/to/project" />
|
||||
</label>
|
||||
<label class="inline-checkbox">
|
||||
<input id="session-close-merge" type="checkbox" />
|
||||
Merge base into project when closing selected session
|
||||
</label>
|
||||
<div class="inline-actions">
|
||||
<button id="session-create" type="button">Create Session</button>
|
||||
<button id="session-close" type="button">Close Selected Session</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h3>Node Inspector</h3>
|
||||
<div id="node-inspector" class="inspector empty">Select a graph node.</div>
|
||||
@@ -109,6 +130,24 @@
|
||||
<div id="event-feed" class="event-feed"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel claude-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Claude Trace</h2>
|
||||
<div class="panel-actions">
|
||||
<label>
|
||||
Limit
|
||||
<select id="claude-events-limit">
|
||||
<option value="80">80</option>
|
||||
<option value="150" selected>150</option>
|
||||
<option value="300">300</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="claude-events-refresh" type="button">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="claude-event-feed" class="event-feed claude-event-feed"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel history-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Run History</h2>
|
||||
@@ -163,6 +202,7 @@
|
||||
<select id="cfg-security-mode">
|
||||
<option value="hard_abort">hard_abort</option>
|
||||
<option value="validation_fail">validation_fail</option>
|
||||
<option value="dangerous_warn_only">dangerous_warn_only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -192,6 +232,7 @@
|
||||
<label>AGENT_TOPOLOGY_MAX_DEPTH<input id="cfg-topology-depth" type="number" min="1" /></label>
|
||||
<label>AGENT_TOPOLOGY_MAX_RETRIES<input id="cfg-topology-retries" type="number" min="0" /></label>
|
||||
<label>AGENT_RELATIONSHIP_MAX_CHILDREN<input id="cfg-relationship-children" type="number" min="1" /></label>
|
||||
<label>AGENT_MERGE_CONFLICT_MAX_ATTEMPTS<input id="cfg-merge-conflict-attempts" type="number" min="1" /></label>
|
||||
<label>AGENT_PORT_BASE<input id="cfg-port-base" type="number" min="1" /></label>
|
||||
<label>AGENT_PORT_BLOCK_SIZE<input id="cfg-port-block-size" type="number" min="1" /></label>
|
||||
<label>AGENT_PORT_BLOCK_COUNT<input id="cfg-port-block-count" type="number" min="1" /></label>
|
||||
|
||||
@@ -79,7 +79,8 @@ p {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
|
||||
grid-template-areas:
|
||||
"graph side"
|
||||
"feed history"
|
||||
"feed claude"
|
||||
"history history"
|
||||
"config config";
|
||||
}
|
||||
|
||||
@@ -129,6 +130,10 @@ p {
|
||||
grid-area: history;
|
||||
}
|
||||
|
||||
.claude-panel {
|
||||
grid-area: claude;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
grid-area: config;
|
||||
}
|
||||
@@ -142,6 +147,12 @@ label {
|
||||
letter-spacing: 0.015em;
|
||||
}
|
||||
|
||||
label.inline-checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
@@ -308,6 +319,14 @@ button.danger {
|
||||
color: var(--critical);
|
||||
}
|
||||
|
||||
.claude-event-feed .event-row {
|
||||
grid-template-columns: 110px 150px 1fr;
|
||||
}
|
||||
|
||||
.claude-event-feed .event-type {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -353,6 +372,22 @@ button.danger {
|
||||
border-color: rgba(255, 201, 74, 0.6);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: var(--accent-cool);
|
||||
border-color: rgba(86, 195, 255, 0.6);
|
||||
}
|
||||
|
||||
.status-suspended,
|
||||
.status-closed_with_conflicts {
|
||||
color: var(--warn);
|
||||
border-color: rgba(255, 201, 74, 0.6);
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
color: var(--muted);
|
||||
border-color: rgba(155, 184, 207, 0.45);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
color: var(--muted);
|
||||
border-color: rgba(155, 184, 207, 0.45);
|
||||
@@ -463,6 +498,7 @@ button.danger {
|
||||
"graph"
|
||||
"side"
|
||||
"feed"
|
||||
"claude"
|
||||
"history"
|
||||
"config";
|
||||
}
|
||||
|
||||
236
src/ui/server.ts
236
src/ui/server.ts
@@ -5,10 +5,17 @@ import express from "express";
|
||||
import cors from "cors";
|
||||
import { z } from "zod";
|
||||
import { buildSessionGraphInsight, buildSessionSummaries } from "../telemetry/session-insights.js";
|
||||
import { UiConfigStore, type LimitSettings, type RuntimeNotificationSettings, type SecurityPolicySettings } from "../store/config-store.js";
|
||||
import {
|
||||
UiConfigStore,
|
||||
type LimitSettings,
|
||||
type RuntimeNotificationSettings,
|
||||
type SecurityPolicySettings,
|
||||
} from "../store/config-store.js";
|
||||
import { ManifestStore } from "../agents/manifest-store.js";
|
||||
import { filterRuntimeEvents, readRuntimeEvents } from "../telemetry/runtime-events-store.js";
|
||||
import { readRunMetaBySession, UiRunService } from "../runs/run-service.js";
|
||||
import { filterClaudeTraceEvents, readClaudeTraceEvents } from "./claude-trace-store.js";
|
||||
import { readRunMetaBySession, UiRunService, type RunExecutionMode } from "../runs/run-service.js";
|
||||
import type { RunProvider } from "../agents/provider-executor.js";
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
const parsed = Number(value ?? "4317");
|
||||
@@ -29,14 +36,23 @@ function parseLimit(value: string | null | undefined, fallback: number): number
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function ensureNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Field "${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{
|
||||
stateRoot: string;
|
||||
runtimeEventLogPath: string;
|
||||
claudeTraceLogPath: string;
|
||||
}> {
|
||||
const snapshot = await configStore.readSnapshot();
|
||||
return {
|
||||
stateRoot: resolve(workspaceRoot, snapshot.paths.stateRoot),
|
||||
runtimeEventLogPath: resolve(workspaceRoot, snapshot.paths.runtimeEventLogPath),
|
||||
claudeTraceLogPath: resolve(workspaceRoot, snapshot.paths.claudeTraceLogPath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +69,21 @@ const StartRunSchema = z.object({
|
||||
provider: z.enum(["claude", "codex"]).optional(),
|
||||
});
|
||||
|
||||
type StartRunBody = z.infer<typeof StartRunSchema>;
|
||||
|
||||
async function resolveManifestFromRunRequest(input: {
|
||||
body: StartRunBody;
|
||||
manifestStore: ManifestStore;
|
||||
}): Promise<unknown> {
|
||||
if (input.body.manifest !== undefined) {
|
||||
return input.body.manifest;
|
||||
}
|
||||
if (input.body.manifestPath) {
|
||||
return (await input.manifestStore.read(input.body.manifestPath)).source;
|
||||
}
|
||||
throw new Error("A manifest or manifestPath is required to start a run.");
|
||||
}
|
||||
|
||||
export async function startUiServer(input: {
|
||||
workspaceRoot: string;
|
||||
port?: number;
|
||||
@@ -79,66 +110,82 @@ export async function startUiServer(input: {
|
||||
try {
|
||||
const snapshot = await configStore.readSnapshot();
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/config/runtime-events", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateRuntimeEvents(req.body as RuntimeNotificationSettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/config/security", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateSecurityPolicy(req.body as SecurityPolicySettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/config/limits", async (req, res, next) => {
|
||||
try {
|
||||
const snapshot = await configStore.updateLimits(req.body as LimitSettings);
|
||||
res.json({ ok: true, config: snapshot });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/manifests", async (req, res, next) => {
|
||||
try {
|
||||
const listing = await manifestStore.list();
|
||||
res.json({ ok: true, manifests: listing.paths });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/manifests/read", async (req, res, next) => {
|
||||
try {
|
||||
const manifestPath = req.query.path as string;
|
||||
const manifestPath = req.query.path as string | undefined;
|
||||
if (!manifestPath) {
|
||||
res.status(400).json({ ok: false, error: 'Query parameter "path" is required.' });
|
||||
return;
|
||||
}
|
||||
const record = await manifestStore.read(manifestPath);
|
||||
res.json({ ok: true, manifest: record });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/manifests/validate", async (req, res, next) => {
|
||||
try {
|
||||
const manifest = await manifestStore.validate(req.body.manifest);
|
||||
const manifest = await manifestStore.validate((req.body as { manifest?: unknown }).manifest);
|
||||
res.json({ ok: true, manifest });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/manifests/save", async (req, res, next) => {
|
||||
try {
|
||||
const { path, manifest } = req.body;
|
||||
const { path, manifest } = req.body as { path?: unknown; manifest?: unknown };
|
||||
if (!path || typeof path !== "string") {
|
||||
res.status(400).json({ ok: false, error: 'Field "path" is required.' });
|
||||
return;
|
||||
}
|
||||
const record = await manifestStore.save(path, manifest);
|
||||
res.json({ ok: true, manifest: record });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/runtime-events", async (req, res, next) => {
|
||||
@@ -151,15 +198,45 @@ export async function startUiServer(input: {
|
||||
limit,
|
||||
});
|
||||
res.json({ ok: true, events });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/claude-trace", async (req, res, next) => {
|
||||
try {
|
||||
const { claudeTraceLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||
const limit = parseLimit(req.query.limit as string | undefined, 200);
|
||||
const sessionId = (req.query.sessionId as string) || undefined;
|
||||
const events = filterClaudeTraceEvents(await readClaudeTraceEvents(claudeTraceLogPath), {
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
limit,
|
||||
});
|
||||
res.json({ ok: true, events });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/sessions", async (req, res, next) => {
|
||||
try {
|
||||
const projectPath = ensureNonEmptyString((req.body as { projectPath?: unknown }).projectPath, "projectPath");
|
||||
const session = await runService.createSession({ projectPath });
|
||||
res.status(201).json({ ok: true, session });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/sessions", async (req, res, next) => {
|
||||
try {
|
||||
const { stateRoot, runtimeEventLogPath } = await readRuntimePaths(configStore, workspaceRoot);
|
||||
const sessions = await buildSessionSummaries({ stateRoot, runtimeEventLogPath });
|
||||
res.json({ ok: true, sessions, runs: runService.listRuns() });
|
||||
} catch (error) { next(error); }
|
||||
const metadata = await runService.listSessions();
|
||||
res.json({ ok: true, sessions, sessionMetadata: metadata, runs: runService.listRuns() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/sessions/graph", async (req, res, next) => {
|
||||
@@ -176,7 +253,10 @@ export async function startUiServer(input: {
|
||||
const manifestPath = explicitManifestPath ?? runMeta?.manifestPath;
|
||||
|
||||
if (!manifestPath) {
|
||||
res.status(400).json({ ok: false, error: "No manifestPath available for this session. Provide one in query string." });
|
||||
res.status(400).json({
|
||||
ok: false,
|
||||
error: "No manifestPath available for this session. Provide one in query string.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +269,68 @@ export async function startUiServer(input: {
|
||||
});
|
||||
|
||||
res.json({ ok: true, graph, manifestPath: manifestRecord.path });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/sessions/:sessionId/run", async (req, res, next) => {
|
||||
try {
|
||||
const sessionId = req.params.sessionId;
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ ok: false, error: "Session id is required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = StartRunSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message ?? "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseResult.data;
|
||||
const manifest = await resolveManifestFromRunRequest({ body, manifestStore });
|
||||
const record = await runService.startRun({
|
||||
prompt: body.prompt,
|
||||
manifest,
|
||||
manifestPath: body.manifestPath,
|
||||
sessionId,
|
||||
topologyHint: body.topologyHint,
|
||||
initialFlags: body.initialFlags ?? {},
|
||||
runtimeContextOverrides: body.runtimeContextOverrides ?? {},
|
||||
simulateValidationNodeIds: body.simulateValidationNodeIds ?? [],
|
||||
executionMode: (body.executionMode ?? "mock") as RunExecutionMode,
|
||||
provider: (body.provider ?? "codex") as RunProvider,
|
||||
});
|
||||
|
||||
res.status(202).json({ ok: true, run: record });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/sessions/:sessionId/close", async (req, res, next) => {
|
||||
try {
|
||||
const sessionId = req.params.sessionId;
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ ok: false, error: "Session id is required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const mergeToProject =
|
||||
typeof (req.body as { mergeToProject?: unknown } | undefined)?.mergeToProject === "boolean"
|
||||
? ((req.body as { mergeToProject: boolean }).mergeToProject)
|
||||
: false;
|
||||
|
||||
const session = await runService.closeSession({
|
||||
sessionId,
|
||||
mergeToProject,
|
||||
});
|
||||
|
||||
res.json({ ok: true, session });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/runs", (req, res) => {
|
||||
@@ -200,23 +341,12 @@ export async function startUiServer(input: {
|
||||
try {
|
||||
const parseResult = StartRunSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message || 'Invalid body' });
|
||||
res.status(400).json({ ok: false, error: parseResult.error.issues[0]?.message ?? "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseResult.data;
|
||||
|
||||
let manifest: unknown;
|
||||
if (body.manifest !== undefined) {
|
||||
manifest = body.manifest;
|
||||
} else if (body.manifestPath) {
|
||||
manifest = (await manifestStore.read(body.manifestPath)).source;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
res.status(400).json({ ok: false, error: "A manifest or manifestPath is required to start a run." });
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = await resolveManifestFromRunRequest({ body, manifestStore });
|
||||
const record = await runService.startRun({
|
||||
prompt: body.prompt,
|
||||
manifest,
|
||||
@@ -226,12 +356,14 @@ export async function startUiServer(input: {
|
||||
initialFlags: body.initialFlags ?? {},
|
||||
runtimeContextOverrides: body.runtimeContextOverrides ?? {},
|
||||
simulateValidationNodeIds: body.simulateValidationNodeIds ?? [],
|
||||
executionMode: body.executionMode ?? "mock",
|
||||
provider: body.provider ?? "codex",
|
||||
executionMode: (body.executionMode ?? "mock") as RunExecutionMode,
|
||||
provider: (body.provider ?? "codex") as RunProvider,
|
||||
});
|
||||
|
||||
res.status(202).json({ ok: true, run: record });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/runs/:runId/cancel", async (req, res, next) => {
|
||||
@@ -243,23 +375,23 @@ export async function startUiServer(input: {
|
||||
}
|
||||
const run = await runService.cancelRun(runId);
|
||||
res.json({ ok: true, run });
|
||||
} catch (error) { next(error); }
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/runs/:runId", async (req, res, next) => {
|
||||
try {
|
||||
const runId = req.params.runId;
|
||||
if (!runId) {
|
||||
res.status(400).json({ ok: false, error: "runId required" });
|
||||
return;
|
||||
}
|
||||
const run = runService.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ ok: false, error: `Run "${runId}" was not found.` });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, run });
|
||||
} catch (error) { next(error); }
|
||||
app.get("/api/runs/:runId", (req, res) => {
|
||||
const runId = req.params.runId;
|
||||
if (!runId) {
|
||||
res.status(400).json({ ok: false, error: "runId required" });
|
||||
return;
|
||||
}
|
||||
const run = runService.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ ok: false, error: `Run "${runId}" was not found.` });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, run });
|
||||
});
|
||||
|
||||
app.use("/api", (req, res) => {
|
||||
@@ -269,15 +401,13 @@ export async function startUiServer(input: {
|
||||
});
|
||||
});
|
||||
|
||||
// Default error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
res.status(400).json({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(staticRoot));
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.sendFile(resolve(staticRoot, "index.html"));
|
||||
|
||||
Reference in New Issue
Block a user