parent
6950401c06
commit
082f91aa80
|
|
@ -1,152 +1,123 @@
|
||||||
import { buildCommand, numberParser } from '@stricli/core'
|
import { buildCommand, numberParser } from "@stricli/core";
|
||||||
import type { LocalContext } from './context.js'
|
import type { LocalContext } from "./context.js";
|
||||||
|
|
||||||
export const command = buildCommand({
|
export const command = buildCommand({
|
||||||
docs: {
|
docs: {
|
||||||
brief: 'Start the ACP proxy server',
|
brief: "Start the ACP proxy server",
|
||||||
fullDescription:
|
fullDescription:
|
||||||
'Starts a WebSocket proxy server that bridges clients to ACP agents. ' +
|
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
|
||||||
'The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n' +
|
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||||
'Use -- to pass arguments to the agent:\n' +
|
"Use -- to pass arguments to the agent:\n" +
|
||||||
' acp-link /path/to/agent -- --verbose --model gpt-4\n\n' +
|
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||||
'Use --manager to start the Manager Web UI instead:\n' +
|
"Use --manager to start the Manager Web UI instead:\n" +
|
||||||
' acp-link --manager\n\n' +
|
" acp-link --manager\n\n" +
|
||||||
'For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.',
|
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
flags: {
|
flags: {
|
||||||
port: {
|
port: {
|
||||||
kind: 'parsed',
|
kind: "parsed",
|
||||||
parse: numberParser,
|
parse: numberParser,
|
||||||
brief: 'Port to listen on',
|
brief: "Port to listen on",
|
||||||
default: '9315' as any,
|
default: "9315",
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
kind: 'parsed',
|
kind: "parsed",
|
||||||
parse: String,
|
parse: String,
|
||||||
brief: 'Host to bind to (use 0.0.0.0 for remote access)',
|
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||||
default: 'localhost' as any,
|
default: "localhost",
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
debug: {
|
debug: {
|
||||||
kind: 'boolean',
|
kind: "boolean",
|
||||||
brief: 'Enable debug logging to file',
|
brief: "Enable debug logging to file",
|
||||||
default: false as any,
|
default: false,
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
'no-auth': {
|
"no-auth": {
|
||||||
kind: 'boolean',
|
kind: "boolean",
|
||||||
brief: 'DANGEROUS: Disable authentication (not recommended)',
|
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||||
default: false as any,
|
default: false,
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
https: {
|
https: {
|
||||||
kind: 'boolean',
|
kind: "boolean",
|
||||||
brief: 'Enable HTTPS with auto-generated self-signed certificate',
|
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||||
default: false as any,
|
default: false,
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
manager: {
|
manager: {
|
||||||
kind: 'boolean',
|
kind: "boolean",
|
||||||
brief: 'Start Manager Web UI (no proxy)',
|
brief: "Start Manager Web UI (no proxy)",
|
||||||
default: false as any,
|
default: false,
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
kind: 'parsed',
|
kind: "parsed",
|
||||||
parse: (value: string) => {
|
parse: (value: string) => {
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||||
throw new Error(
|
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
|
||||||
`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
},
|
},
|
||||||
brief: 'Channel group ID for RCS registration (env: ACP_RCS_GROUP)',
|
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
positional: {
|
positional: {
|
||||||
kind: 'array',
|
kind: "array",
|
||||||
parameter: {
|
parameter: {
|
||||||
brief: 'Agent command and arguments (use -- before agent flags)',
|
brief: "Agent command and arguments (use -- before agent flags)",
|
||||||
parse: String,
|
parse: String,
|
||||||
placeholder: 'command',
|
placeholder: "command",
|
||||||
optional: true as any,
|
|
||||||
},
|
},
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
func: async function (
|
func: async function (
|
||||||
this: LocalContext,
|
this: LocalContext,
|
||||||
flags: {
|
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
||||||
port: number
|
|
||||||
host: string
|
|
||||||
debug: boolean
|
|
||||||
'no-auth': boolean
|
|
||||||
https: boolean
|
|
||||||
manager: boolean
|
|
||||||
group: string | undefined
|
|
||||||
},
|
|
||||||
...args: readonly string[]
|
...args: readonly string[]
|
||||||
) {
|
) {
|
||||||
const port = flags.port
|
const port = flags.port;
|
||||||
const host = flags.host
|
const host = flags.host;
|
||||||
const debug = flags.debug
|
const debug = flags.debug;
|
||||||
const noAuth = flags['no-auth']
|
const noAuth = flags["no-auth"];
|
||||||
const https = flags.https
|
const https = flags.https;
|
||||||
const manager = flags.manager
|
const manager = flags.manager;
|
||||||
const group = flags.group
|
const group = flags.group;
|
||||||
|
|
||||||
// Manager mode: start web UI only, no proxy
|
// Manager mode: start web UI only, no proxy
|
||||||
if (manager) {
|
if (manager) {
|
||||||
const { startManager } = await import('../manager/index.js')
|
const { startManager } = await import("../manager/index.js");
|
||||||
await startManager(port)
|
await startManager(port);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy mode: agent command is required
|
// Proxy mode: agent command is required
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
console.error('Error: agent command is required (or use --manager)')
|
console.error("Error: agent command is required (or use --manager)");
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const [command, ...agentArgs] = args
|
const [command, ...agentArgs] = args;
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd();
|
||||||
|
|
||||||
// Determine auth token
|
// Determine auth token
|
||||||
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
||||||
let token: string | undefined
|
let token: string | undefined;
|
||||||
if (noAuth) {
|
if (noAuth) {
|
||||||
console.warn(
|
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
|
||||||
'⚠️ WARNING: Authentication disabled. This is dangerous for remote access!',
|
token = undefined;
|
||||||
)
|
|
||||||
token = undefined
|
|
||||||
} else {
|
} else {
|
||||||
token = process.env.ACP_AUTH_TOKEN
|
token = process.env.ACP_AUTH_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Auto-generate random token
|
// Auto-generate random token
|
||||||
const { randomBytes } = await import('node:crypto')
|
const { randomBytes } = await import("node:crypto");
|
||||||
token = randomBytes(32).toString('hex')
|
token = randomBytes(32).toString("hex");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
const { initLogger } = await import('../logger.js')
|
const { initLogger } = await import("../logger.js");
|
||||||
initLogger({ debug })
|
initLogger({ debug });
|
||||||
|
|
||||||
// Import and run the server
|
// Import and run the server
|
||||||
const { startServer } = await import('../server.js')
|
const { startServer } = await import("../server.js");
|
||||||
await startServer({
|
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
|
||||||
port,
|
|
||||||
host,
|
|
||||||
command: command!,
|
|
||||||
args: [...agentArgs],
|
|
||||||
cwd,
|
|
||||||
debug,
|
|
||||||
token,
|
|
||||||
https,
|
|
||||||
group,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -342,4 +342,4 @@ fetchInstances();
|
||||||
setInterval(fetchInstances, 3000);
|
setInterval(fetchInstances, 3000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,44 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from "hono";
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from "@hono/node-server";
|
||||||
import { ProcessManager } from './manager.js'
|
import { ProcessManager } from "./manager.js";
|
||||||
import { createApp } from './routes.js'
|
import { createApp } from "./routes.js";
|
||||||
|
|
||||||
export async function startManager(port: number): Promise<void> {
|
export async function startManager(port: number): Promise<void> {
|
||||||
const manager = new ProcessManager()
|
const manager = new ProcessManager();
|
||||||
const app = createApp(manager)
|
const app = createApp(manager);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', c => c.json({ status: 'ok' }))
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
|
|
||||||
let shuttingDown = false
|
let shuttingDown = false;
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
if (shuttingDown) return
|
if (shuttingDown) return;
|
||||||
shuttingDown = true
|
shuttingDown = true;
|
||||||
console.log('Shutting down...')
|
console.log("Shutting down...");
|
||||||
await manager.shutdownAll()
|
await manager.shutdownAll();
|
||||||
process.exit(0)
|
process.exit(0);
|
||||||
}
|
};
|
||||||
process.on('SIGTERM', shutdown)
|
process.on("SIGTERM", shutdown);
|
||||||
process.on('SIGINT', shutdown)
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
const server = serve({ fetch: app.fetch, port })
|
const server = serve({ fetch: app.fetch, port });
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
if (err.code === "EADDRINUSE") {
|
||||||
console.error(
|
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
|
||||||
`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`\n Error: ${err.message}\n`)
|
console.error(`\n Error: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log()
|
console.log();
|
||||||
console.log(` 🖥️ ACP Manager`)
|
console.log(` 🖥️ ACP Manager`);
|
||||||
console.log()
|
console.log();
|
||||||
console.log(` URL: http://localhost:${port}`)
|
console.log(` URL: http://localhost:${port}`);
|
||||||
console.log()
|
console.log();
|
||||||
console.log(` Press Ctrl+C to stop`)
|
console.log(` Press Ctrl+C to stop`);
|
||||||
console.log()
|
console.log();
|
||||||
|
|
||||||
// Keep running
|
// Keep running
|
||||||
await new Promise(() => {})
|
await new Promise(() => {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,217 +1,205 @@
|
||||||
import type { AcpInstance, InstanceSummary, LogEntry } from './types.js'
|
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
|
||||||
|
|
||||||
function log(tag: string, msg: string) {
|
function log(tag: string, msg: string) {
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString();
|
||||||
console.log(`[${ts}] [${tag}] ${msg}`)
|
console.log(`[${ts}] [${tag}] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LOG_LINES = 2000
|
const MAX_LOG_LINES = 2000;
|
||||||
const SHUTDOWN_TIMEOUT_MS = 5000
|
const SHUTDOWN_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
export class ProcessManager {
|
export class ProcessManager {
|
||||||
private instances = new Map<string, AcpInstance>()
|
private instances = new Map<string, AcpInstance>();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private processes = new Map<string, any>()
|
private processes = new Map<string, any>();
|
||||||
|
|
||||||
create(group: string, command: string): AcpInstance {
|
create(group: string, command: string): AcpInstance {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID();
|
||||||
const instance: AcpInstance = {
|
const instance: AcpInstance = {
|
||||||
id,
|
id,
|
||||||
group,
|
group,
|
||||||
command,
|
command,
|
||||||
status: 'running',
|
status: "running",
|
||||||
pid: undefined,
|
pid: undefined,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
logs: [],
|
logs: [],
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
}
|
};
|
||||||
|
|
||||||
const args = this.parseCommand(command)
|
const args = this.parseCommand(command);
|
||||||
const fullArgs = ['--group', group, ...args]
|
const fullArgs = ["--group", group, ...args];
|
||||||
|
|
||||||
const proc = Bun.spawn(['acp-link', ...fullArgs], {
|
const proc = Bun.spawn(["acp-link", ...fullArgs], {
|
||||||
stdout: 'pipe',
|
stdout: "pipe",
|
||||||
stderr: 'pipe',
|
stderr: "pipe",
|
||||||
env: { ...Bun.env, ACP_CHILD: '1' },
|
env: { ...Bun.env, ACP_CHILD: "1" },
|
||||||
})
|
});
|
||||||
|
|
||||||
instance.pid = proc.pid
|
instance.pid = proc.pid;
|
||||||
this.instances.set(id, instance)
|
this.instances.set(id, instance);
|
||||||
this.processes.set(id, proc)
|
this.processes.set(id, proc);
|
||||||
log(
|
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
|
||||||
'manager',
|
|
||||||
`created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(' ')}"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
this.pipeStream(proc.stdout, id, 'stdout')
|
this.pipeStream(proc.stdout, id, "stdout");
|
||||||
this.pipeStream(proc.stderr, id, 'stderr')
|
this.pipeStream(proc.stderr, id, "stderr");
|
||||||
|
|
||||||
proc.exited.then(code => {
|
proc.exited.then((code) => {
|
||||||
instance.status = code === 0 ? 'stopped' : 'failed'
|
instance.status = code === 0 ? "stopped" : "failed";
|
||||||
instance.exitCode = code
|
instance.exitCode = code;
|
||||||
instance.pid = undefined
|
instance.pid = undefined;
|
||||||
this.processes.delete(id)
|
this.processes.delete(id);
|
||||||
log(
|
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
|
||||||
'manager',
|
this.notifyStatus(instance);
|
||||||
`instance ${id.slice(0, 8)} ${instance.status} exit=${code}`,
|
});
|
||||||
)
|
|
||||||
this.notifyStatus(instance)
|
|
||||||
})
|
|
||||||
|
|
||||||
return instance
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(id: string): boolean {
|
stop(id: string): boolean {
|
||||||
const proc = this.processes.get(id)
|
const proc = this.processes.get(id);
|
||||||
if (!proc) return false
|
if (!proc) return false;
|
||||||
const inst = this.instances.get(id)
|
const inst = this.instances.get(id);
|
||||||
log('manager', `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`)
|
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||||
proc.kill('SIGTERM')
|
proc.kill("SIGTERM");
|
||||||
// Immediately mark as stopped to prevent stale state
|
// Immediately mark as stopped to prevent stale state
|
||||||
if (inst) {
|
if (inst) {
|
||||||
inst.status = 'stopped'
|
inst.status = "stopped";
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(id: string): boolean {
|
remove(id: string): boolean {
|
||||||
const instance = this.instances.get(id)
|
const instance = this.instances.get(id);
|
||||||
if (!instance) return false
|
if (!instance) return false;
|
||||||
if (instance.status === 'running') return false
|
if (instance.status === "running") return false;
|
||||||
instance.subscribers.clear()
|
instance.subscribers.clear();
|
||||||
this.instances.delete(id)
|
this.instances.delete(id);
|
||||||
log('manager', `removed instance ${id.slice(0, 8)} group=${instance.group}`)
|
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): InstanceSummary[] {
|
list(): InstanceSummary[] {
|
||||||
return Array.from(this.instances.values()).map(this.toSummary)
|
return Array.from(this.instances.values()).map(this.toSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): AcpInstance | undefined {
|
get(id: string): AcpInstance | undefined {
|
||||||
return this.instances.get(id)
|
return this.instances.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
|
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
|
||||||
const instance = this.instances.get(id)
|
const instance = this.instances.get(id);
|
||||||
if (!instance) return () => {}
|
if (!instance) return () => {};
|
||||||
instance.subscribers.add(callback)
|
instance.subscribers.add(callback);
|
||||||
return () => instance.subscribers.delete(callback)
|
return () => instance.subscribers.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdownAll(): Promise<void> {
|
async shutdownAll(): Promise<void> {
|
||||||
const running = Array.from(this.processes.entries())
|
const running = Array.from(this.processes.entries());
|
||||||
if (running.length === 0) return
|
if (running.length === 0) return;
|
||||||
|
|
||||||
log('manager', `shutting down ${running.length} running instance(s)...`)
|
log("manager", `shutting down ${running.length} running instance(s)...`);
|
||||||
for (const [id, proc] of running) {
|
for (const [id, proc] of running) {
|
||||||
try {
|
try {
|
||||||
proc.kill('SIGTERM')
|
proc.kill("SIGTERM");
|
||||||
log('manager', `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`)
|
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||||
} catch {
|
} catch {
|
||||||
// already dead
|
// already dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = new Promise<void>(resolve =>
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
|
||||||
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS),
|
|
||||||
)
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
|
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
|
||||||
timeout,
|
timeout,
|
||||||
])
|
]);
|
||||||
|
|
||||||
for (const [id, proc] of running) {
|
for (const [id, proc] of running) {
|
||||||
try {
|
try {
|
||||||
proc.kill('SIGKILL')
|
proc.kill("SIGKILL");
|
||||||
log('manager', `sent SIGKILL to ${id.slice(0, 8)}`)
|
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
|
||||||
} catch {
|
} catch {
|
||||||
// already dead
|
// already dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log('manager', 'all instances shut down')
|
log("manager", "all instances shut down");
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseCommand(command: string): string[] {
|
private parseCommand(command: string): string[] {
|
||||||
const args: string[] = []
|
const args: string[] = [];
|
||||||
let current = ''
|
let current = "";
|
||||||
let inQuote: string | null = null
|
let inQuote: string | null = null;
|
||||||
|
|
||||||
for (const ch of command) {
|
for (const ch of command) {
|
||||||
if (inQuote) {
|
if (inQuote) {
|
||||||
if (ch === inQuote) {
|
if (ch === inQuote) {
|
||||||
inQuote = null
|
inQuote = null;
|
||||||
} else {
|
} else {
|
||||||
current += ch
|
current += ch;
|
||||||
}
|
}
|
||||||
} else if (ch === '"' || ch === "'") {
|
} else if (ch === '"' || ch === "'") {
|
||||||
inQuote = ch
|
inQuote = ch;
|
||||||
} else if (ch === ' ' || ch === '\t') {
|
} else if (ch === " " || ch === "\t") {
|
||||||
if (current) {
|
if (current) {
|
||||||
args.push(current)
|
args.push(current);
|
||||||
current = ''
|
current = "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current += ch
|
current += ch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (current) args.push(current)
|
if (current) args.push(current);
|
||||||
return args
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
private pipeStream(
|
private pipeStream(
|
||||||
readable: ReadableStream<Uint8Array>,
|
readable: ReadableStream<Uint8Array>,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
stream: 'stdout' | 'stderr',
|
stream: "stdout" | "stderr",
|
||||||
) {
|
) {
|
||||||
const reader = readable.getReader()
|
const reader = readable.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
let buffer = ''
|
let buffer = "";
|
||||||
|
|
||||||
const processChunk = () => {
|
const processChunk = () => {
|
||||||
reader
|
reader
|
||||||
.read()
|
.read()
|
||||||
.then(({ done, value }) => {
|
.then(({ done, value }) => {
|
||||||
if (done) {
|
if (done) {
|
||||||
if (buffer) this.appendLog(instanceId, buffer, stream)
|
if (buffer) this.appendLog(instanceId, buffer, stream);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split("\n");
|
||||||
buffer = lines.pop() ?? ''
|
buffer = lines.pop() ?? "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line) this.appendLog(instanceId, line, stream)
|
if (line) this.appendLog(instanceId, line, stream);
|
||||||
}
|
}
|
||||||
processChunk()
|
processChunk();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// stream ended or error
|
// stream ended or error
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
processChunk()
|
processChunk();
|
||||||
}
|
}
|
||||||
|
|
||||||
private appendLog(
|
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
|
||||||
instanceId: string,
|
const instance = this.instances.get(instanceId);
|
||||||
text: string,
|
if (!instance) return;
|
||||||
stream: 'stdout' | 'stderr',
|
|
||||||
) {
|
|
||||||
const instance = this.instances.get(instanceId)
|
|
||||||
if (!instance) return
|
|
||||||
|
|
||||||
const entry: LogEntry = { timestamp: Date.now(), stream, text }
|
const entry: LogEntry = { timestamp: Date.now(), stream, text };
|
||||||
instance.logs.push(entry)
|
instance.logs.push(entry);
|
||||||
if (instance.logs.length > MAX_LOG_LINES) {
|
if (instance.logs.length > MAX_LOG_LINES) {
|
||||||
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES)
|
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sub of instance.subscribers) {
|
for (const sub of instance.subscribers) {
|
||||||
try {
|
try {
|
||||||
sub(entry)
|
sub(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// subscriber error, remove it
|
// subscriber error, remove it
|
||||||
instance.subscribers.delete(sub)
|
instance.subscribers.delete(sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -219,14 +207,14 @@ export class ProcessManager {
|
||||||
private notifyStatus(instance: AcpInstance) {
|
private notifyStatus(instance: AcpInstance) {
|
||||||
const statusEntry: LogEntry = {
|
const statusEntry: LogEntry = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
stream: 'stderr',
|
stream: "stderr",
|
||||||
text: `[${instance.status}] exit code: ${instance.exitCode}`,
|
text: `[${instance.status}] exit code: ${instance.exitCode}`,
|
||||||
}
|
};
|
||||||
for (const sub of instance.subscribers) {
|
for (const sub of instance.subscribers) {
|
||||||
try {
|
try {
|
||||||
sub(statusEntry)
|
sub(statusEntry);
|
||||||
} catch {
|
} catch {
|
||||||
instance.subscribers.delete(sub)
|
instance.subscribers.delete(sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,6 +228,6 @@ export class ProcessManager {
|
||||||
pid: inst.pid,
|
pid: inst.pid,
|
||||||
startTime: inst.startTime,
|
startTime: inst.startTime,
|
||||||
exitCode: inst.exitCode,
|
exitCode: inst.exitCode,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,41 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from "hono";
|
||||||
import type { ProcessManager } from './manager.js'
|
import type { ProcessManager } from "./manager.js";
|
||||||
import { MANAGER_HTML } from './html.js'
|
import { MANAGER_HTML } from "./html.js";
|
||||||
|
|
||||||
function logReq(method: string, path: string, status?: number) {
|
function logReq(method: string, path: string, status?: number) {
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString();
|
||||||
const suffix = status != null ? ` -> ${status}` : ''
|
const suffix = status != null ? ` -> ${status}` : "";
|
||||||
console.log(`[${ts}] [http] ${method} ${path}${suffix}`)
|
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(manager: ProcessManager): Hono {
|
export function createApp(manager: ProcessManager): Hono {
|
||||||
const app = new Hono()
|
const app = new Hono();
|
||||||
|
|
||||||
app.get('/', c => {
|
app.get("/", (c) => {
|
||||||
logReq('GET', '/', 200)
|
logReq("GET", "/", 200);
|
||||||
return c.html(MANAGER_HTML)
|
return c.html(MANAGER_HTML);
|
||||||
})
|
});
|
||||||
|
|
||||||
app.get('/api/instances', c => {
|
app.get("/api/instances", (c) => {
|
||||||
const list = manager.list()
|
const list = manager.list();
|
||||||
logReq('GET', '/api/instances', 200)
|
logReq("GET", "/api/instances", 200);
|
||||||
return c.json(list)
|
return c.json(list);
|
||||||
})
|
});
|
||||||
|
|
||||||
app.post('/api/instances', async c => {
|
app.post("/api/instances", async (c) => {
|
||||||
let body: { group?: string; command?: string }
|
let body: { group?: string; command?: string };
|
||||||
try {
|
try {
|
||||||
body = await c.req.json<{ group?: string; command?: string }>()
|
body = await c.req.json<{ group?: string; command?: string }>();
|
||||||
} catch {
|
} catch {
|
||||||
logReq('POST', '/api/instances', 400)
|
logReq("POST", "/api/instances", 400);
|
||||||
return c.json({ error: 'invalid JSON body' }, 400)
|
return c.json({ error: "invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
if (!body.group?.trim() || !body.command?.trim()) {
|
if (!body.group?.trim() || !body.command?.trim()) {
|
||||||
logReq('POST', '/api/instances', 400)
|
logReq("POST", "/api/instances", 400);
|
||||||
return c.json({ error: 'group and command are required' }, 400)
|
return c.json({ error: "group and command are required" }, 400);
|
||||||
}
|
}
|
||||||
const instance = manager.create(body.group.trim(), body.command.trim())
|
const instance = manager.create(body.group.trim(), body.command.trim());
|
||||||
logReq('POST', `/api/instances group=${body.group}`, 201)
|
logReq("POST", `/api/instances group=${body.group}`, 201);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
|
|
@ -47,107 +47,107 @@ export function createApp(manager: ProcessManager): Hono {
|
||||||
exitCode: instance.exitCode,
|
exitCode: instance.exitCode,
|
||||||
},
|
},
|
||||||
201,
|
201,
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
app.post('/api/instances/:id/stop', c => {
|
app.post("/api/instances/:id/stop", (c) => {
|
||||||
const id = c.req.param('id')
|
const id = c.req.param("id");
|
||||||
const inst = manager.get(id)
|
const inst = manager.get(id);
|
||||||
if (!inst) {
|
if (!inst) {
|
||||||
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 404)
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
|
||||||
return c.json({ error: 'not found' }, 404)
|
return c.json({ error: "not found" }, 404);
|
||||||
}
|
}
|
||||||
if (inst.status !== 'running') {
|
if (inst.status !== "running") {
|
||||||
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 400)
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
|
||||||
return c.json({ error: 'not running' }, 400)
|
return c.json({ error: "not running" }, 400);
|
||||||
}
|
}
|
||||||
manager.stop(inst.id)
|
manager.stop(inst.id);
|
||||||
logReq('POST', `/api/instances/${id.slice(0, 8)}/stop`, 200)
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true });
|
||||||
})
|
});
|
||||||
|
|
||||||
app.delete('/api/instances/:id', c => {
|
app.delete("/api/instances/:id", (c) => {
|
||||||
const id = c.req.param('id')
|
const id = c.req.param("id");
|
||||||
const inst = manager.get(id)
|
const inst = manager.get(id);
|
||||||
if (!inst) {
|
if (!inst) {
|
||||||
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 404)
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
|
||||||
return c.json({ error: 'not found' }, 404)
|
return c.json({ error: "not found" }, 404);
|
||||||
}
|
}
|
||||||
if (inst.status === 'running') {
|
if (inst.status === "running") {
|
||||||
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 400)
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
|
||||||
return c.json({ error: 'still running' }, 400)
|
return c.json({ error: "still running" }, 400);
|
||||||
}
|
}
|
||||||
manager.remove(inst.id)
|
manager.remove(inst.id);
|
||||||
logReq('DELETE', `/api/instances/${id.slice(0, 8)}`, 200)
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true });
|
||||||
})
|
});
|
||||||
|
|
||||||
app.get('/api/instances/:id/logs', c => {
|
app.get("/api/instances/:id/logs", (c) => {
|
||||||
const id = c.req.param('id')
|
const id = c.req.param("id");
|
||||||
const inst = manager.get(id)
|
const inst = manager.get(id);
|
||||||
if (!inst) {
|
if (!inst) {
|
||||||
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs`, 404)
|
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
|
||||||
return c.json({ error: 'not found' }, 404)
|
return c.json({ error: "not found" }, 404);
|
||||||
}
|
}
|
||||||
logReq('GET', `/api/instances/${id.slice(0, 8)}/logs SSE`)
|
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const send = (data: string) => {
|
const send = (data: string) => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(data))
|
controller.enqueue(encoder.encode(data));
|
||||||
} catch {
|
} catch {
|
||||||
// stream closed
|
// stream closed
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// send historical logs
|
// send historical logs
|
||||||
for (const log of inst.logs) {
|
for (const log of inst.logs) {
|
||||||
send(`data: ${JSON.stringify(log)}\n\n`)
|
send(`data: ${JSON.stringify(log)}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribe to new logs
|
// subscribe to new logs
|
||||||
const unsub = manager.subscribe(inst.id, entry => {
|
const unsub = manager.subscribe(inst.id, (entry) => {
|
||||||
send(`data: ${JSON.stringify(entry)}\n\n`)
|
send(`data: ${JSON.stringify(entry)}\n\n`);
|
||||||
})
|
});
|
||||||
|
|
||||||
// keepalive every 15s
|
// keepalive every 15s
|
||||||
const keepalive = setInterval(() => {
|
const keepalive = setInterval(() => {
|
||||||
send(': keepalive\n\n')
|
send(": keepalive\n\n");
|
||||||
}, 15000)
|
}, 15000);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
unsub()
|
unsub();
|
||||||
clearInterval(keepalive)
|
clearInterval(keepalive);
|
||||||
logReq('SSE', `/api/instances/${id.slice(0, 8)}/logs closed`)
|
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
|
||||||
try {
|
try {
|
||||||
controller.close()
|
controller.close();
|
||||||
} catch {
|
} catch {
|
||||||
// already closed
|
// already closed
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
c.req.raw.signal.addEventListener('abort', cleanup, { once: true })
|
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/event-stream',
|
"Content-Type": "text/event-stream",
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
Connection: 'keep-alive',
|
Connection: "keep-alive",
|
||||||
'X-Accel-Buffering': 'no',
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Catch-all: log unmatched routes for debugging
|
// Catch-all: log unmatched routes for debugging
|
||||||
app.all('*', c => {
|
app.all("*", (c) => {
|
||||||
logReq(c.req.method, c.req.path, 404)
|
logReq(c.req.method, c.req.path, 404);
|
||||||
return c.json({ error: 'not found', path: c.req.path }, 404)
|
return c.json({ error: "not found", path: c.req.path }, 404);
|
||||||
})
|
});
|
||||||
|
|
||||||
return app
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
export type InstanceStatus = 'running' | 'stopped' | 'failed'
|
export type InstanceStatus = "running" | "stopped" | "failed";
|
||||||
|
|
||||||
export interface AcpInstance {
|
export interface AcpInstance {
|
||||||
id: string
|
id: string;
|
||||||
group: string
|
group: string;
|
||||||
command: string
|
command: string;
|
||||||
status: InstanceStatus
|
status: InstanceStatus;
|
||||||
pid: number | undefined
|
pid: number | undefined;
|
||||||
startTime: number
|
startTime: number;
|
||||||
exitCode: number | null
|
exitCode: number | null;
|
||||||
logs: LogEntry[]
|
logs: LogEntry[];
|
||||||
subscribers: Set<(entry: LogEntry) => void>
|
subscribers: Set<(entry: LogEntry) => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
stream: 'stdout' | 'stderr'
|
stream: "stdout" | "stderr";
|
||||||
text: string
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateInstanceRequest {
|
export interface CreateInstanceRequest {
|
||||||
group: string
|
group: string;
|
||||||
command: string
|
command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceSummary {
|
export interface InstanceSummary {
|
||||||
id: string
|
id: string;
|
||||||
group: string
|
group: string;
|
||||||
command: string
|
command: string;
|
||||||
status: InstanceStatus
|
status: InstanceStatus;
|
||||||
pid: number | undefined
|
pid: number | undefined;
|
||||||
startTime: number
|
startTime: number;
|
||||||
exitCode: number | null
|
exitCode: number | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1282,6 +1282,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||||
process.on('SIGINT', shutdown)
|
process.on('SIGINT', shutdown)
|
||||||
process.on('SIGTERM', shutdown)
|
process.on('SIGTERM', shutdown)
|
||||||
|
|
||||||
|
// Graceful shutdown — close RCS upstream
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
// Keep the server running
|
// Keep the server running
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
"types": ["bun"]
|
"types": ["bun"],
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user