* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering * Extract dashboard simple route table
1757 lines
52 KiB
TypeScript
1757 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 { handleSimpleGetRoute } from './web-dashboard-routes.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 });
|
|
}
|
|
|
|
const simpleGetRoute = handleSimpleGetRoute({
|
|
url,
|
|
statusMaxAgeMs,
|
|
readSnapshots,
|
|
loadTasks,
|
|
jsonResponse,
|
|
});
|
|
if (simpleGetRoute) return simpleGetRoute;
|
|
|
|
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/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),
|
|
};
|
|
}
|