task logs: migrate from filesystem to postgresql; refactor sidecars into submodules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,16 @@ import { Card } from '@/components/Card';
|
||||
import { MessageBubble, type ChatMessage } from 'officerdev';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
id: number;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
entryType: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
isError: boolean;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type FullLog = LogMetadata & {
|
||||
@@ -37,7 +37,7 @@ const ProviderBadge = ({ provider }: { provider: string }) => (
|
||||
export const TaskLogs = () => {
|
||||
const client = useClient();
|
||||
const [logs, setLogs] = useState<LogMetadata[]>([]);
|
||||
const [selectedFilename, setSelectedFilename] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -54,15 +54,15 @@ export const TaskLogs = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFilename) {
|
||||
if (!selectedId) {
|
||||
setSelectedLog(null);
|
||||
return;
|
||||
}
|
||||
client
|
||||
.get<FullLog>(`/task-logs/${selectedFilename}`)
|
||||
.get<FullLog>(`/task-logs/${selectedId}`)
|
||||
.then(setSelectedLog)
|
||||
.catch(() => setSelectedLog(null));
|
||||
}, [selectedFilename]);
|
||||
}, [selectedId]);
|
||||
|
||||
const filtered = search
|
||||
? logs.filter((l) => {
|
||||
@@ -77,101 +77,103 @@ export const TaskLogs = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full p-3 md:p-6 gap-4">
|
||||
{/* Left panel: list */}
|
||||
<Card
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="p-3 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
/>
|
||||
</div>
|
||||
{/* Left panel: list */}
|
||||
<Card
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="p-3 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
||||
)}
|
||||
{filtered.map((log) => (
|
||||
<button
|
||||
key={log.filename}
|
||||
onClick={() => {
|
||||
setSelectedFilename(log.filename);
|
||||
setShowDetail(true);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedFilename === log.filename ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{log.isError ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : log.completedAt ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-5.5">
|
||||
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
|
||||
<ProviderBadge provider={log.provider} />
|
||||
</div>
|
||||
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel: log viewer */}
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
||||
{!selectedLog && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
||||
Select a log to view
|
||||
<button onClick={() => setShowDetail(false)} className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
||||
<ProviderBadge provider={selectedLog.provider} />
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/50 mt-0.5">
|
||||
{selectedLog.entryName} · {selectedLog.model} · {formatDate(selectedLog.startedAt)}
|
||||
{selectedLog.completedAt && ` — ${formatDate(selectedLog.completedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog.isError && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">Error</span>
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
||||
)}
|
||||
{filtered.map((log) => (
|
||||
<button
|
||||
key={log.id}
|
||||
onClick={() => {
|
||||
setSelectedId(log.id);
|
||||
setShowDetail(true);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedId === log.id ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{log.isError ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
) : log.completedAt ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{selectedLog.messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
<div className="flex items-center gap-2 ml-5.5">
|
||||
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
|
||||
<ProviderBadge provider={log.provider} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel: log viewer */}
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
||||
{!selectedLog && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
||||
Select a log to view
|
||||
<button onClick={() => setShowDetail(false)} className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
||||
<ProviderBadge provider={selectedLog.provider} />
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/50 mt-0.5">
|
||||
{selectedLog.entryName} · {selectedLog.model} · {formatDate(selectedLog.startedAt)}
|
||||
{selectedLog.completedAt && ` — ${formatDate(selectedLog.completedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog.isError && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{selectedLog.messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+42
-1
@@ -56,6 +56,12 @@ const sidecarWebsocket = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
||||
if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) {
|
||||
handleSidecarQueueCommand(ws, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = sidecarConnections.get(ws);
|
||||
if (id) {
|
||||
handleSidecarMessage(id, msg);
|
||||
@@ -74,6 +80,39 @@ const sidecarWebsocket = {
|
||||
drain() {},
|
||||
};
|
||||
|
||||
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
||||
async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
|
||||
const id = msg.id as string;
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case 'queue:enqueue': {
|
||||
const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams);
|
||||
ws.send(JSON.stringify({ type: 'queue:enqueued', id, job }));
|
||||
break;
|
||||
}
|
||||
case 'queue:cancel': {
|
||||
const job = await queueCancel(msg.jobId as string);
|
||||
ws.send(JSON.stringify({ type: 'queue:cancelled', id, job }));
|
||||
break;
|
||||
}
|
||||
case 'queue:list': {
|
||||
const jobs = await queueList();
|
||||
ws.send(JSON.stringify({ type: 'queue:list', id, jobs }));
|
||||
break;
|
||||
}
|
||||
case 'queue:get': {
|
||||
const job = await queueGet(msg.jobId as string);
|
||||
ws.send(JSON.stringify({ type: 'queue:get', id, job }));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` }));
|
||||
}
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: Record<string, any> = {
|
||||
terminal: terminalWebsocket,
|
||||
pi: piWebsocket,
|
||||
@@ -274,7 +313,9 @@ try {
|
||||
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Sidecars connect to us via /api/sidecar/register — no init needed
|
||||
// Initialize queue engine in API server process
|
||||
import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet } from './servers/queue/init';
|
||||
initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
|
||||
|
||||
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
|
||||
(async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
updateEmailAccountStatus,
|
||||
} from 'officerdb';
|
||||
import { validateImapConnection } from './imap-validate';
|
||||
import * as sidecar from '../../sidecar-registry';
|
||||
import { enqueueJob, listAllJobs } from '../../queue/init';
|
||||
|
||||
type CreateAccountBody = {
|
||||
provider: string;
|
||||
@@ -44,7 +44,7 @@ accountsRouter.get('/', async (ctx) => {
|
||||
|
||||
if (hasActiveAccounts) {
|
||||
try {
|
||||
const jobs = await sidecar.listJobs();
|
||||
const jobs = await listAllJobs();
|
||||
activeJobAccountIds = new Set(
|
||||
jobs
|
||||
.filter((j) => j.type === 'email-sync' && (j.status === 'queued' || j.status === 'running'))
|
||||
@@ -148,7 +148,7 @@ accountsRouter.post('/:id/sync', async (ctx) => {
|
||||
// Set status immediately so the UI reflects the queued state
|
||||
await updateEmailAccountStatus(id, 'queued');
|
||||
|
||||
const job = await sidecar.enqueueJob({
|
||||
const job = await enqueueJob({
|
||||
lane: 'email',
|
||||
type: 'email-sync',
|
||||
userId: user.email,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as sidecar from '../../sidecar-registry';
|
||||
import { enqueueJob, cancelJob, listAllJobs, readJob } from '../../queue/init';
|
||||
import { NOT_FOUND } from '../../custom-errors';
|
||||
|
||||
export const queueRouter = createRouter();
|
||||
@@ -10,7 +10,7 @@ queueRouter.get('/jobs', async (ctx) => {
|
||||
const type = ctx.req.query('type');
|
||||
const status = ctx.req.query('status');
|
||||
|
||||
let jobs = await sidecar.listJobs();
|
||||
let jobs = await listAllJobs();
|
||||
jobs = jobs.filter((j) => j.userId === user.email);
|
||||
|
||||
if (lane) jobs = jobs.filter((j) => j.lane === lane);
|
||||
@@ -21,7 +21,7 @@ queueRouter.get('/jobs', async (ctx) => {
|
||||
});
|
||||
|
||||
queueRouter.get('/jobs/:id', async (ctx) => {
|
||||
const job = await sidecar.getJob(ctx.req.param('id'));
|
||||
const job = await readJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
@@ -36,12 +36,12 @@ queueRouter.post('/jobs', async (ctx) => {
|
||||
notify?: boolean;
|
||||
};
|
||||
|
||||
const job = await sidecar.enqueueJob({ lane, type, userId: user.email, meta, notify });
|
||||
const job = await enqueueJob({ lane, type, userId: user.email, meta, notify });
|
||||
return ctx.json(job, 201);
|
||||
});
|
||||
|
||||
queueRouter.delete('/jobs/:id', async (ctx) => {
|
||||
const job = await sidecar.cancelJob(ctx.req.param('id'));
|
||||
const job = await cancelJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getTaskLogsDir } from '@@/data-path';
|
||||
import { db, schema } from 'officerdb';
|
||||
import type { TaskInfo } from '@@/api/chat-types';
|
||||
|
||||
type ChatMessage =
|
||||
@@ -18,46 +16,41 @@ type ChatMessage =
|
||||
| { role: 'error'; text: string };
|
||||
|
||||
type TaskLog = {
|
||||
userId: number;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
startedAt: Date;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
email: string;
|
||||
filePath: string;
|
||||
userId: number;
|
||||
log: TaskLog;
|
||||
};
|
||||
|
||||
const activeLogs = new Map<string, LogEntry>();
|
||||
let logCounter = 0;
|
||||
|
||||
export function createTaskLog(email: string, taskInfo: TaskInfo, provider: string, model: string): string {
|
||||
export function createTaskLog(userId: number, taskInfo: TaskInfo, provider: string, model: string): string {
|
||||
const logId = `log_${Date.now()}_${++logCounter}`;
|
||||
const dir = getTaskLogsDir(email);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${timestamp}-${taskInfo.taskDirName}.json`;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
const log: TaskLog = {
|
||||
userId,
|
||||
taskName: taskInfo.taskName,
|
||||
taskDirName: taskInfo.taskDirName,
|
||||
entryName: taskInfo.entryName,
|
||||
entryType: taskInfo.entryType,
|
||||
provider,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
startedAt: new Date(),
|
||||
messages: [],
|
||||
};
|
||||
|
||||
activeLogs.set(logId, { email, filePath, log });
|
||||
activeLogs.set(logId, { userId, log });
|
||||
return logId;
|
||||
}
|
||||
|
||||
@@ -82,11 +75,24 @@ export async function finalizeLog(logId: string) {
|
||||
const entry = activeLogs.get(logId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.log.completedAt = new Date().toISOString();
|
||||
const { log } = entry;
|
||||
const lastMessage = log.messages[log.messages.length - 1];
|
||||
const isError = lastMessage?.role === 'result' ? lastMessage.isError : lastMessage?.role === 'error';
|
||||
|
||||
try {
|
||||
await mkdir(join(entry.filePath, '..'), { recursive: true });
|
||||
await Bun.write(entry.filePath, JSON.stringify(entry.log, null, 2));
|
||||
await db.insert(schema.taskLogs).values({
|
||||
userId: log.userId,
|
||||
taskName: log.taskName,
|
||||
taskDirName: log.taskDirName,
|
||||
entryName: log.entryName,
|
||||
entryType: log.entryType,
|
||||
provider: log.provider,
|
||||
model: log.model,
|
||||
isError: !!isError,
|
||||
messages: log.messages,
|
||||
startedAt: log.startedAt,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-logger] Failed to write log:', err);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,47 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from 'officerdb';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getTaskLogsDir } from '../../data-path';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
provider: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const taskLogsRouter = createRouter();
|
||||
|
||||
// GET / — list all log files (metadata only, no messages)
|
||||
// GET / — list all logs (metadata only, no messages)
|
||||
taskLogsRouter.get('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dir = getTaskLogsDir(email);
|
||||
const userId = ctx.get('user').id;
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(dir)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest first since filenames start with timestamp)
|
||||
files.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const logs: LogMetadata[] = [];
|
||||
for (const filename of files) {
|
||||
try {
|
||||
const raw = await Bun.file(join(dir, filename)).json();
|
||||
const lastMessage = Array.isArray(raw.messages) ? raw.messages[raw.messages.length - 1] : null;
|
||||
const isError = lastMessage?.role === 'result' ? lastMessage.isError : lastMessage?.role === 'error';
|
||||
logs.push({
|
||||
filename,
|
||||
taskName: raw.taskName ?? '',
|
||||
taskDirName: raw.taskDirName ?? '',
|
||||
entryName: raw.entryName ?? '',
|
||||
entryType: raw.entryType ?? 'file',
|
||||
provider: raw.provider ?? '',
|
||||
model: raw.model ?? '',
|
||||
startedAt: raw.startedAt ?? '',
|
||||
completedAt: raw.completedAt ?? null,
|
||||
isError: !!isError,
|
||||
});
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
const logs = await db
|
||||
.select({
|
||||
id: schema.taskLogs.id,
|
||||
taskName: schema.taskLogs.taskName,
|
||||
taskDirName: schema.taskLogs.taskDirName,
|
||||
entryName: schema.taskLogs.entryName,
|
||||
entryType: schema.taskLogs.entryType,
|
||||
provider: schema.taskLogs.provider,
|
||||
model: schema.taskLogs.model,
|
||||
isError: schema.taskLogs.isError,
|
||||
startedAt: schema.taskLogs.startedAt,
|
||||
completedAt: schema.taskLogs.completedAt,
|
||||
})
|
||||
.from(schema.taskLogs)
|
||||
.where(eq(schema.taskLogs.userId, userId))
|
||||
.orderBy(desc(schema.taskLogs.startedAt));
|
||||
|
||||
return ctx.json(logs);
|
||||
});
|
||||
|
||||
// GET /:filename — return full log file content
|
||||
taskLogsRouter.get('/:filename', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const filename = ctx.req.param('filename');
|
||||
// GET /:id — return full log with messages
|
||||
taskLogsRouter.get('/:id', async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const id = Number(ctx.req.param('id'));
|
||||
|
||||
if (!filename.endsWith('.json') || filename.includes('/') || filename.includes('..')) {
|
||||
return ctx.text('Invalid filename', 400);
|
||||
if (Number.isNaN(id)) {
|
||||
return ctx.text('Invalid id', 400);
|
||||
}
|
||||
|
||||
const filePath = join(getTaskLogsDir(email), filename);
|
||||
const [log] = await db.select().from(schema.taskLogs).where(eq(schema.taskLogs.id, id));
|
||||
|
||||
try {
|
||||
const data = await Bun.file(filePath).json();
|
||||
return ctx.json(data);
|
||||
} catch {
|
||||
if (!log || log.userId !== userId) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
return ctx.json(log);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
|
||||
import { consumePairingCode } from '../pairing';
|
||||
import { chunkMessage } from './chunker';
|
||||
import { listPiModels } from '@@/api/pi/list-models';
|
||||
import { enqueueJob } from '../../sidecar-registry';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { consumePairingCode } from '../pairing';
|
||||
import { chunkMessage } from './chunker';
|
||||
import { getTelegramBot } from './bot';
|
||||
import { listPiModels } from '@@/api/pi/list-models';
|
||||
import { enqueueJob } from '../../sidecar-registry';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-awai
|
||||
import { consumePairingCode } from '../pairing';
|
||||
import { getWhatsAppClient } from './bot';
|
||||
import { listPiModels } from '@@/api/pi/list-models';
|
||||
import { enqueueJob } from '../../sidecar-registry';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
|
||||
@@ -69,8 +69,6 @@ export const getGlobalProcessesDir = () => join(DATA_PATH, 'processes');
|
||||
|
||||
export const getUserProcessesDir = (email: string) => join(DATA_PATH, email, 'processes');
|
||||
|
||||
export const getTaskLogsDir = (email: string) => join(DATA_PATH, email, 'logs', 'tasks');
|
||||
|
||||
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments');
|
||||
|
||||
export const getAttachmentsDir = (email: string, provider: 'claude' | 'pi-mono', sessionId: string) =>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type Job, type JobProgress, type EnqueueParams, type StepContext, PermanentError } from '../queue/types';
|
||||
import { readJob, writeJob, listAllJobs, ensureQueueDir } from '../queue/storage';
|
||||
import { getHandler } from '../queue/handler-registry';
|
||||
import { type Job, type EnqueueParams } from './types';
|
||||
import { readJob, writeJob, listAllJobs, ensureQueueDir } from './storage';
|
||||
import { getHandler } from './handler-registry';
|
||||
|
||||
// Import handlers to register them
|
||||
import '../queue/handlers';
|
||||
import './handlers';
|
||||
|
||||
// ── Queue engine (moved from sidecar/queue-runner.ts) ──
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
@@ -22,10 +24,10 @@ const PROGRESS_THROTTLE_MS = 1000;
|
||||
export async function initQueue() {
|
||||
await ensureQueueDir();
|
||||
await resumeInterruptedJobs();
|
||||
console.log('[sidecar:queue] initialized');
|
||||
console.log('[queue] initialized');
|
||||
}
|
||||
|
||||
export async function enqueue(params: EnqueueParams): Promise<Job> {
|
||||
export async function enqueueJob(params: EnqueueParams): Promise<Job> {
|
||||
const handler = getHandler(params.type);
|
||||
if (!handler) throw new Error(`No handler registered for job type: ${params.type}`);
|
||||
|
||||
@@ -43,7 +45,7 @@ export async function enqueue(params: EnqueueParams): Promise<Job> {
|
||||
};
|
||||
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] enqueued job ${job.id} (${job.type}) in lane ${job.lane}`);
|
||||
console.log(`[queue] enqueued job ${job.id} (${job.type}) in lane ${job.lane}`);
|
||||
kickLane(job.lane);
|
||||
return job;
|
||||
}
|
||||
@@ -62,7 +64,7 @@ export async function cancelJob(id: string): Promise<Job | null> {
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] cancelled job ${job.id}`);
|
||||
console.log(`[queue] cancelled job ${job.id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -81,14 +83,13 @@ async function resumeInterruptedJobs() {
|
||||
}
|
||||
}
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] reset interrupted job ${job.id} back to queued`);
|
||||
console.log(`[queue] reset interrupted job ${job.id} back to queued`);
|
||||
lanesToKick.add(job.lane);
|
||||
} else if (job.status === 'queued') {
|
||||
// Clear retry delay on restart — no reason to wait after a sidecar restart
|
||||
if (job.retryAt) {
|
||||
job.retryAt = undefined;
|
||||
await writeJob(job);
|
||||
console.log(`[sidecar:queue] cleared retry delay for job ${job.id}`);
|
||||
console.log(`[queue] cleared retry delay for job ${job.id}`);
|
||||
}
|
||||
lanesToKick.add(job.lane);
|
||||
}
|
||||
@@ -124,7 +125,7 @@ async function processNextInLane(lane: string) {
|
||||
|
||||
await runJob(next);
|
||||
} catch (err) {
|
||||
console.error(`[sidecar:queue] lane ${lane} processing error:`, err);
|
||||
console.error(`[queue] lane ${lane} processing error:`, err);
|
||||
} finally {
|
||||
const jobs = await listAllJobs();
|
||||
const hasMore = jobs.some(
|
||||
@@ -139,6 +140,7 @@ async function processNextInLane(lane: string) {
|
||||
}
|
||||
|
||||
async function runJob(job: Job) {
|
||||
const { PermanentError } = await import('./types');
|
||||
const handler = getHandler(job.type);
|
||||
if (!handler) {
|
||||
job.status = 'failed';
|
||||
@@ -155,7 +157,7 @@ async function runJob(job: Job) {
|
||||
const isRetry = (job.retries ?? 0) > 0;
|
||||
const startTime = Date.now();
|
||||
console.log(
|
||||
`[sidecar:queue] ▶ ${isRetry ? 'resuming' : 'running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`,
|
||||
`[queue] ▶ ${isRetry ? 'resuming' : 'running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`,
|
||||
);
|
||||
|
||||
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
|
||||
@@ -163,7 +165,7 @@ async function runJob(job: Job) {
|
||||
for (let i = 0; i < handler.steps.length; i++) {
|
||||
const fresh = await readJob(job.id);
|
||||
if (!fresh || fresh.status === 'cancelled') {
|
||||
console.log(`[sidecar:queue] job ${job.id} was cancelled, stopping`);
|
||||
console.log(`[queue] job ${job.id} was cancelled, stopping`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,9 +180,9 @@ async function runJob(job: Job) {
|
||||
await writeJob(fresh);
|
||||
|
||||
let lastProgressWrite = 0;
|
||||
let pendingProgress: JobProgress | null = null;
|
||||
let pendingProgress: import('./types').JobProgress | null = null;
|
||||
|
||||
const updateProgress = async (progress: JobProgress) => {
|
||||
const updateProgress = async (progress: import('./types').JobProgress) => {
|
||||
step.progress = progress;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressWrite >= PROGRESS_THROTTLE_MS) {
|
||||
@@ -192,7 +194,7 @@ async function runJob(job: Job) {
|
||||
}
|
||||
};
|
||||
|
||||
const ctx: StepContext = { job: fresh, step, updateProgress, meta: sharedMeta };
|
||||
const ctx: import('./types').StepContext = { job: fresh, step, updateProgress, meta: sharedMeta };
|
||||
|
||||
try {
|
||||
await handlerStep.run(ctx);
|
||||
@@ -205,7 +207,7 @@ async function runJob(job: Job) {
|
||||
await writeJob(fresh);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[sidecar:queue] step "${step.name}" failed: ${errorMessage}`);
|
||||
console.error(`[queue] step "${step.name}" failed: ${errorMessage}`);
|
||||
step.status = 'failed';
|
||||
step.error = errorMessage;
|
||||
step.completedAt = Date.now();
|
||||
@@ -226,7 +228,7 @@ async function runJob(job: Job) {
|
||||
fresh.retryAt = Date.now() + handler.retry.delayMs;
|
||||
await writeJob(fresh);
|
||||
console.log(
|
||||
`[sidecar:queue] job ${fresh.id} will retry (${retries}/${handler.retry.maxRetries}) in ${handler.retry.delayMs / 1000}s`,
|
||||
`[queue] job ${fresh.id} will retry (${retries}/${handler.retry.maxRetries}) in ${handler.retry.delayMs / 1000}s`,
|
||||
);
|
||||
scheduleRetry(fresh.lane, handler.retry.delayMs);
|
||||
return;
|
||||
@@ -237,7 +239,10 @@ async function runJob(job: Job) {
|
||||
fresh.completedAt = Date.now();
|
||||
fresh.meta = { ...fresh.meta, ...sharedMeta };
|
||||
await writeJob(fresh);
|
||||
console.error(`[sidecar:queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`, errorMessage);
|
||||
console.error(
|
||||
`[queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`,
|
||||
errorMessage,
|
||||
);
|
||||
await notifyFailure(fresh);
|
||||
return;
|
||||
}
|
||||
@@ -249,7 +254,7 @@ async function runJob(job: Job) {
|
||||
final.completedAt = Date.now();
|
||||
final.meta = { ...final.meta, ...sharedMeta };
|
||||
await writeJob(final);
|
||||
console.log(`[sidecar:queue] ✓ job ${final.id} completed in ${formatDuration(Date.now() - startTime)}`);
|
||||
console.log(`[queue] ✓ job ${final.id} completed in ${formatDuration(Date.now() - startTime)}`);
|
||||
await notifyCompletion(final);
|
||||
}
|
||||
}
|
||||
+44
-11
@@ -1,7 +1,7 @@
|
||||
import { join } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from '../api/pi/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from './protocol';
|
||||
import type { PiEvent, MessageCost } from '../../api/pi/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||
import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { getProxySecret } from './proxy';
|
||||
|
||||
@@ -13,7 +13,13 @@ const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
|
||||
const toShellUsername = (username: string, email: string): string => {
|
||||
const raw = username || email.split('@')[0]!;
|
||||
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
|
||||
return (
|
||||
raw
|
||||
.replace(/@.*$/, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.toLowerCase()
|
||||
.slice(0, 32) || 'officer'
|
||||
);
|
||||
};
|
||||
|
||||
async function hasOwnCredentials(homeDir: string): Promise<boolean> {
|
||||
@@ -33,7 +39,12 @@ const CLAUDE_BIN = (() => {
|
||||
// Active streaming processes
|
||||
const activeProcs = new Map<string, Subprocess>();
|
||||
|
||||
function buildAuthEnv(shellUsername: string, homeDir: string, isServiceUser: boolean, userHasCredentials: boolean): Record<string, string> {
|
||||
function buildAuthEnv(
|
||||
shellUsername: string,
|
||||
homeDir: string,
|
||||
isServiceUser: boolean,
|
||||
userHasCredentials: boolean,
|
||||
): Record<string, string> {
|
||||
if (isServiceUser) return { HOME: process.env.HOME ?? '' };
|
||||
if (userHasCredentials) return { HOME: homeDir };
|
||||
return { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: getProxySecret() };
|
||||
@@ -83,7 +94,11 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
@@ -143,10 +158,14 @@ export async function spawnClaudeStreaming(
|
||||
const workDir = cwd ?? homeDir;
|
||||
|
||||
const claudeArgs = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
CLAUDE_BIN,
|
||||
'-p',
|
||||
prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose', '--include-partial-messages',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
@@ -170,7 +189,13 @@ export async function spawnClaudeStreaming(
|
||||
};
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(claudeArgs, { cwd: workDir, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: { ...cleanEnv, ...env } })
|
||||
? Bun.spawn(claudeArgs, {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...cleanEnv, ...env },
|
||||
})
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
||||
{ cwd: workDir, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
@@ -179,7 +204,11 @@ export async function spawnClaudeStreaming(
|
||||
activeProcs.set(sessionKey, proc);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
@@ -339,7 +368,11 @@ export async function spawnClaudeStreaming(
|
||||
export function killClaudeSession(sessionKey: string): boolean {
|
||||
const proc = activeProcs.get(sessionKey);
|
||||
if (proc) {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
activeProcs.delete(sessionKey);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error('[claude] another instance is already running (lock file exists with live PID)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
loadState();
|
||||
ensureProxySecret();
|
||||
|
||||
// Start Anthropic proxy
|
||||
try {
|
||||
startAnthropicProxy();
|
||||
} catch (err) {
|
||||
console.error('[claude] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'state:sync':
|
||||
reply({
|
||||
type: 'state:sync',
|
||||
id: cmd.id,
|
||||
state: {
|
||||
proxySecret: getProxySecret(),
|
||||
claudeSessions: { ...getState().claudeSessions },
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 'proxy:secret':
|
||||
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
||||
break;
|
||||
|
||||
case 'claude:spawn': {
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply({ type: 'claude:result', id: cmd.id, result });
|
||||
} catch (err) {
|
||||
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
|
||||
};
|
||||
|
||||
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||
connection.send({
|
||||
type: 'claude:event',
|
||||
sessionKey: cmd.params.sessionKey,
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'claude',
|
||||
capabilities: ['claude', 'proxy'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[claude] ${signal} received, saving state...`);
|
||||
connection.destroy();
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -100,5 +100,5 @@ export function startAnthropicProxy() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[sidecar:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
||||
console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
||||
}
|
||||
@@ -3,26 +3,17 @@ import { mkdirSync, existsSync } from 'node:fs';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const STATE_DIR = join(DATA_PATH, 'sidecar');
|
||||
const STATE_FILE = join(STATE_DIR, 'state.json');
|
||||
const LOCK_FILE = join(STATE_DIR, 'sidecar.lock');
|
||||
const STATE_FILE = join(STATE_DIR, 'claude-state.json');
|
||||
const LOCK_FILE = join(STATE_DIR, 'claude.lock');
|
||||
|
||||
export type PersistedState = {
|
||||
proxySecret: string;
|
||||
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
||||
piSessions: Array<{
|
||||
sessionId: string;
|
||||
email: string;
|
||||
userId: number;
|
||||
model: string;
|
||||
cwd: string;
|
||||
pid: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: PersistedState = {
|
||||
proxySecret: '',
|
||||
claudeSessions: {},
|
||||
piSessions: [],
|
||||
};
|
||||
|
||||
let currentState: PersistedState = { ...DEFAULT_STATE };
|
||||
@@ -37,13 +28,10 @@ function ensureDir() {
|
||||
export function loadState(): PersistedState {
|
||||
ensureDir();
|
||||
try {
|
||||
const raw = Bun.file(STATE_FILE);
|
||||
// Synchronous check — Bun.file doesn't have sync exists, use fs
|
||||
if (!existsSync(STATE_FILE)) {
|
||||
currentState = { ...DEFAULT_STATE };
|
||||
return currentState;
|
||||
}
|
||||
// We need to read synchronously at startup
|
||||
const text = require('node:fs').readFileSync(STATE_FILE, 'utf-8');
|
||||
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
|
||||
return currentState;
|
||||
@@ -106,9 +94,8 @@ export function acquireLock(): boolean {
|
||||
const pidStr = require('node:fs').readFileSync(LOCK_FILE, 'utf-8').trim();
|
||||
const pid = Number(pidStr);
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
return false; // another sidecar is running
|
||||
return false;
|
||||
}
|
||||
// Stale lock — remove it
|
||||
}
|
||||
require('node:fs').writeFileSync(LOCK_FILE, String(process.pid));
|
||||
return true;
|
||||
@@ -135,7 +122,3 @@ function isProcessAlive(pid: number): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
return isProcessAlive(pid);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { Job, EnqueueParams } from '../../queue/types';
|
||||
import { getAllSyncedAccounts, getUserById, getUserIntegration } from 'officerdb';
|
||||
import * as queueRunner from './queue-runner';
|
||||
|
||||
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let enqueueFn: ((params: EnqueueParams) => Promise<Job>) | null = null;
|
||||
let listJobsFn: (() => Promise<Job[]>) | null = null;
|
||||
|
||||
async function tick() {
|
||||
if (!enqueueFn || !listJobsFn) return;
|
||||
|
||||
try {
|
||||
const accounts = await getAllSyncedAccounts();
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const allJobs = await queueRunner.listAllJobs();
|
||||
const allJobs = await listJobsFn();
|
||||
const activeEmailSyncIds = new Set(
|
||||
allJobs
|
||||
.filter((j) => j.type === 'email-sync' && (j.status === 'queued' || j.status === 'running'))
|
||||
@@ -40,7 +44,7 @@ async function tick() {
|
||||
}
|
||||
|
||||
try {
|
||||
await queueRunner.enqueue({
|
||||
await enqueueFn({
|
||||
lane: 'email',
|
||||
type: 'email-sync',
|
||||
userId: user.email,
|
||||
@@ -63,7 +67,10 @@ async function tick() {
|
||||
});
|
||||
console.log(`[email-cron] Enqueued incremental sync for ${account.email}`);
|
||||
} catch (err) {
|
||||
console.error(`[email-cron] Failed to enqueue sync for ${account.email}:`, err instanceof Error ? err.message : err);
|
||||
console.error(
|
||||
`[email-cron] Failed to enqueue sync for ${account.email}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -71,11 +78,18 @@ async function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
export function initEmailCron() {
|
||||
type EmailCronDeps = {
|
||||
enqueue: (params: EnqueueParams) => Promise<Job>;
|
||||
listJobs: () => Promise<Job[]>;
|
||||
};
|
||||
|
||||
export function initEmailCron(deps: EmailCronDeps) {
|
||||
if (timer) return;
|
||||
enqueueFn = deps.enqueue;
|
||||
listJobsFn = deps.listJobs;
|
||||
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
|
||||
timer = setInterval(tick, INTERVAL_MS);
|
||||
// Run first tick after a short delay to let the queue initialize
|
||||
// Run first tick after a short delay
|
||||
setTimeout(tick, 30_000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SidecarEvent } from '../protocol';
|
||||
import type { Job, EnqueueParams } from '../../queue/types';
|
||||
import { initEmailCron, stopEmailCron } from './email-cron';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Queue access via WS ──
|
||||
|
||||
let reqCounter = 0;
|
||||
const pendingQueue = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>();
|
||||
|
||||
function nextQueueId(): string {
|
||||
return `eq_${Date.now()}_${++reqCounter}`;
|
||||
}
|
||||
|
||||
function sendQueueCommand(cmd: Record<string, unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = cmd.id as string;
|
||||
const timer = setTimeout(() => {
|
||||
pendingQueue.delete(id);
|
||||
reject(new Error(`Queue command ${cmd.type} timed out`));
|
||||
}, 30_000);
|
||||
pendingQueue.set(id, { resolve, reject, timer });
|
||||
connection.send(cmd as SidecarEvent);
|
||||
});
|
||||
}
|
||||
|
||||
async function enqueueViaWs(params: EnqueueParams): Promise<Job> {
|
||||
const res = (await sendQueueCommand({ type: 'queue:enqueue', id: nextQueueId(), params })) as Record<string, unknown>;
|
||||
if (res.type === 'queue:enqueued') return res.job as Job;
|
||||
if (res.type === 'queue:error') throw new Error(res.error as string);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
async function listJobsViaWs(): Promise<Job[]> {
|
||||
const res = (await sendQueueCommand({ type: 'queue:list', id: nextQueueId() })) as Record<string, unknown>;
|
||||
if (res.type === 'queue:list') return res.jobs as Job[];
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: Record<string, unknown>, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id as string });
|
||||
break;
|
||||
|
||||
default:
|
||||
// Check if this is a queue response (from API server responding to our queue commands)
|
||||
if (
|
||||
typeof cmd.type === 'string' &&
|
||||
cmd.type.startsWith('queue:') &&
|
||||
cmd.id &&
|
||||
pendingQueue.has(cmd.id as string)
|
||||
) {
|
||||
const pending = pendingQueue.get(cmd.id as string)!;
|
||||
pendingQueue.delete(cmd.id as string);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
reply({
|
||||
type: 'error',
|
||||
id: cmd.id as string,
|
||||
error: `Unknown command type: ${cmd.type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'email',
|
||||
capabilities: ['email'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// Start email cron once connected (so queue commands can reach API server)
|
||||
// initEmailCron({ enqueue: enqueueViaWs, listJobs: listJobsViaWs }); // TODO: re-enable after testing
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[email] ${signal} received, shutting down...`);
|
||||
stopEmailCron();
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent, SidecarState } from './protocol';
|
||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import * as piManager from './pi-manager';
|
||||
import * as queueRunner from './queue-runner';
|
||||
import { initEmailCron, stopEmailCron } from './email-cron';
|
||||
import { createSidecarConnector } from './connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error('[sidecar] another instance is already running (lock file exists with live PID)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
loadState();
|
||||
ensureProxySecret();
|
||||
|
||||
// Start Anthropic proxy
|
||||
try {
|
||||
startAnthropicProxy();
|
||||
} catch (err) {
|
||||
console.error('[sidecar] failed to start Anthropic proxy:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
// Initialize queue
|
||||
queueRunner.initQueue().catch((err) => {
|
||||
console.error('[sidecar] failed to initialize queue:', err);
|
||||
});
|
||||
|
||||
// Start email sync cron
|
||||
// initEmailCron(); // TODO: re-enable after initial sync testing
|
||||
|
||||
// ── State ──
|
||||
|
||||
function buildState(): SidecarState {
|
||||
return {
|
||||
proxySecret: getProxySecret(),
|
||||
claudeSessions: { ...getState().claudeSessions },
|
||||
piSessions: piManager.getAllSessions(),
|
||||
uptime: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'state:sync':
|
||||
reply({ type: 'state:sync', id: cmd.id, state: buildState() });
|
||||
break;
|
||||
|
||||
case 'proxy:secret':
|
||||
reply({ type: 'proxy:secret', id: cmd.id, secret: getProxySecret() });
|
||||
break;
|
||||
|
||||
// ── Claude Code ──
|
||||
|
||||
case 'claude:spawn': {
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply({ type: 'claude:result', id: cmd.id, result });
|
||||
} catch (err) {
|
||||
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
|
||||
};
|
||||
|
||||
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||
connection.send({
|
||||
type: 'claude:event',
|
||||
sessionKey: cmd.params.sessionKey,
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
// ── Pi ──
|
||||
|
||||
case 'pi:spawn': {
|
||||
try {
|
||||
const onEvent = (event: import('../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
|
||||
};
|
||||
|
||||
await piManager.spawnPi({
|
||||
sessionId: cmd.params.sessionId,
|
||||
email: cmd.params.email,
|
||||
userId: cmd.params.userId,
|
||||
username: cmd.params.username,
|
||||
role: cmd.params.role,
|
||||
cwd: cmd.params.cwd,
|
||||
model: cmd.params.model,
|
||||
sessionFile: cmd.params.sessionFile,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
reply({ type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
|
||||
} catch (err) {
|
||||
reply({ type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pi:prompt':
|
||||
piManager.sendPrompt(cmd.sessionId, cmd.prompt, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:abort':
|
||||
piManager.abort(cmd.sessionId, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:kill':
|
||||
piManager.killPiSession(cmd.sessionId);
|
||||
reply({ type: 'pi:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'pi:set-thinking':
|
||||
piManager.setThinkingLevel(cmd.sessionId, cmd.level);
|
||||
break;
|
||||
|
||||
// ── Queue ──
|
||||
|
||||
case 'queue:enqueue': {
|
||||
try {
|
||||
const job = await queueRunner.enqueue(cmd.params);
|
||||
reply({ type: 'queue:enqueued', id: cmd.id, job });
|
||||
} catch (err) {
|
||||
reply({ type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:cancel': {
|
||||
try {
|
||||
const job = await queueRunner.cancelJob(cmd.jobId);
|
||||
reply({ type: 'queue:cancelled', id: cmd.id, job });
|
||||
} catch (err) {
|
||||
reply({ type: 'queue:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:list': {
|
||||
const jobs = await queueRunner.listAllJobs();
|
||||
reply({ type: 'queue:list', id: cmd.id, jobs });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'queue:get': {
|
||||
const job = await queueRunner.readJob(cmd.jobId);
|
||||
reply({ type: 'queue:get', id: cmd.id, job });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'process',
|
||||
capabilities: ['claude', 'pi', 'queue', 'proxy'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[sidecar] ${signal} received, saving state...`);
|
||||
stopEmailCron();
|
||||
connection.destroy();
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import * as piManager from './pi-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'pi:spawn': {
|
||||
try {
|
||||
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'pi:event', sessionId: cmd.params.sessionId, event });
|
||||
};
|
||||
|
||||
await piManager.spawnPi({
|
||||
sessionId: cmd.params.sessionId,
|
||||
email: cmd.params.email,
|
||||
userId: cmd.params.userId,
|
||||
username: cmd.params.username,
|
||||
role: cmd.params.role,
|
||||
cwd: cmd.params.cwd,
|
||||
model: cmd.params.model,
|
||||
sessionFile: cmd.params.sessionFile,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
reply({ type: 'pi:spawned', id: cmd.id, sessionId: cmd.params.sessionId });
|
||||
} catch (err) {
|
||||
reply({ type: 'pi:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pi:prompt':
|
||||
piManager.sendPrompt(cmd.sessionId, cmd.prompt, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:abort':
|
||||
piManager.abort(cmd.sessionId, cmd.requestId);
|
||||
break;
|
||||
|
||||
case 'pi:kill':
|
||||
piManager.killPiSession(cmd.sessionId);
|
||||
reply({ type: 'pi:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'pi:set-thinking':
|
||||
piManager.setThinkingLevel(cmd.sessionId, cmd.level);
|
||||
break;
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'pi',
|
||||
capabilities: ['pi'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[pi] ${signal} received, shutting down...`);
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -1,9 +1,8 @@
|
||||
import { join } from 'node:path';
|
||||
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent, MessageCost } from '../api/pi/types';
|
||||
import type { PiSpawnParams, PiSessionInfo } from './protocol';
|
||||
import { isPidAlive } from './state';
|
||||
import type { PiEvent, MessageCost } from '../../api/pi/types';
|
||||
import type { PiSpawnParams, PiSessionInfo } from '../protocol';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent');
|
||||
@@ -27,6 +26,15 @@ const toShellUsername = (username: string, email: string): string => {
|
||||
);
|
||||
};
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve pi as [node, cli.js]
|
||||
const PI_CMD = (() => {
|
||||
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
|
||||
@@ -216,7 +224,7 @@ function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): vo
|
||||
writer.write(JSON.stringify(command) + '\n');
|
||||
writer.flush();
|
||||
} catch (err) {
|
||||
console.error('[sidecar:pi] writeRpcCommand error:', err);
|
||||
console.error('[pi] writeRpcCommand error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +296,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
|
||||
const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
console.log(`[sidecar:pi] spawned Pi for session ${sessionId} (model=${model}, pid=${proc.pid})`);
|
||||
console.log(`[pi] spawned Pi for session ${sessionId} (model=${model}, pid=${proc.pid})`);
|
||||
|
||||
// Read stdout JSON event stream
|
||||
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||
@@ -338,7 +346,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
if (text.trim()) console.log(`[sidecar:pi:stderr] ${text.trim()}`);
|
||||
if (text.trim()) console.log(`[pi:stderr] ${text.trim()}`);
|
||||
}
|
||||
} catch {
|
||||
/* process ended */
|
||||
@@ -349,7 +357,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
|
||||
proc.exited.then((code) => {
|
||||
sessions.delete(sessionId);
|
||||
if (code !== 0) {
|
||||
console.error(`[sidecar:pi] Pi process ${sessionId} exited with code ${code}`);
|
||||
console.error(`[pi] Pi process ${sessionId} exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user