feat: add local workflow web console
This commit is contained in:
+12
-1
@@ -24,6 +24,7 @@ import {
|
||||
} from "./workflow-engine.js";
|
||||
import { WorkflowConfigError } from "./workflow-config.js";
|
||||
import { WorkflowLockError } from "./workflow-state.js";
|
||||
import { startWorkflowWebServer } from "./workflow-web.js";
|
||||
|
||||
async function main() {
|
||||
let args: WorkflowArgs;
|
||||
@@ -91,12 +92,22 @@ async function main() {
|
||||
});
|
||||
break;
|
||||
|
||||
case "web":
|
||||
await startWorkflowWebServer(args.port);
|
||||
// Keep the process alive
|
||||
await new Promise(() => {});
|
||||
break;
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = args;
|
||||
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err && err.code === "EADDRINUSE") {
|
||||
process.stderr.write(`Error: Port is already in use.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err instanceof WorkflowEngineError) {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exit(err.exitCode);
|
||||
|
||||
@@ -134,6 +134,7 @@ export type {
|
||||
WorkflowAbortArgs,
|
||||
WorkflowLogsArgs,
|
||||
WorkflowExtendArgs,
|
||||
WorkflowWebArgs,
|
||||
} from "./workflow-args.js";
|
||||
|
||||
export type {
|
||||
@@ -202,3 +203,7 @@ export {
|
||||
export type {
|
||||
VcsProvider,
|
||||
} from "./vcs-provider.js";
|
||||
|
||||
export {
|
||||
startWorkflowWebServer,
|
||||
} from "./workflow-web.js";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend";
|
||||
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend" | "web";
|
||||
|
||||
export interface WorkflowStartArgs {
|
||||
sub: "start";
|
||||
@@ -60,6 +60,11 @@ export interface WorkflowExtendArgs {
|
||||
additionalCycles: number;
|
||||
}
|
||||
|
||||
export interface WorkflowWebArgs {
|
||||
sub: "web";
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export type WorkflowArgs =
|
||||
| WorkflowStartArgs
|
||||
| WorkflowReviewArgs
|
||||
@@ -69,7 +74,8 @@ export type WorkflowArgs =
|
||||
| WorkflowStatusArgs
|
||||
| WorkflowAbortArgs
|
||||
| WorkflowLogsArgs
|
||||
| WorkflowExtendArgs;
|
||||
| WorkflowExtendArgs
|
||||
| WorkflowWebArgs;
|
||||
|
||||
export class WorkflowArgsError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -104,6 +110,7 @@ Usage:
|
||||
agent-workflow status --run <run-id> [--json]
|
||||
agent-workflow abort --run <run-id> --reason <text>
|
||||
agent-workflow logs --run <run-id> [--follow] [--tail <lines>]
|
||||
agent-workflow web [--port <n>]
|
||||
|
||||
Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 for stdout).
|
||||
|
||||
@@ -122,6 +129,8 @@ Examples:
|
||||
agent-workflow status --run <id>
|
||||
agent-workflow status --run <id> --json
|
||||
agent-workflow abort --run <id> --reason "changed requirements"
|
||||
agent-workflow web
|
||||
agent-workflow web --port 4317
|
||||
`.trimStart();
|
||||
|
||||
export function workflowUsage(exitCode = 0): never {
|
||||
@@ -331,6 +340,25 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
return { sub: "extend", runId, additionalCycles };
|
||||
}
|
||||
|
||||
case "web": {
|
||||
let port: number | undefined;
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
if (arg === "--port") {
|
||||
const p = parsePositiveInt(arg, requireValue(arg, rest));
|
||||
if (p < 1 || p > 65535) {
|
||||
throw new WorkflowArgsError("--port must be between 1 and 65535");
|
||||
}
|
||||
port = p;
|
||||
} else if (arg === "-h" || arg === "--help") {
|
||||
workflowUsage(0);
|
||||
} else {
|
||||
throw new WorkflowArgsError(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return { sub: "web", port };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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