feat: add local workflow web console
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user