feat: add local workflow web console

This commit is contained in:
liujing
2026-07-20 02:48:08 +08:00
parent 84f1dc589e
commit c1a982a179
11 changed files with 3071 additions and 3 deletions
+12 -1
View File
@@ -24,6 +24,7 @@ import {
} from "./workflow-engine.js";
import { WorkflowConfigError } from "./workflow-config.js";
import { WorkflowLockError } from "./workflow-state.js";
import { startWorkflowWebServer } from "./workflow-web.js";
async function main() {
let args: WorkflowArgs;
@@ -91,12 +92,22 @@ async function main() {
});
break;
case "web":
await startWorkflowWebServer(args.port);
// Keep the process alive
await new Promise(() => {});
break;
default: {
const _exhaustive: never = args;
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
}
}
} catch (err) {
} catch (err: any) {
if (err && err.code === "EADDRINUSE") {
process.stderr.write(`Error: Port is already in use.\n`);
process.exit(1);
}
if (err instanceof WorkflowEngineError) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(err.exitCode);
+5
View File
@@ -134,6 +134,7 @@ export type {
WorkflowAbortArgs,
WorkflowLogsArgs,
WorkflowExtendArgs,
WorkflowWebArgs,
} from "./workflow-args.js";
export type {
@@ -202,3 +203,7 @@ export {
export type {
VcsProvider,
} from "./vcs-provider.js";
export {
startWorkflowWebServer,
} from "./workflow-web.js";
+23
View File
@@ -40,3 +40,26 @@ describe("workflow retry-execute arguments", () => {
});
});
});
describe("workflow web arguments", () => {
it("accepts no options", () => {
expect(parseWorkflowArgs(["web"])).toEqual({
sub: "web",
port: undefined,
});
});
it("accepts a custom port", () => {
expect(parseWorkflowArgs(["web", "--port", "8080"])).toEqual({
sub: "web",
port: 8080,
});
});
it("throws on invalid port", () => {
expect(() => parseWorkflowArgs(["web", "--port", "abc"])).toThrow();
expect(() => parseWorkflowArgs(["web", "--port", "-1"])).toThrow();
expect(() => parseWorkflowArgs(["web", "--port", "0"])).toThrow();
expect(() => parseWorkflowArgs(["web", "--port", "70000"])).toThrow();
});
});
+30 -2
View File
@@ -2,7 +2,7 @@
* Argument parsing for agent-workflow subcommands.
*/
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend";
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend" | "web";
export interface WorkflowStartArgs {
sub: "start";
@@ -60,6 +60,11 @@ export interface WorkflowExtendArgs {
additionalCycles: number;
}
export interface WorkflowWebArgs {
sub: "web";
port?: number;
}
export type WorkflowArgs =
| WorkflowStartArgs
| WorkflowReviewArgs
@@ -69,7 +74,8 @@ export type WorkflowArgs =
| WorkflowStatusArgs
| WorkflowAbortArgs
| WorkflowLogsArgs
| WorkflowExtendArgs;
| WorkflowExtendArgs
| WorkflowWebArgs;
export class WorkflowArgsError extends Error {
constructor(message: string) {
@@ -104,6 +110,7 @@ Usage:
agent-workflow status --run <run-id> [--json]
agent-workflow abort --run <run-id> --reason <text>
agent-workflow logs --run <run-id> [--follow] [--tail <lines>]
agent-workflow web [--port <n>]
Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 for stdout).
@@ -122,6 +129,8 @@ Examples:
agent-workflow status --run <id>
agent-workflow status --run <id> --json
agent-workflow abort --run <id> --reason "changed requirements"
agent-workflow web
agent-workflow web --port 4317
`.trimStart();
export function workflowUsage(exitCode = 0): never {
@@ -331,6 +340,25 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
return { sub: "extend", runId, additionalCycles };
}
case "web": {
let port: number | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--port") {
const p = parsePositiveInt(arg, requireValue(arg, rest));
if (p < 1 || p > 65535) {
throw new WorkflowArgsError("--port must be between 1 and 65535");
}
port = p;
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
return { sub: "web", port };
}
default:
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import { parseLogLine, parseLogContent } from "./workflow-web-parser.js";
describe("workflow-web-parser - Claude stream-json", () => {
it("parses thinking block inside assistant message", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"secret thought"}]}}`;
const res = parseLogLine(line, "claude");
expect(res).toHaveLength(1);
expect(res[0]).toEqual({
type: "thinking",
content: "secret thought",
raw: line,
});
});
it("parses text block inside assistant message", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"text","text":"hello world"}]}}`;
const res = parseLogLine(line, "claude");
expect(res).toHaveLength(1);
expect(res[0]).toEqual({
type: "text",
content: "hello world",
raw: line,
});
});
it("parses tool_use block inside assistant message", () => {
const line = `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/index.ts"}}]}}`;
const res = parseLogLine(line, "claude");
expect(res).toHaveLength(1);
expect(res[0].type).toBe("tool_call");
expect(res[0].content).toContain("Calling tool Read");
expect(res[0].meta?.name).toBe("Read");
});
it("parses user message tool_result block", () => {
const line = `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-123","content":"File contents here"}]}}`;
const res = parseLogLine(line, "claude");
expect(res).toHaveLength(1);
expect(res[0]).toEqual({
type: "tool_result",
content: "Tool result: File contents here",
meta: { type: "tool_result", tool_use_id: "tool-123", content: "File contents here" },
raw: line,
});
});
it("parses result block", () => {
const line = `{"type":"result","result":"Success outcome"}`;
const res = parseLogLine(line, "claude");
expect(res[0]).toEqual({
type: "result",
content: "Success outcome",
meta: { type: "result", result: "Success outcome" },
raw: line,
});
});
it("parses error result block", () => {
const line = `{"type":"result","is_error":true,"result":"Rate Limit"}`;
const res = parseLogLine(line, "claude");
expect(res[0]).toEqual({
type: "error",
content: "Rate Limit",
meta: { type: "result", is_error: true, result: "Rate Limit" },
raw: line,
});
});
it("parses token_progress block", () => {
const line = `{"type":"progress","token_progress":456}`;
const res = parseLogLine(line, "claude");
expect(res[0]).toEqual({
type: "progress",
content: "456",
meta: { type: "progress", token_progress: 456 },
raw: line,
});
});
it("parses thinking_tokens progress block", () => {
const line = `{"type":"progress","thinking_tokens":120,"total_tokens":850}`;
const res = parseLogLine(line, "claude");
expect(res[0]).toEqual({
type: "progress",
content: "Thinking tokens: 120 / Total: 850",
meta: { type: "progress", thinking_tokens: 120, total_tokens: 850 },
raw: line,
});
});
});
describe("workflow-web-parser - Reasonix heuristics", () => {
it("cleans ANSI codes and parses thinking", () => {
const line = "\u001b[32mThinking: I should explore the directory structure\u001b[0m";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "thinking",
content: "Thinking: I should explore the directory structure",
raw: line,
});
});
it("parses Reasonix '▎ thinking' line", () => {
const line = "▎ thinking ... some thought";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "thinking",
content: "▎ thinking ... some thought",
raw: line,
});
});
it("cleans ANSI codes and parses tool call", () => {
const line = "\u001b[34mCalling tool Read with args...\u001b[0m";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "tool_call",
content: "Calling tool Read with args...",
raw: line,
});
});
it("parses Reasonix '-> tool' line", () => {
const line = "-> tool calling edit";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "tool_call",
content: "-> tool calling edit",
raw: line,
});
});
it("cleans ANSI codes and parses statistics", () => {
const line = "Tokens used: 1250";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "stats",
content: "Tokens used: 1250",
raw: line,
});
});
it("parses Reasonix '· N tok' line", () => {
const line = "· 452 tok";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "stats",
content: "· 452 tok",
raw: line,
});
});
it("cleans ANSI codes and parses normal text", () => {
const line = "\u001b[1mStandard console log output\u001b[0m";
const res = parseLogLine(line, "reasonix");
expect(res[0]).toEqual({
type: "text",
content: "Standard console log output",
raw: line,
});
});
});
describe("workflow-web-parser - Agy raw fallback", () => {
it("passes through line as raw", () => {
const line = "random unformatted output line";
const res = parseLogLine(line, "agy");
expect(res[0]).toEqual({
type: "raw",
content: line,
raw: line,
});
});
});
describe("parseLogContent", () => {
it("splits text by lines and parses all", () => {
const content = "line 1\nline 2\r\nline 3\n";
const res = parseLogContent(content, "agy");
expect(res).toHaveLength(3);
expect(res[0].content).toBe("line 1");
expect(res[1].content).toBe("line 2");
expect(res[2].content).toBe("line 3");
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Unified parser for executor logs.
* Supports Claude stream-json, Reasonix console logs, and Agy stdout/stderr logs.
*/
export interface ParsedLogEntry {
type: "thinking" | "text" | "tool_call" | "tool_result" | "system" | "error" | "result" | "progress" | "stats" | "raw";
content: string;
meta?: Record<string, any>;
raw: string;
}
const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
// Reasonix heuristics after stripping ANSI
export const REASONIX_THINKING_REGEX = /▎\s*thinking|^(?:thinking|thought|reasoning|inside thinking)[:\-\s]|\[(?:thinking|thought|reasoning)\]|^🤔/i;
export const REASONIX_TOOL_REGEX = /->\s*tool|^(?:tool|action|calling|running|using|executing|executing tool)[:\-\s]|\[(?:tool|action)\]/i;
export const REASONIX_STATS_REGEX = /·\s*\d+\s*tok|^(?:tokens|cost|stats|duration|elapsed|prompt tokens|completion tokens|total tokens|statistics)[:\-\s\d]/i;
/**
* Parse a single line from an executor log.
*/
export function parseLogLine(line: string, executor: string): ParsedLogEntry[] {
if (!line) {
return [{ type: "raw", content: "", raw: "" }];
}
const trimmed = line.trim();
// 1. Claude stream-json parser
if (executor === "claude" && trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const parsed = JSON.parse(trimmed) as Record<string, any>;
const type = String(parsed.type ?? "");
if (type === "assistant") {
const message = parsed.message as Record<string, any> | undefined;
const content = message?.content;
if (Array.isArray(content)) {
const entries: ParsedLogEntry[] = [];
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const block = item as Record<string, any>;
const blockType = String(block.type ?? "");
if (blockType === "thinking") {
entries.push({
type: "thinking",
content: String(block.thinking ?? ""),
raw: line,
});
} else if (blockType === "text") {
entries.push({
type: "text",
content: String(block.text ?? ""),
raw: line,
});
} else if (blockType === "tool_use") {
entries.push({
type: "tool_call",
content: `Calling tool ${block.name} with input: ${JSON.stringify(block.input ?? {})}`,
meta: block,
raw: line,
});
} else {
entries.push({
type: "raw",
content: JSON.stringify(block),
raw: line,
});
}
}
if (entries.length > 0) return entries;
}
}
if (type === "user") {
const message = parsed.message as Record<string, any> | undefined;
const content = message?.content;
if (Array.isArray(content)) {
const entries: ParsedLogEntry[] = [];
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const block = item as Record<string, any>;
const blockType = String(block.type ?? "");
if (blockType === "tool_result") {
entries.push({
type: "tool_result",
content: `Tool result: ${String(block.content ?? "")}`,
meta: block,
raw: line,
});
} else {
entries.push({
type: "raw",
content: JSON.stringify(block),
raw: line,
});
}
}
if (entries.length > 0) return entries;
}
}
if (type === "result" || parsed.result !== undefined) {
if (parsed.is_error === true) {
return [{
type: "error",
content: String(parsed.result ?? parsed.error ?? "Error occurred"),
meta: parsed,
raw: line,
}];
}
return [{
type: "result",
content: String(parsed.result ?? ""),
meta: parsed,
raw: line,
}];
}
if (type === "error" || parsed.error !== undefined) {
return [{
type: "error",
content: String(parsed.error ?? ""),
meta: parsed,
raw: line,
}];
}
if (type === "system") {
return [{
type: "system",
content: parsed.subtype === "init"
? `Session initialized: ${parsed.session_id ?? ""}`
: String(parsed.message ?? JSON.stringify(parsed)),
meta: parsed,
raw: line,
}];
}
if (
type === "progress" ||
type === "token_progress" ||
parsed.token_progress !== undefined ||
parsed.progress !== undefined ||
parsed.tokenProgress !== undefined ||
parsed.thinking_tokens !== undefined ||
parsed.tokens !== undefined
) {
let content = "";
if (parsed.thinking_tokens !== undefined) {
content = `Thinking tokens: ${parsed.thinking_tokens}`;
if (parsed.total_tokens !== undefined) {
content += ` / Total: ${parsed.total_tokens}`;
}
} else if (parsed.tokens !== undefined) {
content = `Tokens: ${parsed.tokens}`;
} else {
content = String(parsed.message ?? parsed.token_progress ?? JSON.stringify(parsed));
}
return [{
type: "progress",
content,
meta: parsed,
raw: line,
}];
}
// Fallback inside JSON
return [{
type: "raw",
content: trimmed,
meta: parsed,
raw: line,
}];
} catch {
// JSON parse failed, fallback to raw
}
}
// 2. Reasonix parser
if (executor === "reasonix") {
const clean = line.replace(ANSI_REGEX, "");
if (REASONIX_THINKING_REGEX.test(clean)) {
return [{ type: "thinking", content: clean, raw: line }];
}
if (REASONIX_TOOL_REGEX.test(clean)) {
return [{ type: "tool_call", content: clean, raw: line }];
}
if (REASONIX_STATS_REGEX.test(clean)) {
return [{ type: "stats", content: clean, raw: line }];
}
return [{ type: "text", content: clean, raw: line }];
}
// 3. Agy or any fallback
return [{
type: "raw",
content: line,
raw: line,
}];
}
/**
* Parse an entire log content string.
*/
export function parseLogContent(content: string, executor: string): ParsedLogEntry[] {
if (!content) return [];
const lines = content.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const result: ParsedLogEntry[] = [];
for (const line of lines) {
result.push(...parseLogLine(line, executor));
}
return result;
}
+472
View File
@@ -0,0 +1,472 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { Buffer } from "node:buffer";
import { startWorkflowWebServer, splitCompleteUtf8, hasSymlinkInPath } from "./workflow-web.js";
import { writeState, appendEvent, attemptLogPath, readState } from "./workflow-state.js";
import type { WorkflowState } from "./workflow-types.js";
// Make request helper
function makeRequest(
url: string,
method = "GET",
headers: Record<string, string> = { "Host": "127.0.0.1:14319" }
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, headers }, (res) => {
let body = "";
res.on("data", (chunk) => { body += chunk; });
res.on("end", () => {
resolve({
status: res.statusCode || 0,
headers: res.headers,
body,
});
});
});
req.on("error", reject);
req.end();
});
}
describe("workflow-web", () => {
let tmpDir: string;
let server: http.Server;
let port = 14319;
let baseUrl = `http://127.0.0.1:${port}`;
let runId = "test-run-123";
let repoRoot = "/tmp/fake-repo-root";
let hash = "75b89b99";
beforeAll(async () => {
// Setup temp workflows root directory
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-test-"));
process.env.AGENT_WORKFLOW_DIR = tmpDir;
const runDir = path.join(tmpDir, hash, runId);
fs.mkdirSync(runDir, { recursive: true });
const mockState: WorkflowState = {
version: "1",
runId,
repoHash: hash,
repoRoot,
executor: "claude",
status: "executing",
maxCycles: 5,
currentCycle: 1,
cycles: [
{
cycleIndex: 1,
startedAt: new Date().toISOString(),
executorAttemptLogs: [`cycle-1-exec.log`],
}
],
plan: {
version: "1",
title: "Test Plan Title",
planMarkdown: "Implement something",
scope: ["src/index.ts"],
acceptanceCriteria: ["AC1"],
verificationCommands: [["npm", "test"]],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
writeState(runDir, mockState);
appendEvent(runDir, { kind: "workflow_started", runId, timestamp: new Date().toISOString() });
// Write mock logs
const logPath = attemptLogPath(runDir, 1, 0);
fs.writeFileSync(logPath, "Hello world line 1\nHello world line 2\n", "utf8");
// Start server
server = await startWorkflowWebServer(port);
});
afterAll(() => {
return new Promise<void>((resolve) => {
server.close(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
resolve();
});
});
});
it("serves HTML assets on /", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/html");
expect(res.body).toContain("智能体工作流控制台");
expect(res.body).toContain("智能体执行监控");
expect(res.body).toContain("全部状态");
expect(res.body).toContain("时间线与日志");
});
it("serves list of runs on /api/runs", async () => {
const res = await makeRequest(`${baseUrl}/api/runs`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("application/json");
const runsList = JSON.parse(res.body);
expect(runsList).toHaveLength(1);
expect(runsList[0].runId).toBe(runId);
expect(runsList[0].plan.title).toBe("Test Plan Title");
});
it("serves individual run state on /api/runs/:runId", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/${runId}`);
expect(res.status).toBe(200);
const state = JSON.parse(res.body);
expect(state.runId).toBe(runId);
expect(state.plan.title).toBe("Test Plan Title");
});
it("serves events on /api/runs/:runId/events", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/${runId}/events`);
expect(res.status).toBe(200);
const events = JSON.parse(res.body);
expect(events).toHaveLength(1);
expect(events[0].kind).toBe("workflow_started");
});
it("serves log files on /api/runs/:runId/logs", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=0`);
expect(res.status).toBe(200);
const logs = JSON.parse(res.body);
expect(logs.content).toBe("Hello world line 1\nHello world line 2\n");
expect(logs.hasMore).toBe(false);
});
it("serves log files paginated/with offset", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=0&offset=6&limit=5`);
expect(res.status).toBe(200);
const logs = JSON.parse(res.body);
expect(logs.content).toBe("world");
expect(logs.nextOffset).toBe(11);
});
it("rejects path traversal in runId", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/..%2F..%2Fsomefile`);
expect(res.status).toBe(404);
});
it("rejects path traversal in logs search params", async () => {
const badRes = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=0&type=..%2F..%2Fetc%2Fpasswd`);
expect(badRes.status).toBe(403);
});
it("rejects non-GET/HEAD methods with 405", async () => {
const res = await makeRequest(`${baseUrl}/`, "POST");
expect(res.status).toBe(405);
expect(res.headers["allow"]).toBe("GET, HEAD");
});
it("supports HEAD request", async () => {
const res = await makeRequest(`${baseUrl}/api/runs`, "HEAD");
expect(res.status).toBe(200);
expect(res.body).toBe("");
});
it("rejects invalid Host header", async () => {
const res = await makeRequest(`${baseUrl}/`, "GET", { "Host": "evil.example" });
expect(res.status).toBe(400);
});
it("rejects invalid Origin header", async () => {
const res = await makeRequest(`${baseUrl}/`, "GET", {
"Host": "127.0.0.1:14319",
"Origin": "http://evil.example"
});
expect(res.status).toBe(403);
});
it("rejects out of bounds query parameters", async () => {
const res1 = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=0&attempt=0`);
expect(res1.status).toBe(400);
const res2 = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1001&attempt=0`);
expect(res2.status).toBe(400);
const res3 = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=-1`);
expect(res3.status).toBe(400);
const res4 = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=0&offset=-5`);
expect(res4.status).toBe(400);
const res5 = await makeRequest(`${baseUrl}/api/runs/${runId}/logs?cycle=1&attempt=0&limit=3`);
expect(res5.status).toBe(400);
});
it("supports HEAD stream check without hanging", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/${runId}/stream`, "HEAD");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("text/event-stream");
});
it("rejects startWorkflowWebServer with invalid port", async () => {
await expect(startWorkflowWebServer(70000)).rejects.toThrow();
await expect(startWorkflowWebServer(0)).rejects.toThrow();
});
// UI contract tests (Finding codex-5-1, codex-5-4)
it("does not contain &sq placeholders in generated HTML", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.body).not.toContain("&sq");
expect(res.body).toContain("`/api/runs/${activeRun.runId}/events`");
expect(res.body).toContain("`/api/runs/${runId}/stream`");
});
it("includes responsive mobile layout CSS for sidebar", async () => {
const res = await makeRequest(`${baseUrl}/`);
expect(res.body).toContain("@media (max-width: 768px)");
expect(res.body).toContain("height: 35vh;");
expect(res.body).toContain("height: 65vh;");
expect(res.body).not.toContain("height: 250px;");
});
// Javascript validity contract test (Finding codex-6-2)
it("generates syntactically valid Javascript that defines resetTimeline and loadLogs", async () => {
const res = await makeRequest(`${baseUrl}/`);
const scriptMatch = res.body.match(/<script>([\s\S]*?)<\/script>/);
expect(scriptMatch).not.toBeNull();
const scriptContent = scriptMatch![1];
// Test that the script can be evaluated without SyntaxError
const vm = require("node:vm");
const script = new vm.Script(scriptContent);
const sandbox = {
window: {},
document: {
getElementById: () => ({ style: {}, addEventListener: () => {} }),
querySelectorAll: () => ([]),
createElement: () => ({ style: {} }),
},
fetch: async () => ({ json: async () => ({}) }),
EventSource: class { addEventListener() {} },
setInterval: () => {},
console: { log: () => {}, error: () => {} }
};
vm.createContext(sandbox);
// Evaluate the script (declarations and initial setup)
expect(() => {
script.runInContext(sandbox);
}).not.toThrow();
// Verify required functions are defined
expect(typeof sandbox.resetTimeline).toBe("function");
expect(typeof sandbox.resetRawLogs).toBe("function");
});
// symlinks containment tests (Finding codex-4-4)
it("rejects symlinks in run listing and direct requests", async () => {
// 1. Create a symlinked hash directory
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-symlink-target-"));
const targetStateFile = path.join(externalDir, "state.json");
const mockExternalState = {
runId: "external-symlink-run",
repoRoot: "/tmp/external-repo",
status: "completed",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(targetStateFile, JSON.stringify(mockExternalState), "utf8");
// Create a symlinked hash under tmpDir
const symlinkHash = path.join(tmpDir, "symlink-hash");
try { fs.unlinkSync(symlinkHash); } catch {}
fs.symlinkSync(externalDir, symlinkHash, "dir");
// 2. Create a symlinked run directory inside safe hash
const symlinkRun = path.join(tmpDir, hash, "symlink-run");
try { fs.unlinkSync(symlinkRun); } catch {}
fs.symlinkSync(externalDir, symlinkRun, "dir");
// 3. Create a symlinked state file inside a real run dir
const symStateRunDir = path.join(tmpDir, hash, "run-with-symlink-state");
try { fs.rmSync(symStateRunDir, { recursive: true, force: true }); } catch {}
fs.mkdirSync(symStateRunDir, { recursive: true });
const symStatePath = path.join(symStateRunDir, "state.json");
try { fs.unlinkSync(symStatePath); } catch {}
fs.symlinkSync(targetStateFile, symStatePath);
// Verify listing rejects all symlinked items
const listRes = await makeRequest(`${baseUrl}/api/runs`);
const list = JSON.parse(listRes.body);
const ids = list.map((r: any) => r.runId);
expect(ids).not.toContain("external-symlink-run");
expect(ids).not.toContain("run-with-symlink-state");
// Verify direct API accesses to symlinked paths fail
const apiRes1 = await makeRequest(`${baseUrl}/api/runs/symlink-run`);
expect(apiRes1.status).toBe(404);
const apiRes2 = await makeRequest(`${baseUrl}/api/runs/run-with-symlink-state`);
expect(apiRes2.status).toBe(404);
// Clean up
fs.rmSync(externalDir, { recursive: true, force: true });
try { fs.unlinkSync(symlinkHash); } catch {}
try { fs.unlinkSync(symlinkRun); } catch {}
try { fs.rmSync(symStateRunDir, { recursive: true, force: true }); } catch {}
});
// Client-contract & SSE tests (Finding codex-4-5)
it("emits initial state snapshot and does not replay historical events over SSE", async () => {
return new Promise<void>((resolve, reject) => {
const req = http.request(`${baseUrl}/api/runs/${runId}/stream`, {
headers: { "Accept": "text/event-stream" }
}, (res) => {
let buffer = "";
let stateReceived = false;
let eventReceived = false;
res.on("data", (chunk) => {
buffer += chunk.toString();
if (buffer.includes("event: state")) {
stateReceived = true;
}
if (buffer.includes("event: event")) {
eventReceived = true;
}
// Once state event is read, test the assertions
if (stateReceived) {
req.destroy();
expect(stateReceived).toBe(true);
expect(eventReceived).toBe(false); // Historical events must not be replayed
resolve();
}
});
res.on("error", reject);
});
req.end();
});
});
it("closes SSE connection when state transitions to non-executing status", async () => {
const runDir = path.join(tmpDir, hash, runId);
return new Promise<void>((resolve, reject) => {
const req = http.request(`${baseUrl}/api/runs/${runId}/stream`, {
headers: { "Accept": "text/event-stream" }
}, (res) => {
res.on("data", (chunk) => {
const state = readState(runDir);
if (state && state.status === "executing") {
state.status = "completed";
writeState(runDir, state);
}
});
res.on("end", () => {
// SSE closed by server on terminal state change
resolve();
});
res.on("error", reject);
});
req.end();
});
});
});
describe("splitCompleteUtf8 helper", () => {
it("splits clean buffers fully", () => {
const buf = Buffer.from("hello world", "utf8");
const { complete, residual } = splitCompleteUtf8(buf);
expect(complete.toString("utf8")).toBe("hello world");
expect(residual.length).toBe(0);
});
it("correctly identifies incomplete UTF-8 characters at the end", () => {
const char = "中";
const buf = Buffer.from(char, "utf8");
const incompleteBuf = buf.subarray(0, 2);
const { complete, residual } = splitCompleteUtf8(incompleteBuf);
expect(complete.length).toBe(0);
expect(residual).toEqual(incompleteBuf);
const { complete: complete2, residual: residual2 } = splitCompleteUtf8(buf);
expect(complete2.toString("utf8")).toBe("中");
expect(residual2.length).toBe(0);
});
});
describe("UTF-8 boundary and EOF API progression", () => {
let tmpDir: string;
let server: http.Server;
let baseUrl: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-utf8-"));
process.env.AGENT_WORKFLOW_DIR = tmpDir;
const runId = "utf8-run";
const runDir = path.join(tmpDir, "hash1", runId);
fs.mkdirSync(runDir, { recursive: true });
const mockState: WorkflowState = {
version: "1", runId, repoHash: "hash1", repoRoot: "/tmp",
executor: "claude", status: "executing", maxCycles: 1, currentCycle: 1,
cycles: [{ cycleIndex: 1, startedAt: new Date().toISOString(), executorAttemptLogs: ["cycle-1-exec.log"] }],
plan: { version: "1", title: "", planMarkdown: "", scope: [], acceptanceCriteria: [], verificationCommands: [] },
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
};
writeState(runDir, mockState);
// Write incomplete UTF-8 at EOF (3 bytes of a 4-byte emoji 🚀)
// 🚀 is F0 9F 9A 80
const logPath = attemptLogPath(runDir, 1, 0);
const incompleteEmoji = Buffer.from([0xF0, 0x9F, 0x9A]);
fs.writeFileSync(logPath, incompleteEmoji);
server = await startWorkflowWebServer(14320);
baseUrl = `http://127.0.0.1:14320`;
});
afterAll(() => {
return new Promise<void>((resolve) => {
server.close(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
resolve();
});
});
});
it("advances nextOffset without infinite loops when reading incomplete UTF-8 at EOF", async () => {
const res = await makeRequest(`${baseUrl}/api/runs/utf8-run/logs?cycle=1&attempt=0&offset=0&limit=4`);
const data = JSON.parse(res.body);
// Since it's incomplete and at EOF, it should just decode to replacement character (\\ufffd) and advance offset
expect(data.nextOffset).toBe(3); // advanced by 3 bytes
expect(data.hasMore).toBe(false); // nextOffset == fileSize (3 == 3)
});
});
describe("hasSymlinkInPath", () => {
it("detects symlinks at various levels", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "symlink-test-"));
const dirA = path.join(tempRoot, "dirA");
fs.mkdirSync(dirA);
const realFile = path.join(dirA, "real.txt");
fs.writeFileSync(realFile, "hello");
expect(hasSymlinkInPath(realFile, tempRoot)).toBe(false);
// Symlinked file
const symlinkFile = path.join(dirA, "sym.txt");
fs.symlinkSync(realFile, symlinkFile);
expect(hasSymlinkInPath(symlinkFile, tempRoot)).toBe(true);
// Symlinked directory
const symlinkDir = path.join(tempRoot, "dirB");
fs.symlinkSync(dirA, symlinkDir, "dir");
expect(hasSymlinkInPath(path.join(symlinkDir, "real.txt"), tempRoot)).toBe(true);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
});
+661
View File
@@ -0,0 +1,661 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { Buffer } from "node:buffer";
import {
workflowsRoot,
findRunDir,
stateFilePath,
eventsFilePath,
attemptLogPath,
readState
} from "./workflow-state.js";
import { getAssetsHtml } from "./workflow-web-assets.js";
// Helper to write JSON responses
function sendJson(res: http.ServerResponse, status: number, obj: any) {
res.writeHead(status, {
"Content-Type": "application/json",
});
res.end(JSON.stringify(obj));
}
// Helper to send HTML
function sendHtml(res: http.ServerResponse, status: number, html: string) {
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
// Helper to send plain errors
function sendError(res: http.ServerResponse, status: number, message: string) {
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
res.end(message);
}
export function splitCompleteUtf8(buffer: Buffer): { complete: Buffer; residual: Buffer } {
const len = buffer.length;
if (len === 0) return { complete: buffer, residual: Buffer.alloc(0) };
const checkLen = Math.min(len, 4);
for (let i = 1; i <= checkLen; i++) {
const byte = buffer[len - i];
if ((byte & 0xC0) === 0xC0) {
let expectedBytes = 0;
if ((byte & 0xE0) === 0xC0) expectedBytes = 2;
else if ((byte & 0xF0) === 0xE0) expectedBytes = 3;
else if ((byte & 0xF8) === 0xF0) expectedBytes = 4;
if (i < expectedBytes) {
const complete = buffer.subarray(0, len - i) as Buffer;
const residual = buffer.subarray(len - i) as Buffer;
return { complete, residual };
}
break;
}
if ((byte & 0x80) === 0x00) {
break;
}
}
return { complete: buffer, residual: Buffer.alloc(0) as Buffer };
}
/**
* Verify that no segment of targetPath from rootDir downwards is a symlink.
* Blocks all symlinks (including in-root symlink aliases).
*/
export function hasSymlinkInPath(targetPath: string, rootDir: string): boolean {
try {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetPath);
const relative = path.relative(resolvedRoot, resolvedTarget);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return true; // Out of bounds
}
let current = resolvedRoot;
if (relative === "") {
try {
return fs.lstatSync(current).isSymbolicLink();
} catch (err: any) {
if (err.code === 'ENOENT') return false;
return true;
}
}
const segments = relative.split(path.sep);
for (const segment of segments) {
current = path.join(current, segment);
try {
if (fs.lstatSync(current).isSymbolicLink()) {
return true;
}
} catch (err: any) {
if (err.code === 'ENOENT') return false;
return true;
}
}
return false;
} catch {
return true; // Reject on stat error
}
}
function getSafeRunDir(runId: string): string | undefined {
if (!/^[a-zA-Z0-9_\-\:]+$/.test(runId)) {
return undefined;
}
const dir = findRunDir(runId);
if (!dir) return undefined;
const root = workflowsRoot();
if (hasSymlinkInPath(dir, root)) {
return undefined;
}
return path.resolve(dir);
}
function isSafeLogPath(logPath: string, runDir: string): boolean {
if (hasSymlinkInPath(logPath, runDir)) {
return false;
}
const filename = path.basename(logPath);
return (
/^cycle-\d+-exec\.log$/.test(filename) ||
/^cycle-\d+-exec-retry-\d+\.log$/.test(filename) ||
/^cycle-\d+-agy\.log$/.test(filename)
);
}
function listAllRuns(): any[] {
const root = workflowsRoot();
try {
if (!fs.existsSync(root) || fs.lstatSync(root).isSymbolicLink()) return [];
} catch {
return [];
}
const results: any[] = [];
let repoHashes: string[] = [];
try {
repoHashes = fs.readdirSync(root);
} catch {
return [];
}
for (const hash of repoHashes) {
const hashDir = path.join(root, hash);
try {
const hashStat = fs.lstatSync(hashDir);
if (hashStat.isSymbolicLink() || !hashStat.isDirectory()) continue;
const runIds = fs.readdirSync(hashDir);
for (const runId of runIds) {
if (!/^[a-zA-Z0-9_\-\:]+$/.test(runId)) continue;
const runDir = path.join(hashDir, runId);
const runStat = fs.lstatSync(runDir);
if (runStat.isSymbolicLink() || !runStat.isDirectory()) continue;
const stateFile = stateFilePath(runDir);
if (!fs.existsSync(stateFile) || fs.lstatSync(stateFile).isSymbolicLink()) continue;
const fd = fs.openSync(stateFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const stateStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
const state = JSON.parse(stateStr);
if (state && state.runId && state.status) {
results.push({
runId: state.runId,
repoRoot: state.repoRoot,
status: state.status,
executor: state.executor,
currentCycle: state.currentCycle,
maxCycles: state.maxCycles,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
plan: {
title: state.plan?.title || "",
},
});
}
}
} catch {
// Ignore corrupt/disappearing/symlinked entries
}
}
results.sort((a, b) => {
const timeA = new Date(a.updatedAt || a.createdAt || 0).getTime();
const timeB = new Date(b.updatedAt || b.createdAt || 0).getTime();
return timeB - timeA;
});
return results;
}
function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, runId: string) {
const runDir = getSafeRunDir(runId);
if (!runDir) {
return sendError(res, 404, "Run directory not found or invalid run ID");
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
let pollInterval: NodeJS.Timeout;
let heartbeatInterval: NodeJS.Timeout;
let watcher: fs.FSWatcher | undefined;
let cleanedUp = false;
// Single idempotent cleanup path declared before first poll
const cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(pollInterval);
clearInterval(heartbeatInterval);
if (watcher) {
try {
watcher.close();
} catch {
// ignore
}
}
res.end();
};
function pushEvent(event: string, data: string) {
if (cleanedUp) return;
res.write(`event: ${event}\n`);
const lines = data.split("\n");
for (const line of lines) {
res.write(`data: ${line}\n`);
}
res.write("\n");
}
let lastStateStr = "";
let lastEventsLineCount = 0;
let currentLogPath = "";
let logBytesRead = 0;
let logResidualBuffer = Buffer.alloc(0);
// Initialize historical event cursor to the current size to avoid replaying history over SSE
const eventsFile = eventsFilePath(runDir);
if (fs.existsSync(eventsFile) && !fs.lstatSync(eventsFile).isSymbolicLink()) {
try {
const fd = fs.openSync(eventsFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const eventsStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
const lines = eventsStr.split("\n").filter(l => l.trim() !== "");
lastEventsLineCount = lines.length;
} catch {
// ignore
}
}
// Helper to resolve active log path from state
const getActiveLogPath = (stateObj: any) => {
if (!stateObj || stateObj.currentCycle === undefined) return "";
const cycle = stateObj.currentCycle;
let attempt = 0;
const progressFile = path.join(runDir, ".progress.json");
if (fs.existsSync(progressFile) && !fs.lstatSync(progressFile).isSymbolicLink()) {
try {
const fd = fs.openSync(progressFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const progStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
const prog = JSON.parse(progStr);
if (prog && prog.cycleIndex === cycle && prog.attemptIndex !== undefined) {
attempt = prog.attemptIndex;
}
} catch {
// ignore
}
} else {
if (stateObj.cycles) {
const cyRec = stateObj.cycles.find((cy: any) => cy.cycleIndex === cycle);
if (cyRec && cyRec.executorAttemptLogs) {
attempt = Math.max(0, cyRec.executorAttemptLogs.length - 1);
}
}
}
return attemptLogPath(runDir, cycle, attempt);
};
const poll = () => {
if (cleanedUp) return;
try {
const stateFile = stateFilePath(runDir);
let stateObj: any = null;
if (fs.existsSync(stateFile) && !fs.lstatSync(stateFile).isSymbolicLink()) {
const fd = fs.openSync(stateFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const stateStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
if (stateStr !== lastStateStr) {
lastStateStr = stateStr;
pushEvent("state", stateStr);
}
try {
stateObj = JSON.parse(stateStr);
} catch {
// ignore
}
}
if (fs.existsSync(eventsFile) && !fs.lstatSync(eventsFile).isSymbolicLink()) {
const fd = fs.openSync(eventsFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const eventsStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
const lines = eventsStr.split("\n").filter(l => l.trim() !== "");
if (lines.length > lastEventsLineCount) {
const newLines = lines.slice(lastEventsLineCount);
lastEventsLineCount = lines.length;
for (const line of newLines) {
pushEvent("event", line);
}
}
}
if (stateObj) {
const activeLog = getActiveLogPath(stateObj);
if (activeLog && activeLog !== currentLogPath) {
currentLogPath = activeLog;
logBytesRead = 0;
logResidualBuffer = Buffer.alloc(0);
if (fs.existsSync(activeLog) && !fs.lstatSync(activeLog).isSymbolicLink()) {
logBytesRead = fs.statSync(activeLog).size;
}
pushEvent("log_rotation", JSON.stringify({
cycleIndex: stateObj.currentCycle,
attemptIndex: stateObj.cycles && stateObj.cycles[stateObj.currentCycle - 1] && stateObj.cycles[stateObj.currentCycle - 1].executorAttemptLogs ? Math.max(0, stateObj.cycles[stateObj.currentCycle - 1].executorAttemptLogs.length - 1) : 0,
logFilePath: activeLog,
}));
}
}
if (currentLogPath && fs.existsSync(currentLogPath) && !fs.lstatSync(currentLogPath).isSymbolicLink()) {
const stat = fs.statSync(currentLogPath);
if (stat.size < logBytesRead) {
logBytesRead = 0;
logResidualBuffer = Buffer.alloc(0);
}
if (stat.size > logBytesRead) {
const sizeToRead = stat.size - logBytesRead;
const buffer = Buffer.alloc(sizeToRead);
const fd = fs.openSync(currentLogPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
fs.readSync(fd, buffer, 0, sizeToRead, logBytesRead);
fs.closeSync(fd);
logBytesRead += sizeToRead;
const combined = Buffer.concat([logResidualBuffer, buffer]) as Buffer;
const { complete, residual } = splitCompleteUtf8(combined);
logResidualBuffer = residual as any;
if (complete.length > 0) {
pushEvent("log_append", complete.toString("utf8"));
}
}
}
// Close the connection when execution reaches a terminal/non-executing state
if (stateObj && stateObj.status !== "executing") {
cleanup();
}
} catch (err) {
console.error("Error polling run updates:", err);
}
};
// Pre-determine logBytesRead of current log before first state check to avoid replaying history
try {
const stateFile = stateFilePath(runDir);
if (fs.existsSync(stateFile) && !fs.lstatSync(stateFile).isSymbolicLink()) {
const fd = fs.openSync(stateFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const stateStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
const stateObj = JSON.parse(stateStr);
currentLogPath = getActiveLogPath(stateObj);
if (currentLogPath && fs.existsSync(currentLogPath) && !fs.lstatSync(currentLogPath).isSymbolicLink()) {
logBytesRead = fs.statSync(currentLogPath).size;
}
}
} catch {
// ignore
}
// First poll runs before interval/watcher is initialized
poll();
if (!cleanedUp) {
try {
watcher = fs.watch(runDir, () => {
poll();
});
} catch {
// ignore
}
pollInterval = setInterval(poll, 250);
// Heartbeats
heartbeatInterval = setInterval(() => {
if (!cleanedUp) {
res.write(":\n\n");
}
}, 15000);
}
req.on("close", cleanup);
req.on("error", cleanup);
}
export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.reject(new Error("Port must be between 1 and 65535"));
}
return new Promise<http.Server>((resolve, reject) => {
const server = http.createServer((req, res) => {
// 1. Host validation
const host = req.headers.host || "";
if (!/^(localhost|127\.0\.0\.1)(:\d+)?$/i.test(host)) {
return sendError(res, 400, "Bad Request: Invalid Host header");
}
// 2. Origin validation
const origin = req.headers.origin;
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
return sendError(res, 403, "Forbidden: Invalid Origin");
}
// 3. Referer validation
const referer = req.headers.referer;
if (referer) {
try {
const parsedReferer = new URL(referer);
if (!/^(localhost|127\.0\.0\.1)$/i.test(parsedReferer.hostname)) {
return sendError(res, 403, "Forbidden: Invalid Referer");
}
} catch {
return sendError(res, 400, "Bad Request: Malformed Referer header");
}
}
const method = req.method;
if (method !== "GET" && method !== "HEAD") {
res.writeHead(405, { "Allow": "GET, HEAD" });
return res.end("Method Not Allowed");
}
const parsedUrl = new URL(req.url || "", `http://${host}`);
const pathname = parsedUrl.pathname;
if (pathname === "/" || pathname === "/index.html") {
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end();
}
return sendHtml(res, 200, getAssetsHtml());
}
if (pathname === "/api/runs") {
const list = listAllRuns();
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return sendJson(res, 200, list);
}
const runMatch = pathname.match(/^\/api\/runs\/([a-zA-Z0-9_\-\:]+)$/);
if (runMatch) {
const runId = runMatch[1];
const runDir = getSafeRunDir(runId);
if (!runDir) {
return sendError(res, 404, "Run not found");
}
const stateFile = stateFilePath(runDir);
if (hasSymlinkInPath(stateFile, runDir)) {
return sendError(res, 404, "Run not found");
}
let stateStr = "";
try {
const fd = fs.openSync(stateFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
stateStr = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
} catch {
return sendError(res, 404, "Run state file is missing or corrupt");
}
let state: any;
try {
state = JSON.parse(stateStr);
} catch {
return sendError(res, 404, "Run state file is missing or corrupt");
}
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return sendJson(res, 200, state);
}
const eventsMatch = pathname.match(/^\/api\/runs\/([a-zA-Z0-9_\-\:]+)\/events$/);
if (eventsMatch) {
const runId = eventsMatch[1];
const runDir = getSafeRunDir(runId);
if (!runDir) {
return sendError(res, 404, "Run not found");
}
const eventsFile = eventsFilePath(runDir);
if (hasSymlinkInPath(eventsFile, runDir)) {
return sendError(res, 404, "Events file is missing or contains symlinks");
}
let events: any[] = [];
if (fs.existsSync(eventsFile) && !fs.lstatSync(eventsFile).isSymbolicLink()) {
try {
const fd = fs.openSync(eventsFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const content = fs.readFileSync(fd, "utf8");
fs.closeSync(fd);
events = content
.split("\n")
.filter(line => line.trim() !== "")
.map(line => JSON.parse(line));
} catch {
// ignore
}
}
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return sendJson(res, 200, events);
}
const logsMatch = pathname.match(/^\/api\/runs\/([a-zA-Z0-9_\-\:]+)\/logs$/);
if (logsMatch) {
const runId = logsMatch[1];
const runDir = getSafeRunDir(runId);
if (!runDir) {
return sendError(res, 404, "Run not found");
}
const cycleStr = parsedUrl.searchParams.get("cycle") || "1";
const attemptStr = parsedUrl.searchParams.get("attempt") || "0";
const offsetStr = parsedUrl.searchParams.get("offset") || "0";
const limitStr = parsedUrl.searchParams.get("limit") || "100000";
const cycle = parseInt(cycleStr, 10);
const attempt = parseInt(attemptStr, 10);
const offset = parseInt(offsetStr, 10);
const limit = parseInt(limitStr, 10);
if (
!/^\d+$/.test(cycleStr) || cycle < 1 || cycle > 1000 ||
!/^\d+$/.test(attemptStr) || attempt < 0 || attempt > 1000 ||
!/^\d+$/.test(offsetStr) || offset < 0 || offset > 100 * 1024 * 1024 ||
!/^\d+$/.test(limitStr) || limit < 4 || limit > 10 * 1024 * 1024
) {
return sendError(res, 400, "Bad Request: Invalid numeric parameters");
}
const type = parsedUrl.searchParams.get("type") || "exec";
if (type !== "exec" && type !== "agy") {
return sendError(res, 403, "Access to requested log file is forbidden");
}
let logPath = "";
if (type === "agy") {
logPath = path.join(runDir, `cycle-${cycle}-agy.log`);
} else {
logPath = attemptLogPath(runDir, cycle, attempt);
}
if (!isSafeLogPath(logPath, runDir)) {
return sendError(res, 403, "Access to requested log file is forbidden");
}
if (!fs.existsSync(logPath)) {
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return sendJson(res, 200, { content: "", nextOffset: 0, size: 0, hasMore: false });
}
let logContent = "";
let fileSize = 0;
let nextOffset = offset;
try {
const stat = fs.statSync(logPath);
fileSize = stat.size;
const start = Math.min(offset, fileSize);
const end = Math.min(start + limit, fileSize);
const readLength = end - start;
if (readLength > 0) {
const fd = fs.openSync(logPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const buffer = Buffer.alloc(readLength);
fs.readSync(fd, buffer, 0, readLength, start);
fs.closeSync(fd);
const { complete } = splitCompleteUtf8(buffer);
if (complete.length === 0) {
logContent = buffer.toString("utf8");
nextOffset = start + buffer.length;
} else {
logContent = complete.toString("utf8");
nextOffset = start + complete.length;
}
} else {
nextOffset = end;
}
} catch (err: any) {
return sendError(res, 500, `Error reading log file: ${err.message}`);
}
if (method === "HEAD") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end();
}
return sendJson(res, 200, {
content: logContent,
nextOffset,
size: fileSize,
hasMore: nextOffset < fileSize,
});
}
const streamMatch = pathname.match(/^\/api\/runs\/([a-zA-Z0-9_\-\:]+)\/stream$/);
if (streamMatch) {
const runId = streamMatch[1];
if (method === "HEAD") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
return res.end();
}
return handleSseStream(req, res, runId);
}
return sendError(res, 404, "Not Found");
});
server.on("error", (err: any) => {
reject(err);
});
server.listen(port, "127.0.0.1", () => {
process.stdout.write(`Local web console started.\nAccess URL: http://127.0.0.1:${port}/\n`);
resolve(server);
});
});
}