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
+13
View File
@@ -172,6 +172,19 @@ agent-workflow logs --run <run-id> --follow
agent-workflow logs --run <run-id> --tail 200 agent-workflow logs --run <run-id> --tail 200
``` ```
### `agent-workflow web`
Start a local read-only web server to monitor agent executions, runs, cycles, and logs in real-time.
```bash
agent-workflow web [--port <n>]
```
- **`--port <n>`** — specify a custom port to listen on (default: `4317`).
- **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local.
- **Observability Boundaries & Limits** — The console is completely read-only and provides no endpoints to modify or control workflows. It directly parses and displays the execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console.
- **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently.
### `agent-workflow abort` ### `agent-workflow abort`
Terminate an active workflow. Terminate an active workflow.
+3
View File
@@ -108,10 +108,13 @@ Executor has been running for ~2m. Current activity: Editing src/parser.ts (PID
- Invite the user once after the Run starts to watch live output independently; do not repeat this command in every progress update: - Invite the user once after the Run starts to watch live output independently; do not repeat this command in every progress update:
``` ```
Watch live: agent-workflow logs --run <run-id> --follow Watch live: agent-workflow logs --run <run-id> --follow
Or view Web console: agent-workflow web [--port <port>] (defaults to http://127.0.0.1:4317/)
``` ```
Start that `logs --follow` command at most once for a given Run in the current Codex thread. If it reports that a follower is already active, do not retry the same command; use `agent-workflow status --run <run-id> --json` for progress instead. Start that `logs --follow` command at most once for a given Run in the current Codex thread. If it reports that a follower is already active, do not retry the same command; use `agent-workflow status --run <run-id> --json` for progress instead.
The `agent-workflow web` command starts a local read-only web server that reads workflow state files without taking follower locks or interrupting the main execution loop. It only binds to the local loopback interface (127.0.0.1) for safety, as logs can contain sensitive code and environment details. The web interface displays status, execution events, cycle summaries, and live log updates. The console displays the CLI's actual stdout/stderr outputs and parsed structured activity; it cannot reconstruct unlogged private model reasoning.
`timeoutSeconds` is a consecutive inactivity timeout. Observable executor output refreshes the deadline, so do not treat total runtime beyond that value as a timeout while activity continues. `timeoutSeconds` is a consecutive inactivity timeout. Observable executor output refreshes the deadline, so do not treat total runtime beyond that value as a timeout while activity continues.
### `awaiting_scope_resolution` ### `awaiting_scope_resolution`
+12 -1
View File
@@ -24,6 +24,7 @@ import {
} from "./workflow-engine.js"; } from "./workflow-engine.js";
import { WorkflowConfigError } from "./workflow-config.js"; import { WorkflowConfigError } from "./workflow-config.js";
import { WorkflowLockError } from "./workflow-state.js"; import { WorkflowLockError } from "./workflow-state.js";
import { startWorkflowWebServer } from "./workflow-web.js";
async function main() { async function main() {
let args: WorkflowArgs; let args: WorkflowArgs;
@@ -91,12 +92,22 @@ async function main() {
}); });
break; break;
case "web":
await startWorkflowWebServer(args.port);
// Keep the process alive
await new Promise(() => {});
break;
default: { default: {
const _exhaustive: never = args; const _exhaustive: never = args;
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`); 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) { if (err instanceof WorkflowEngineError) {
process.stderr.write(`Error: ${err.message}\n`); process.stderr.write(`Error: ${err.message}\n`);
process.exit(err.exitCode); process.exit(err.exitCode);
+5
View File
@@ -134,6 +134,7 @@ export type {
WorkflowAbortArgs, WorkflowAbortArgs,
WorkflowLogsArgs, WorkflowLogsArgs,
WorkflowExtendArgs, WorkflowExtendArgs,
WorkflowWebArgs,
} from "./workflow-args.js"; } from "./workflow-args.js";
export type { export type {
@@ -202,3 +203,7 @@ export {
export type { export type {
VcsProvider, VcsProvider,
} from "./vcs-provider.js"; } 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. * 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 { export interface WorkflowStartArgs {
sub: "start"; sub: "start";
@@ -60,6 +60,11 @@ export interface WorkflowExtendArgs {
additionalCycles: number; additionalCycles: number;
} }
export interface WorkflowWebArgs {
sub: "web";
port?: number;
}
export type WorkflowArgs = export type WorkflowArgs =
| WorkflowStartArgs | WorkflowStartArgs
| WorkflowReviewArgs | WorkflowReviewArgs
@@ -69,7 +74,8 @@ export type WorkflowArgs =
| WorkflowStatusArgs | WorkflowStatusArgs
| WorkflowAbortArgs | WorkflowAbortArgs
| WorkflowLogsArgs | WorkflowLogsArgs
| WorkflowExtendArgs; | WorkflowExtendArgs
| WorkflowWebArgs;
export class WorkflowArgsError extends Error { export class WorkflowArgsError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -104,6 +110,7 @@ Usage:
agent-workflow status --run <run-id> [--json] agent-workflow status --run <run-id> [--json]
agent-workflow abort --run <run-id> --reason <text> agent-workflow abort --run <run-id> --reason <text>
agent-workflow logs --run <run-id> [--follow] [--tail <lines>] 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). 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>
agent-workflow status --run <id> --json agent-workflow status --run <id> --json
agent-workflow abort --run <id> --reason "changed requirements" agent-workflow abort --run <id> --reason "changed requirements"
agent-workflow web
agent-workflow web --port 4317
`.trimStart(); `.trimStart();
export function workflowUsage(exitCode = 0): never { export function workflowUsage(exitCode = 0): never {
@@ -331,6 +340,25 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
return { sub: "extend", runId, additionalCycles }; 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: default:
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`); 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);
});
});
}