Improve mobile room UX, separate live progress from recent chat, and lock the parity behavior with service tests.
1759 lines
52 KiB
TypeScript
1759 lines
52 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
|
|
import { CronExpressionParser } from 'cron-parser';
|
|
|
|
import { TIMEZONE, WEB_DASHBOARD } from './config.js';
|
|
import {
|
|
createTask,
|
|
deleteTask,
|
|
getAllOpenPairedTasks,
|
|
getAllTasks,
|
|
getLatestPairedTaskForChat,
|
|
getPairedTaskById,
|
|
getPairedTurnAttempts,
|
|
getPairedTurnOutputs,
|
|
getRecentPairedTurnOutputsForChat,
|
|
getPairedTurnsForTask,
|
|
getLatestPairedTurnForTask,
|
|
getRecentChatMessages,
|
|
getRecentChatMessagesBatch,
|
|
getTaskById,
|
|
hasMessage,
|
|
storeChatMetadata,
|
|
storeMessage,
|
|
updatePairedTaskIfUnchanged,
|
|
updateTask,
|
|
} from './db.js';
|
|
import { logger } from './logger.js';
|
|
import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js';
|
|
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
|
import {
|
|
readStatusSnapshots,
|
|
type StatusSnapshot,
|
|
} from './status-dashboard.js';
|
|
import type {
|
|
AgentType,
|
|
NewMessage,
|
|
PairedTask,
|
|
RegisteredGroup,
|
|
ScheduledTask,
|
|
} from './types.js';
|
|
import { isWatchCiTask } from './task-watch-status.js';
|
|
import {
|
|
buildWebDashboardRoomActivity,
|
|
buildWebDashboardOverview,
|
|
sanitizeScheduledTask,
|
|
} from './web-dashboard-data.js';
|
|
import {
|
|
addClaudeAccountFromToken,
|
|
getActiveCodexSettingsIndex,
|
|
getFastMode,
|
|
getModelConfig,
|
|
listClaudeAccounts,
|
|
listCodexAccounts,
|
|
refreshAllCodexAccounts,
|
|
refreshCodexAccount,
|
|
removeAccountDirectory,
|
|
setActiveCodexSettingsIndex,
|
|
updateFastMode,
|
|
updateModelConfig,
|
|
} from './settings-store.js';
|
|
|
|
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
|
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
|
|
const SERVICE_RESTART_LOG_LIMIT = 20;
|
|
const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service';
|
|
const WEB_TASK_PROMPT_MAX_LENGTH = 8000;
|
|
const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
|
|
'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.';
|
|
const UNIT_NOT_FOUND_PATTERN =
|
|
/(Unit .* not found|Could not find the requested service|not-found)/i;
|
|
|
|
type PairedFollowUpTask = Pick<
|
|
PairedTask,
|
|
'id' | 'status' | 'round_trip_count' | 'updated_at'
|
|
>;
|
|
|
|
type WebPairedFollowUpScheduler = (args: {
|
|
chatJid: string;
|
|
runId: string;
|
|
task: PairedFollowUpTask;
|
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
|
enqueue: () => void;
|
|
}) => boolean;
|
|
|
|
export interface ServiceRestartRecord {
|
|
id: string;
|
|
target: 'stack';
|
|
requestedAt: string;
|
|
completedAt: string | null;
|
|
status: 'running' | 'success' | 'failed';
|
|
services: string[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface WebDashboardHandlerOptions {
|
|
staticDir?: string;
|
|
statusMaxAgeMs?: number;
|
|
startBackgroundCacheRefresh?: boolean;
|
|
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
|
|
getTasks?: () => ScheduledTask[];
|
|
getTaskById?: (id: string) => ScheduledTask | undefined;
|
|
updateTask?: (
|
|
id: string,
|
|
updates: Partial<
|
|
Pick<
|
|
ScheduledTask,
|
|
| 'prompt'
|
|
| 'schedule_type'
|
|
| 'schedule_value'
|
|
| 'next_run'
|
|
| 'status'
|
|
| 'suspended_until'
|
|
>
|
|
>,
|
|
) => void;
|
|
createTask?: (task: {
|
|
id: string;
|
|
group_folder: string;
|
|
chat_jid: string;
|
|
agent_type?: AgentType | null;
|
|
prompt: string;
|
|
schedule_type: ScheduledTask['schedule_type'];
|
|
schedule_value: string;
|
|
context_mode: ScheduledTask['context_mode'];
|
|
next_run: string | null;
|
|
status: ScheduledTask['status'];
|
|
created_at: string;
|
|
}) => void;
|
|
deleteTask?: (id: string) => void;
|
|
getPairedTasks?: () => PairedTask[];
|
|
getLatestPairedTaskForChat?: (chatJid: string) => PairedTask | undefined;
|
|
getPairedTurnsForTask?: typeof getPairedTurnsForTask;
|
|
getLatestPairedTurnForTask?: typeof getLatestPairedTurnForTask;
|
|
getPairedTurnAttempts?: typeof getPairedTurnAttempts;
|
|
getPairedTurnOutputs?: typeof getPairedTurnOutputs;
|
|
getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
|
|
getRecentChatMessages?: typeof getRecentChatMessages;
|
|
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
|
updatePairedTaskIfUnchanged?: (
|
|
id: string,
|
|
expectedUpdatedAt: string,
|
|
updates: Partial<
|
|
Pick<
|
|
PairedTask,
|
|
'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason'
|
|
>
|
|
>,
|
|
) => boolean;
|
|
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
|
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
|
storeChatMetadata?: (
|
|
chatJid: string,
|
|
timestamp: string,
|
|
name?: string,
|
|
channel?: string,
|
|
isGroup?: boolean,
|
|
) => void;
|
|
storeMessage?: (message: NewMessage) => void;
|
|
hasMessage?: (chatJid: string, id: string) => boolean;
|
|
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
|
nudgeScheduler?: () => void;
|
|
restartServiceStack?: () => string[];
|
|
now?: () => string;
|
|
}
|
|
|
|
export interface StartedWebDashboardServer {
|
|
url: string;
|
|
stop: () => void;
|
|
}
|
|
|
|
const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
|
|
let roomsTimelineCache: {
|
|
key: string;
|
|
builtAt: number;
|
|
rawJson: string;
|
|
gzipBuffer: Uint8Array;
|
|
} | null = null;
|
|
|
|
function jsonResponse(
|
|
value: unknown,
|
|
init?: ResponseInit,
|
|
request?: Request,
|
|
): Response {
|
|
const body = JSON.stringify(value);
|
|
const acceptsGzip =
|
|
request?.headers.get('accept-encoding')?.includes('gzip') ?? false;
|
|
const baseHeaders: Record<string, string> = {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
...(init?.headers as Record<string, string> | undefined),
|
|
};
|
|
if (acceptsGzip && body.length > 1024) {
|
|
const compressed = Bun.gzipSync(new TextEncoder().encode(body));
|
|
return new Response(compressed, {
|
|
...init,
|
|
headers: {
|
|
...baseHeaders,
|
|
'content-encoding': 'gzip',
|
|
vary: 'accept-encoding',
|
|
},
|
|
});
|
|
}
|
|
return new Response(body, {
|
|
...init,
|
|
headers: baseHeaders,
|
|
});
|
|
}
|
|
|
|
function getContentType(filePath: string): string {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
switch (ext) {
|
|
case '.html':
|
|
return 'text/html; charset=utf-8';
|
|
case '.js':
|
|
case '.mjs':
|
|
return 'text/javascript; charset=utf-8';
|
|
case '.css':
|
|
return 'text/css; charset=utf-8';
|
|
case '.json':
|
|
return 'application/json; charset=utf-8';
|
|
case '.svg':
|
|
return 'image/svg+xml';
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.ico':
|
|
return 'image/x-icon';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
function resolveStaticFile(staticDir: string, pathname: string): string | null {
|
|
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
const candidate = path.resolve(staticDir, normalizedPath || 'index.html');
|
|
const root = path.resolve(staticDir);
|
|
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
|
|
return null;
|
|
}
|
|
|
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
return candidate;
|
|
}
|
|
|
|
const indexPath = path.join(root, 'index.html');
|
|
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
|
return indexPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function serveStaticFile(staticDir: string, pathname: string): Response {
|
|
const filePath = resolveStaticFile(staticDir, pathname);
|
|
if (!filePath) {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
|
|
return new Response(fs.readFileSync(filePath), {
|
|
headers: {
|
|
'content-type': getContentType(filePath),
|
|
},
|
|
});
|
|
}
|
|
|
|
type TaskAction = 'pause' | 'resume' | 'cancel';
|
|
type InboxAction = 'run' | 'decline' | 'dismiss';
|
|
type ServiceAction = 'restart';
|
|
|
|
interface InboxActionRequest {
|
|
action: InboxAction;
|
|
requestId: string | null;
|
|
lastOccurredAt: string | null;
|
|
}
|
|
|
|
interface ServiceActionRequest {
|
|
action: ServiceAction;
|
|
requestId: string | null;
|
|
}
|
|
|
|
function parseTaskPath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseTaskActionPath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseInboxActionPath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseRoomMessagePath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseRoomTimelinePath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/timeline$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseServiceActionPath(pathname: string): string | null {
|
|
const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/);
|
|
if (!match) return null;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parsePairedInboxTarget(
|
|
inboxId: string,
|
|
): { taskId: string; status: PairedTask['status'] } | null {
|
|
if (!inboxId.startsWith('paired:')) return null;
|
|
const rest = inboxId.slice('paired:'.length);
|
|
const separatorIndex = rest.lastIndexOf(':');
|
|
if (separatorIndex <= 0 || separatorIndex === rest.length - 1) return null;
|
|
const status = rest.slice(separatorIndex + 1);
|
|
if (!isPairedTaskStatus(status)) return null;
|
|
return {
|
|
taskId: rest.slice(0, separatorIndex),
|
|
status,
|
|
};
|
|
}
|
|
|
|
function isTaskAction(value: unknown): value is TaskAction {
|
|
return value === 'pause' || value === 'resume' || value === 'cancel';
|
|
}
|
|
|
|
function isScheduleType(
|
|
value: unknown,
|
|
): value is ScheduledTask['schedule_type'] {
|
|
return value === 'cron' || value === 'interval' || value === 'once';
|
|
}
|
|
|
|
function isContextMode(value: unknown): value is ScheduledTask['context_mode'] {
|
|
return value === 'group' || value === 'isolated';
|
|
}
|
|
|
|
function isAgentType(value: unknown): value is AgentType {
|
|
return value === 'claude-code' || value === 'codex';
|
|
}
|
|
|
|
function isInboxAction(value: unknown): value is InboxAction {
|
|
return value === 'run' || value === 'decline' || value === 'dismiss';
|
|
}
|
|
|
|
function isServiceAction(value: unknown): value is ServiceAction {
|
|
return value === 'restart';
|
|
}
|
|
|
|
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
|
|
return (
|
|
value === 'active' ||
|
|
value === 'review_ready' ||
|
|
value === 'in_review' ||
|
|
value === 'merge_ready' ||
|
|
value === 'completed' ||
|
|
value === 'arbiter_requested' ||
|
|
value === 'in_arbitration'
|
|
);
|
|
}
|
|
|
|
async function readTaskAction(request: Request): Promise<TaskAction | null> {
|
|
try {
|
|
const body = (await request.json()) as { action?: unknown };
|
|
return isTaskAction(body.action) ? body.action : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readInboxAction(
|
|
request: Request,
|
|
): Promise<InboxActionRequest | null> {
|
|
try {
|
|
const body = (await request.json()) as {
|
|
action?: unknown;
|
|
requestId?: unknown;
|
|
lastOccurredAt?: unknown;
|
|
};
|
|
if (!isInboxAction(body.action)) return null;
|
|
return {
|
|
action: body.action,
|
|
requestId: sanitizeInboxActionRequestId(body.requestId),
|
|
lastOccurredAt:
|
|
typeof body.lastOccurredAt === 'string' && body.lastOccurredAt.trim()
|
|
? body.lastOccurredAt.trim()
|
|
: null,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readServiceAction(
|
|
request: Request,
|
|
): Promise<ServiceActionRequest | null> {
|
|
try {
|
|
const body = (await request.json()) as {
|
|
action?: unknown;
|
|
requestId?: unknown;
|
|
};
|
|
if (!isServiceAction(body.action)) return null;
|
|
return {
|
|
action: body.action,
|
|
requestId: sanitizeServiceActionRequestId(body.requestId),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface ScheduledTaskMutationBody {
|
|
roomJid?: unknown;
|
|
groupFolder?: unknown;
|
|
prompt?: unknown;
|
|
scheduleType?: unknown;
|
|
scheduleValue?: unknown;
|
|
contextMode?: unknown;
|
|
agentType?: unknown;
|
|
requestId?: unknown;
|
|
}
|
|
|
|
async function readScheduledTaskMutationBody(
|
|
request: Request,
|
|
): Promise<ScheduledTaskMutationBody | null> {
|
|
try {
|
|
return (await request.json()) as ScheduledTaskMutationBody;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function normalizeTaskPrompt(value: unknown): string | null {
|
|
const prompt = normalizeNonEmptyString(value);
|
|
if (!prompt) return null;
|
|
return prompt.length > WEB_TASK_PROMPT_MAX_LENGTH
|
|
? prompt.slice(0, WEB_TASK_PROMPT_MAX_LENGTH)
|
|
: prompt;
|
|
}
|
|
|
|
function computeInitialNextRun(
|
|
scheduleType: ScheduledTask['schedule_type'],
|
|
scheduleValue: string,
|
|
nowIso: string,
|
|
): { nextRun: string | null; error?: string } {
|
|
if (scheduleType === 'cron') {
|
|
try {
|
|
const interval = CronExpressionParser.parse(scheduleValue, {
|
|
tz: TIMEZONE,
|
|
});
|
|
return { nextRun: interval.next().toISOString() };
|
|
} catch {
|
|
return { nextRun: null, error: 'Invalid cron schedule' };
|
|
}
|
|
}
|
|
|
|
if (scheduleType === 'interval') {
|
|
const ms = Number.parseInt(scheduleValue, 10);
|
|
if (!Number.isFinite(ms) || ms <= 0) {
|
|
return { nextRun: null, error: 'Invalid interval schedule' };
|
|
}
|
|
return {
|
|
nextRun: new Date(new Date(nowIso).getTime() + ms).toISOString(),
|
|
};
|
|
}
|
|
|
|
const date = new Date(scheduleValue);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return { nextRun: null, error: 'Invalid timestamp schedule' };
|
|
}
|
|
return { nextRun: date.toISOString() };
|
|
}
|
|
|
|
function resolveTaskRoom(
|
|
body: ScheduledTaskMutationBody,
|
|
roomBindings: Record<string, RegisteredGroup>,
|
|
): { chatJid: string; group: RegisteredGroup } | null {
|
|
const roomJid = normalizeNonEmptyString(body.roomJid);
|
|
const groupFolder = normalizeNonEmptyString(body.groupFolder);
|
|
if (roomJid && roomBindings[roomJid]) {
|
|
return { chatJid: roomJid, group: roomBindings[roomJid] };
|
|
}
|
|
if (groupFolder) {
|
|
const match = Object.entries(roomBindings).find(
|
|
([, group]) => group.folder === groupFolder,
|
|
);
|
|
if (match) return { chatJid: match[0], group: match[1] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makeWebTaskId(): string {
|
|
return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function sanitizeScheduledTaskRequestId(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
|
|
return safe || null;
|
|
}
|
|
|
|
function makeWebTaskIdFromRequest(requestId: string | null): string {
|
|
return requestId ? `web-task-${requestId}` : makeWebTaskId();
|
|
}
|
|
|
|
function sanitizeRoomMessageRequestId(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
|
|
return safe || null;
|
|
}
|
|
|
|
async function readRoomMessageBody(request: Request): Promise<{
|
|
text: string;
|
|
requestId: string | null;
|
|
nickname: string | null;
|
|
} | null> {
|
|
try {
|
|
const body = (await request.json()) as {
|
|
text?: unknown;
|
|
requestId?: unknown;
|
|
nickname?: unknown;
|
|
};
|
|
if (typeof body.text !== 'string') return null;
|
|
const text = body.text.trim();
|
|
if (!text) return null;
|
|
let nickname: string | null = null;
|
|
if (typeof body.nickname === 'string') {
|
|
const trimmed = body.nickname.trim().slice(0, 32);
|
|
if (trimmed) nickname = trimmed;
|
|
}
|
|
return {
|
|
text: text.length > 8000 ? text.slice(0, 8000) : text,
|
|
requestId: sanitizeRoomMessageRequestId(body.requestId),
|
|
nickname,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function makeWebMessageId(): string {
|
|
return `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function makeWebMessageIdFromRequest(requestId: string | null): string {
|
|
return requestId ? `web-${requestId}` : makeWebMessageId();
|
|
}
|
|
|
|
function makeWebRunId(prefix: string): string {
|
|
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function sanitizeServiceActionRequestId(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
|
|
return safe || null;
|
|
}
|
|
|
|
function makeServiceRestartId(requestId: string | null): string {
|
|
return requestId
|
|
? `web-restart-${requestId}`
|
|
: `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function sanitizeInboxActionRequestId(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
|
|
return safe || null;
|
|
}
|
|
|
|
function makeInboxDismissKey(
|
|
inboxId: string,
|
|
lastOccurredAt: string | null,
|
|
): string {
|
|
return lastOccurredAt ? `${inboxId}\0${lastOccurredAt}` : inboxId;
|
|
}
|
|
|
|
function makeWebInboxMessageId(requestId: string | null): string {
|
|
return requestId
|
|
? `web-inbox-${requestId}`
|
|
: `web-inbox-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function declinedInboxMessage(status: PairedTask['status']): string {
|
|
if (status === 'review_ready' || status === 'in_review') {
|
|
return 'Dashboard declined review. Continue with the owner turn.';
|
|
}
|
|
if (status === 'merge_ready') {
|
|
return 'Dashboard declined finalization. Continue with the owner turn.';
|
|
}
|
|
if (status === 'arbiter_requested' || status === 'in_arbitration') {
|
|
return 'Dashboard declined arbiter escalation. Continue with the owner turn.';
|
|
}
|
|
return 'Dashboard declined this inbox item. Continue with the owner turn.';
|
|
}
|
|
|
|
function pairedFollowUpIntentForStatus(
|
|
status: PairedTask['status'],
|
|
): ScheduledPairedFollowUpIntentKind | null {
|
|
if (status === 'review_ready' || status === 'in_review') {
|
|
return 'reviewer-turn';
|
|
}
|
|
if (status === 'merge_ready') {
|
|
return 'finalize-owner-turn';
|
|
}
|
|
if (status === 'arbiter_requested' || status === 'in_arbitration') {
|
|
return 'arbiter-turn';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isRoot(): boolean {
|
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
}
|
|
|
|
function restartEjclawStackServices(): string[] {
|
|
if (process.platform !== 'linux') {
|
|
throw new Error('Service restart only supports Linux systemd services');
|
|
}
|
|
|
|
const services = ['ejclaw'];
|
|
const systemctlArgs = isRoot() ? [] : ['--user'];
|
|
|
|
try {
|
|
execFileSync(
|
|
'systemctl',
|
|
[...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
} catch (error) {
|
|
const stderr =
|
|
error && typeof error === 'object' && 'stderr' in error
|
|
? String((error as { stderr?: unknown }).stderr || '')
|
|
: '';
|
|
const stdout =
|
|
error && typeof error === 'object' && 'stdout' in error
|
|
? String((error as { stdout?: unknown }).stdout || '')
|
|
: '';
|
|
const message =
|
|
error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim();
|
|
const combined = `${message}\n${stdout}\n${stderr}`;
|
|
if (!UNIT_NOT_FOUND_PATTERN.test(combined)) {
|
|
throw error;
|
|
}
|
|
if (process.env.SERVICE_ID) {
|
|
throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE);
|
|
}
|
|
|
|
execFileSync('systemctl', [...systemctlArgs, 'restart', ...services], {
|
|
stdio: 'ignore',
|
|
});
|
|
for (const service of services) {
|
|
execFileSync(
|
|
'systemctl',
|
|
[...systemctlArgs, 'is-active', '--quiet', service],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
}
|
|
}
|
|
|
|
return services;
|
|
}
|
|
|
|
export function createWebDashboardHandler(
|
|
opts: WebDashboardHandlerOptions = {},
|
|
): (request: Request) => Response | Promise<Response> {
|
|
const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots;
|
|
const loadTasks = opts.getTasks ?? getAllTasks;
|
|
const loadTaskById = opts.getTaskById ?? getTaskById;
|
|
const mutateTask = opts.updateTask ?? updateTask;
|
|
const createScheduledTask = opts.createTask ?? createTask;
|
|
const removeTask = opts.deleteTask ?? deleteTask;
|
|
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
|
|
const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
|
|
const mutatePairedTaskIfUnchanged =
|
|
opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged;
|
|
const loadLatestPairedTaskForChat =
|
|
opts.getLatestPairedTaskForChat ?? getLatestPairedTaskForChat;
|
|
const loadPairedTurnsForTask =
|
|
opts.getPairedTurnsForTask ?? getPairedTurnsForTask;
|
|
const loadLatestPairedTurnForTask =
|
|
opts.getLatestPairedTurnForTask ?? getLatestPairedTurnForTask;
|
|
const loadPairedTurnOutputs =
|
|
opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
|
|
const loadRecentPairedTurnOutputsForChat =
|
|
opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
|
|
const loadPairedTurnAttempts =
|
|
opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
|
|
const loadRecentChatMessages =
|
|
opts.getRecentChatMessages ?? getRecentChatMessages;
|
|
const schedulePairedFollowUp =
|
|
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
|
|
const loadRoomBindings = opts.getRoomBindings;
|
|
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
|
|
const writeMessage = opts.storeMessage ?? storeMessage;
|
|
const messageExists = opts.hasMessage ?? hasMessage;
|
|
const enqueueMessageCheck = opts.enqueueMessageCheck;
|
|
const nudgeScheduler = opts.nudgeScheduler;
|
|
const restartServiceStack =
|
|
opts.restartServiceStack ?? restartEjclawStackServices;
|
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
|
|
|
function buildRoomsTimelineResult(): Record<
|
|
string,
|
|
ReturnType<typeof buildWebDashboardRoomActivity>
|
|
> {
|
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
|
const uniqueByJid = new Map<
|
|
string,
|
|
{
|
|
snapshot: (typeof snapshots)[number];
|
|
entry: (typeof snapshots)[number]['entries'][number];
|
|
}
|
|
>();
|
|
for (const snapshot of snapshots) {
|
|
for (const entry of snapshot.entries) {
|
|
const existing = uniqueByJid.get(entry.jid);
|
|
if (
|
|
!existing ||
|
|
existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0
|
|
) {
|
|
uniqueByJid.set(entry.jid, { snapshot, entry });
|
|
}
|
|
}
|
|
}
|
|
const result: Record<
|
|
string,
|
|
ReturnType<typeof buildWebDashboardRoomActivity>
|
|
> = {};
|
|
for (const [jid, { snapshot, entry }] of uniqueByJid) {
|
|
const pairedTask = loadLatestPairedTaskForChat(jid) ?? null;
|
|
const messages = loadRecentChatMessages(jid, 8);
|
|
const progressMessages = pairedTask
|
|
? loadRecentChatMessages(jid, 40)
|
|
: messages;
|
|
const outputs = loadRecentPairedTurnOutputsForChat(jid, 8);
|
|
if (!pairedTask && messages.length === 0 && outputs.length === 0)
|
|
continue;
|
|
const latestTurn = pairedTask
|
|
? loadLatestPairedTurnForTask(pairedTask.id)
|
|
: null;
|
|
const turns = latestTurn ? [latestTurn] : [];
|
|
result[jid] = buildWebDashboardRoomActivity({
|
|
serviceId: snapshot.serviceId,
|
|
entry,
|
|
pairedTask,
|
|
turns,
|
|
attempts: [],
|
|
outputs,
|
|
messages,
|
|
progressMessages,
|
|
outputLimit: 8,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function computeRoomsCacheKey(): string {
|
|
return readSnapshots(statusMaxAgeMs)
|
|
.map((s) => s.updatedAt)
|
|
.sort()
|
|
.join('|');
|
|
}
|
|
|
|
function ensureRoomsTimelineCache(): NonNullable<typeof roomsTimelineCache> {
|
|
const key = computeRoomsCacheKey();
|
|
if (roomsTimelineCache && roomsTimelineCache.key === key) {
|
|
return roomsTimelineCache;
|
|
}
|
|
const result = buildRoomsTimelineResult();
|
|
const rawJson = JSON.stringify(result);
|
|
const gzipBuffer = Bun.gzipSync(new TextEncoder().encode(rawJson));
|
|
roomsTimelineCache = {
|
|
key,
|
|
builtAt: Date.now(),
|
|
rawJson,
|
|
gzipBuffer,
|
|
};
|
|
return roomsTimelineCache;
|
|
}
|
|
|
|
if (opts.startBackgroundCacheRefresh !== false) {
|
|
setTimeout(() => {
|
|
try {
|
|
ensureRoomsTimelineCache();
|
|
} catch {
|
|
/* warm-up failure is non-fatal */
|
|
}
|
|
}, 0);
|
|
setInterval(() => {
|
|
try {
|
|
ensureRoomsTimelineCache();
|
|
} catch {
|
|
/* refresh failure is non-fatal */
|
|
}
|
|
}, ROOMS_TIMELINE_BG_INTERVAL_MS).unref();
|
|
}
|
|
|
|
const seenRoomMessageIds: string[] = [];
|
|
const seenRoomMessageIdSet = new Set<string>();
|
|
const dismissedInboxKeys = new Set<string>();
|
|
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
|
const activeServiceRestartTargets = new Set<string>();
|
|
|
|
function rememberRoomMessageId(id: string): boolean {
|
|
if (seenRoomMessageIdSet.has(id)) return false;
|
|
seenRoomMessageIdSet.add(id);
|
|
seenRoomMessageIds.push(id);
|
|
if (seenRoomMessageIds.length > ROOM_MESSAGE_ID_CACHE_LIMIT) {
|
|
const oldest = seenRoomMessageIds.shift();
|
|
if (oldest) seenRoomMessageIdSet.delete(oldest);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isInboxItemDismissed(item: {
|
|
id: string;
|
|
lastOccurredAt: string;
|
|
}): boolean {
|
|
return (
|
|
dismissedInboxKeys.has(item.id) ||
|
|
dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt))
|
|
);
|
|
}
|
|
|
|
function rememberServiceRestart(record: ServiceRestartRecord): void {
|
|
recentServiceRestarts.unshift(record);
|
|
if (recentServiceRestarts.length > SERVICE_RESTART_LOG_LIMIT) {
|
|
recentServiceRestarts.length = SERVICE_RESTART_LOG_LIMIT;
|
|
}
|
|
}
|
|
|
|
return async (request: Request): Promise<Response> => {
|
|
const url = new URL(request.url);
|
|
const actionTaskId = parseTaskActionPath(url.pathname);
|
|
const taskId = parseTaskPath(url.pathname);
|
|
const actionInboxId = parseInboxActionPath(url.pathname);
|
|
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
|
const timelineRoomJid = parseRoomTimelinePath(url.pathname);
|
|
const actionServiceId = parseServiceActionPath(url.pathname);
|
|
|
|
if (actionTaskId) {
|
|
if (request.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
const action = await readTaskAction(request);
|
|
if (!action) {
|
|
return jsonResponse({ error: 'Invalid task action' }, { status: 400 });
|
|
}
|
|
|
|
const task = loadTaskById(actionTaskId);
|
|
if (!task) {
|
|
return jsonResponse({ error: 'Task not found' }, { status: 404 });
|
|
}
|
|
|
|
if (task.status === 'completed' && action !== 'cancel') {
|
|
return jsonResponse(
|
|
{ error: 'Completed tasks cannot be changed' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
if (action === 'cancel') {
|
|
removeTask(actionTaskId);
|
|
return jsonResponse({ ok: true, id: actionTaskId, deleted: true });
|
|
}
|
|
|
|
mutateTask(actionTaskId, {
|
|
status: action === 'pause' ? 'paused' : 'active',
|
|
suspended_until: null,
|
|
});
|
|
|
|
const updatedTask = loadTaskById(actionTaskId);
|
|
return jsonResponse({
|
|
ok: true,
|
|
task: updatedTask ? sanitizeScheduledTask(updatedTask) : null,
|
|
});
|
|
}
|
|
|
|
if (actionInboxId) {
|
|
if (request.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
const inboxRequest = await readInboxAction(request);
|
|
if (!inboxRequest) {
|
|
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
|
|
}
|
|
|
|
if (inboxRequest.action === 'dismiss') {
|
|
dismissedInboxKeys.add(
|
|
makeInboxDismissKey(actionInboxId, inboxRequest.lastOccurredAt),
|
|
);
|
|
return jsonResponse({ ok: true, id: actionInboxId, dismissed: true });
|
|
}
|
|
|
|
const pairedTarget = parsePairedInboxTarget(actionInboxId);
|
|
if (!pairedTarget) {
|
|
return jsonResponse(
|
|
{ error: 'Unsupported inbox action target' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (!enqueueMessageCheck) {
|
|
return jsonResponse(
|
|
{ error: 'Paired follow-up queue is not configured' },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
const task = loadPairedTaskById(pairedTarget.taskId);
|
|
if (!task) {
|
|
return jsonResponse(
|
|
{ error: 'Paired task not found' },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
if (inboxRequest.action === 'decline') {
|
|
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
|
|
if (inboxRequest.requestId && messageExists(task.chat_jid, messageId)) {
|
|
return jsonResponse({
|
|
ok: true,
|
|
id: actionInboxId,
|
|
taskId: task.id,
|
|
queued: false,
|
|
duplicate: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (task.status !== pairedTarget.status) {
|
|
return jsonResponse(
|
|
{
|
|
error: 'Inbox item is stale',
|
|
status: task.status,
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
if (inboxRequest.action === 'decline') {
|
|
const timestamp = opts.now?.() ?? new Date().toISOString();
|
|
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
|
|
const updates: Partial<
|
|
Pick<
|
|
PairedTask,
|
|
| 'status'
|
|
| 'updated_at'
|
|
| 'arbiter_requested_at'
|
|
| 'completion_reason'
|
|
>
|
|
> = {
|
|
status: 'active',
|
|
updated_at: timestamp,
|
|
};
|
|
if (
|
|
task.status === 'arbiter_requested' ||
|
|
task.status === 'in_arbitration'
|
|
) {
|
|
updates.arbiter_requested_at = null;
|
|
}
|
|
|
|
const updated = mutatePairedTaskIfUnchanged(
|
|
task.id,
|
|
task.updated_at,
|
|
updates,
|
|
);
|
|
if (!updated) {
|
|
return jsonResponse(
|
|
{ error: 'Inbox item is stale', status: task.status },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
writeChatMetadata(
|
|
task.chat_jid,
|
|
timestamp,
|
|
undefined,
|
|
'web-dashboard',
|
|
true,
|
|
);
|
|
writeMessage({
|
|
id: messageId,
|
|
chat_jid: task.chat_jid,
|
|
sender: 'web-dashboard',
|
|
sender_name: 'Web Dashboard',
|
|
content: declinedInboxMessage(task.status),
|
|
timestamp,
|
|
is_from_me: false,
|
|
is_bot_message: false,
|
|
message_source_kind: 'ipc_injected_human',
|
|
});
|
|
enqueueMessageCheck(task.chat_jid, task.group_folder);
|
|
|
|
return jsonResponse({
|
|
ok: true,
|
|
id: actionInboxId,
|
|
taskId: task.id,
|
|
status: 'active',
|
|
queued: true,
|
|
});
|
|
}
|
|
|
|
const intentKind = pairedFollowUpIntentForStatus(task.status);
|
|
if (!intentKind) {
|
|
return jsonResponse(
|
|
{ error: 'Paired task is not actionable' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const queued = schedulePairedFollowUp({
|
|
chatJid: task.chat_jid,
|
|
runId: makeWebRunId('inbox'),
|
|
task,
|
|
intentKind,
|
|
enqueue: () => enqueueMessageCheck(task.chat_jid, task.group_folder),
|
|
});
|
|
|
|
return jsonResponse({
|
|
ok: true,
|
|
id: actionInboxId,
|
|
taskId: task.id,
|
|
intentKind,
|
|
queued,
|
|
});
|
|
}
|
|
|
|
if (messageRoomJid) {
|
|
if (request.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
if (!loadRoomBindings || !enqueueMessageCheck) {
|
|
return jsonResponse(
|
|
{ error: 'Room message injection is not configured' },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
const body = await readRoomMessageBody(request);
|
|
if (!body) {
|
|
return jsonResponse(
|
|
{ error: 'Message text is required' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const binding = loadRoomBindings()[messageRoomJid];
|
|
if (!binding) {
|
|
return jsonResponse({ error: 'Room not found' }, { status: 404 });
|
|
}
|
|
|
|
const timestamp = opts.now?.() ?? new Date().toISOString();
|
|
const id = makeWebMessageIdFromRequest(body.requestId);
|
|
if (body.requestId && messageExists(messageRoomJid, id)) {
|
|
return jsonResponse({ ok: true, id, queued: false, duplicate: true });
|
|
}
|
|
|
|
if (!rememberRoomMessageId(`${messageRoomJid}:${id}`)) {
|
|
return jsonResponse({ ok: true, id, queued: false, duplicate: true });
|
|
}
|
|
|
|
writeChatMetadata(
|
|
messageRoomJid,
|
|
timestamp,
|
|
binding.name,
|
|
'web-dashboard',
|
|
true,
|
|
);
|
|
writeMessage({
|
|
id,
|
|
chat_jid: messageRoomJid,
|
|
sender: 'web-dashboard',
|
|
sender_name: body.nickname ?? 'Web Dashboard',
|
|
content: body.text,
|
|
timestamp,
|
|
is_from_me: false,
|
|
is_bot_message: false,
|
|
message_source_kind: 'ipc_injected_human',
|
|
});
|
|
enqueueMessageCheck(messageRoomJid, binding.folder);
|
|
|
|
return jsonResponse({ ok: true, id, queued: true });
|
|
}
|
|
|
|
if (timelineRoomJid) {
|
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
|
const matched = snapshots
|
|
.flatMap((snapshot) =>
|
|
snapshot.entries
|
|
.filter((entry) => entry.jid === timelineRoomJid)
|
|
.map((entry) => ({ snapshot, entry })),
|
|
)
|
|
.sort((a, b) =>
|
|
b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt),
|
|
)
|
|
.at(0);
|
|
if (!matched) {
|
|
return jsonResponse(
|
|
{ error: 'Room timeline not found' },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
const pairedTask = loadLatestPairedTaskForChat(timelineRoomJid) ?? null;
|
|
const turns = pairedTask ? loadPairedTurnsForTask(pairedTask.id) : [];
|
|
const attempts = turns.flatMap((turn) =>
|
|
loadPairedTurnAttempts(turn.turn_id),
|
|
);
|
|
return jsonResponse(
|
|
buildWebDashboardRoomActivity({
|
|
serviceId: matched.snapshot.serviceId,
|
|
entry: matched.entry,
|
|
pairedTask,
|
|
turns,
|
|
attempts,
|
|
outputs: pairedTask ? loadPairedTurnOutputs(pairedTask.id) : [],
|
|
messages: loadRecentChatMessages(timelineRoomJid, 8),
|
|
progressMessages: loadRecentChatMessages(timelineRoomJid, 40),
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (actionServiceId) {
|
|
if (request.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
if (actionServiceId !== 'stack') {
|
|
return jsonResponse(
|
|
{ error: 'Unsupported service restart target' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const serviceRequest = await readServiceAction(request);
|
|
if (!serviceRequest) {
|
|
return jsonResponse(
|
|
{ error: 'Invalid service action' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const id = makeServiceRestartId(serviceRequest.requestId);
|
|
const previous = recentServiceRestarts.find((record) => record.id === id);
|
|
if (serviceRequest.requestId && previous) {
|
|
if (previous.status === 'failed') {
|
|
return jsonResponse(
|
|
{
|
|
error: previous.error ?? 'Service restart failed',
|
|
duplicate: true,
|
|
restart: previous,
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
return jsonResponse({
|
|
ok: true,
|
|
duplicate: true,
|
|
restart: previous,
|
|
});
|
|
}
|
|
|
|
if (activeServiceRestartTargets.has(actionServiceId)) {
|
|
return jsonResponse(
|
|
{ error: 'Service restart is already running' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const requestedAt = opts.now?.() ?? new Date().toISOString();
|
|
const record: ServiceRestartRecord = {
|
|
id,
|
|
target: 'stack',
|
|
requestedAt,
|
|
completedAt: null,
|
|
status: 'running',
|
|
services: [],
|
|
};
|
|
rememberServiceRestart(record);
|
|
activeServiceRestartTargets.add(actionServiceId);
|
|
|
|
try {
|
|
const services = restartServiceStack();
|
|
record.completedAt = opts.now?.() ?? new Date().toISOString();
|
|
record.status = 'success';
|
|
record.services = services;
|
|
return jsonResponse({ ok: true, restart: record });
|
|
} catch (error) {
|
|
record.completedAt = opts.now?.() ?? new Date().toISOString();
|
|
record.status = 'failed';
|
|
record.error = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse(
|
|
{ error: record.error, restart: record },
|
|
{ status: 500 },
|
|
);
|
|
} finally {
|
|
activeServiceRestartTargets.delete(actionServiceId);
|
|
}
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/api/settings/models' &&
|
|
(request.method === 'PUT' || request.method === 'PATCH')
|
|
) {
|
|
let body: unknown = null;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
|
}
|
|
if (!body || typeof body !== 'object') {
|
|
return jsonResponse(
|
|
{ error: 'Body must be a JSON object' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
try {
|
|
const next = updateModelConfig(body as Record<string, unknown>);
|
|
return jsonResponse(next);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/api/settings/fast-mode' &&
|
|
(request.method === 'PUT' || request.method === 'PATCH')
|
|
) {
|
|
let body: unknown = null;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
|
}
|
|
if (!body || typeof body !== 'object') {
|
|
return jsonResponse(
|
|
{ error: 'Body must be a JSON object' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
try {
|
|
const next = updateFastMode(body as Record<string, unknown>);
|
|
return jsonResponse(next);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
{
|
|
const accountAddMatch = url.pathname.match(
|
|
/^\/api\/settings\/accounts\/(claude)$/,
|
|
);
|
|
if (accountAddMatch && request.method === 'POST') {
|
|
let body: { token?: unknown } | null = null;
|
|
try {
|
|
body = (await request.json()) as { token?: unknown };
|
|
} catch {
|
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
|
}
|
|
const token = typeof body?.token === 'string' ? body.token.trim() : '';
|
|
if (!token) {
|
|
return jsonResponse({ error: 'token is required' }, { status: 400 });
|
|
}
|
|
try {
|
|
const result = addClaudeAccountFromToken(token);
|
|
return jsonResponse({ ok: true, ...result });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 400 });
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
const accountDelMatch = url.pathname.match(
|
|
/^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/,
|
|
);
|
|
if (accountDelMatch && request.method === 'DELETE') {
|
|
const provider = accountDelMatch[1] as 'claude' | 'codex';
|
|
const index = Number.parseInt(accountDelMatch[2], 10);
|
|
try {
|
|
removeAccountDirectory(provider, index);
|
|
return jsonResponse({ ok: true, provider, index });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 400 });
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
const refreshMatch = url.pathname.match(
|
|
/^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/,
|
|
);
|
|
if (refreshMatch && request.method === 'POST') {
|
|
const index = Number.parseInt(refreshMatch[1], 10);
|
|
try {
|
|
const updated = await refreshCodexAccount(index);
|
|
return jsonResponse({ ok: true, account: updated });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 400 });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/api/settings/accounts/codex/refresh-all' &&
|
|
request.method === 'POST'
|
|
) {
|
|
try {
|
|
const result = await refreshAllCodexAccounts();
|
|
return jsonResponse({ ok: true, ...result });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/api/settings/accounts/codex/current' &&
|
|
request.method === 'PUT'
|
|
) {
|
|
let body: { index?: unknown } | null = null;
|
|
try {
|
|
body = (await request.json()) as { index?: unknown };
|
|
} catch {
|
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
|
}
|
|
const idx = typeof body?.index === 'number' ? body.index : Number.NaN;
|
|
if (!Number.isInteger(idx)) {
|
|
return jsonResponse(
|
|
{ error: 'index must be an integer' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
try {
|
|
setActiveCodexSettingsIndex(idx);
|
|
return jsonResponse({
|
|
ok: true,
|
|
codexCurrentIndex: getActiveCodexSettingsIndex(),
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return jsonResponse({ error: message }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
if (url.pathname === '/api/tasks' && request.method === 'POST') {
|
|
if (!loadRoomBindings) {
|
|
return jsonResponse(
|
|
{ error: 'Task creation is not configured' },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
const body = await readScheduledTaskMutationBody(request);
|
|
const prompt = normalizeTaskPrompt(body?.prompt);
|
|
const scheduleType =
|
|
body && isScheduleType(body.scheduleType) ? body.scheduleType : null;
|
|
const scheduleValue = normalizeNonEmptyString(body?.scheduleValue);
|
|
if (!body || !prompt || !scheduleType || !scheduleValue) {
|
|
return jsonResponse(
|
|
{
|
|
error:
|
|
'Task prompt, schedule type, and schedule value are required',
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const room = resolveTaskRoom(body, loadRoomBindings());
|
|
if (!room) {
|
|
return jsonResponse({ error: 'Room not found' }, { status: 404 });
|
|
}
|
|
|
|
const nowIso = opts.now?.() ?? new Date().toISOString();
|
|
const next = computeInitialNextRun(scheduleType, scheduleValue, nowIso);
|
|
if (next.error) {
|
|
return jsonResponse({ error: next.error }, { status: 400 });
|
|
}
|
|
|
|
const contextMode = isContextMode(body.contextMode)
|
|
? body.contextMode
|
|
: 'isolated';
|
|
const agentType = isAgentType(body.agentType)
|
|
? body.agentType
|
|
: (room.group.agentType ?? 'claude-code');
|
|
const requestId = sanitizeScheduledTaskRequestId(body.requestId);
|
|
const id = makeWebTaskIdFromRequest(requestId);
|
|
const existing = requestId ? loadTaskById(id) : undefined;
|
|
if (existing) {
|
|
return jsonResponse({
|
|
ok: true,
|
|
duplicate: true,
|
|
task: sanitizeScheduledTask(existing),
|
|
});
|
|
}
|
|
|
|
createScheduledTask({
|
|
id,
|
|
group_folder: room.group.folder,
|
|
chat_jid: room.chatJid,
|
|
agent_type: agentType,
|
|
prompt,
|
|
schedule_type: scheduleType,
|
|
schedule_value: scheduleValue,
|
|
context_mode: contextMode,
|
|
next_run: next.nextRun,
|
|
status: 'active',
|
|
created_at: nowIso,
|
|
});
|
|
|
|
const created = loadTaskById(id);
|
|
if (
|
|
next.nextRun &&
|
|
new Date(next.nextRun).getTime() <= new Date(nowIso).getTime()
|
|
) {
|
|
nudgeScheduler?.();
|
|
}
|
|
return jsonResponse({
|
|
ok: true,
|
|
task: created ? sanitizeScheduledTask(created) : null,
|
|
});
|
|
}
|
|
|
|
if (taskId && request.method === 'PATCH') {
|
|
const task = loadTaskById(taskId);
|
|
if (!task) {
|
|
return jsonResponse({ error: 'Task not found' }, { status: 404 });
|
|
}
|
|
if (task.status === 'completed') {
|
|
return jsonResponse(
|
|
{ error: 'Completed tasks cannot be edited' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
if (isWatchCiTask(task)) {
|
|
return jsonResponse(
|
|
{ error: 'CI watchers cannot be edited here' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const body = await readScheduledTaskMutationBody(request);
|
|
if (!body) {
|
|
return jsonResponse({ error: 'Invalid task update' }, { status: 400 });
|
|
}
|
|
if (
|
|
body.roomJid !== undefined ||
|
|
body.groupFolder !== undefined ||
|
|
body.contextMode !== undefined ||
|
|
body.agentType !== undefined
|
|
) {
|
|
return jsonResponse(
|
|
{ error: 'Task room, context, and agent cannot be edited here' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const prompt =
|
|
body.prompt === undefined
|
|
? task.prompt
|
|
: normalizeTaskPrompt(body.prompt);
|
|
const scheduleChanged =
|
|
body.scheduleType !== undefined || body.scheduleValue !== undefined;
|
|
const scheduleType =
|
|
body.scheduleType === undefined
|
|
? task.schedule_type
|
|
: isScheduleType(body.scheduleType)
|
|
? body.scheduleType
|
|
: null;
|
|
const scheduleValue =
|
|
body.scheduleValue === undefined
|
|
? task.schedule_value
|
|
: normalizeNonEmptyString(body.scheduleValue);
|
|
if (!prompt || !scheduleType || !scheduleValue) {
|
|
return jsonResponse({ error: 'Invalid task update' }, { status: 400 });
|
|
}
|
|
|
|
const updates: Parameters<typeof mutateTask>[1] = {
|
|
prompt,
|
|
schedule_type: scheduleType,
|
|
schedule_value: scheduleValue,
|
|
};
|
|
|
|
if (scheduleChanged) {
|
|
const next = computeInitialNextRun(
|
|
scheduleType,
|
|
scheduleValue,
|
|
opts.now?.() ?? new Date().toISOString(),
|
|
);
|
|
if (next.error) {
|
|
return jsonResponse({ error: next.error }, { status: 400 });
|
|
}
|
|
updates.next_run = next.nextRun;
|
|
updates.suspended_until = null;
|
|
}
|
|
|
|
mutateTask(taskId, updates);
|
|
const updatedTask = loadTaskById(taskId);
|
|
return jsonResponse({
|
|
ok: true,
|
|
task: updatedTask ? sanitizeScheduledTask(updatedTask) : null,
|
|
});
|
|
}
|
|
|
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
|
}
|
|
|
|
if (url.pathname === '/api/health') {
|
|
return jsonResponse({ ok: true });
|
|
}
|
|
|
|
if (url.pathname === '/api/overview') {
|
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
|
const tasks = loadTasks();
|
|
const pairedTasks = loadPairedTasks();
|
|
const overview = buildWebDashboardOverview({
|
|
now: opts.now?.(),
|
|
snapshots,
|
|
tasks,
|
|
pairedTasks,
|
|
});
|
|
return jsonResponse({
|
|
...overview,
|
|
operations: {
|
|
serviceRestarts: recentServiceRestarts,
|
|
},
|
|
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/api/status-snapshots') {
|
|
return jsonResponse(readSnapshots(statusMaxAgeMs));
|
|
}
|
|
|
|
if (url.pathname === '/api/tasks') {
|
|
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
|
|
}
|
|
|
|
if (url.pathname === '/api/settings/accounts') {
|
|
return jsonResponse({
|
|
claude: listClaudeAccounts(),
|
|
codex: listCodexAccounts(),
|
|
codexCurrentIndex: getActiveCodexSettingsIndex(),
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/api/settings/models') {
|
|
return jsonResponse(getModelConfig());
|
|
}
|
|
|
|
if (url.pathname === '/api/settings/fast-mode') {
|
|
return jsonResponse(getFastMode());
|
|
}
|
|
|
|
if (url.pathname === '/api/stream') {
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
let lastBuiltAt = 0;
|
|
let closed = false;
|
|
|
|
const enqueue = (chunk: string) => {
|
|
if (closed) return;
|
|
try {
|
|
controller.enqueue(encoder.encode(chunk));
|
|
} catch {
|
|
closed = true;
|
|
}
|
|
};
|
|
|
|
// Initial: send retry hint and seed event
|
|
enqueue(`retry: 3000\n\n`);
|
|
try {
|
|
const cache = ensureRoomsTimelineCache();
|
|
lastBuiltAt = cache.builtAt;
|
|
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
|
} catch {
|
|
/* warm-up failure is non-fatal */
|
|
}
|
|
|
|
const tick = () => {
|
|
if (closed) return;
|
|
try {
|
|
const cache = ensureRoomsTimelineCache();
|
|
if (cache.builtAt !== lastBuiltAt) {
|
|
lastBuiltAt = cache.builtAt;
|
|
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
|
} else {
|
|
// heartbeat to keep connection alive through proxies
|
|
enqueue(`: ping ${Date.now()}\n\n`);
|
|
}
|
|
} catch {
|
|
/* skip this tick */
|
|
}
|
|
};
|
|
|
|
const interval = setInterval(tick, 1500);
|
|
const close = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
clearInterval(interval);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
request.signal.addEventListener('abort', close);
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
'cache-control': 'no-cache, no-transform',
|
|
connection: 'keep-alive',
|
|
'x-accel-buffering': 'no',
|
|
},
|
|
});
|
|
}
|
|
|
|
if (url.pathname === '/api/rooms-timeline') {
|
|
const cache = ensureRoomsTimelineCache();
|
|
const acceptsGzip =
|
|
request.headers.get('accept-encoding')?.includes('gzip') ?? false;
|
|
if (acceptsGzip) {
|
|
return new Response(cache.gzipBuffer, {
|
|
headers: {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'content-encoding': 'gzip',
|
|
vary: 'accept-encoding',
|
|
'x-cache-age': String(Date.now() - cache.builtAt),
|
|
},
|
|
});
|
|
}
|
|
return new Response(cache.rawJson, {
|
|
headers: {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'x-cache-age': String(Date.now() - cache.builtAt),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/')) {
|
|
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
|
|
if (!opts.staticDir) {
|
|
return new Response('Dashboard static directory is not configured', {
|
|
status: 404,
|
|
});
|
|
}
|
|
|
|
return serveStaticFile(opts.staticDir, url.pathname);
|
|
};
|
|
}
|
|
|
|
export function startWebDashboardServer(
|
|
opts: {
|
|
enabled?: boolean;
|
|
host?: string;
|
|
port?: number;
|
|
staticDir?: string;
|
|
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
|
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
|
nudgeScheduler?: () => void;
|
|
} = {},
|
|
): StartedWebDashboardServer | null {
|
|
const enabled = opts.enabled ?? WEB_DASHBOARD.enabled;
|
|
if (!enabled) return null;
|
|
|
|
const host = opts.host ?? WEB_DASHBOARD.host;
|
|
const port = opts.port ?? WEB_DASHBOARD.port;
|
|
const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir;
|
|
const server = Bun.serve({
|
|
hostname: host,
|
|
port,
|
|
fetch: createWebDashboardHandler({
|
|
staticDir,
|
|
getRoomBindings: opts.getRoomBindings,
|
|
enqueueMessageCheck: opts.enqueueMessageCheck,
|
|
nudgeScheduler: opts.nudgeScheduler,
|
|
}),
|
|
});
|
|
const url = `http://${host}:${server.port}`;
|
|
|
|
logger.info({ url, staticDir }, 'Web dashboard started');
|
|
|
|
return {
|
|
url,
|
|
stop: () => server.stop(true),
|
|
};
|
|
}
|