* 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
546 lines
13 KiB
TypeScript
546 lines
13 KiB
TypeScript
export interface DashboardOverview {
|
|
generatedAt: string;
|
|
services: Array<{
|
|
serviceId: string;
|
|
assistantName: string;
|
|
agentType: string;
|
|
updatedAt: string;
|
|
totalRooms: number;
|
|
activeRooms: number;
|
|
}>;
|
|
rooms: {
|
|
total: number;
|
|
active: number;
|
|
waiting: number;
|
|
inactive: number;
|
|
};
|
|
tasks: {
|
|
total: number;
|
|
active: number;
|
|
paused: number;
|
|
completed: number;
|
|
watchers: {
|
|
active: number;
|
|
paused: number;
|
|
completed: number;
|
|
};
|
|
};
|
|
usage: {
|
|
rows: Array<{
|
|
name: string;
|
|
h5pct: number;
|
|
h5reset: string;
|
|
d7pct: number;
|
|
d7reset: string;
|
|
}>;
|
|
fetchedAt: string | null;
|
|
};
|
|
operations?: {
|
|
serviceRestarts: DashboardServiceRestart[];
|
|
};
|
|
inbox: Array<{
|
|
id: string;
|
|
groupKey: string;
|
|
kind:
|
|
| 'pending-room'
|
|
| 'reviewer-request'
|
|
| 'approval'
|
|
| 'arbiter-request'
|
|
| 'ci-failure'
|
|
| 'mention';
|
|
severity: 'info' | 'warn' | 'error';
|
|
title: string;
|
|
summary: string;
|
|
occurredAt: string;
|
|
lastOccurredAt: string;
|
|
createdAt: string;
|
|
occurrences: number;
|
|
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
|
roomJid?: string;
|
|
roomName?: string;
|
|
groupFolder?: string;
|
|
serviceId?: string;
|
|
taskId?: string;
|
|
taskStatus?: string;
|
|
}>;
|
|
}
|
|
|
|
export interface DashboardServiceRestart {
|
|
id: string;
|
|
target: 'stack';
|
|
requestedAt: string;
|
|
completedAt: string | null;
|
|
status: 'running' | 'success' | 'failed';
|
|
services: string[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface StatusSnapshot {
|
|
serviceId: string;
|
|
agentType: string;
|
|
assistantName: string;
|
|
updatedAt: string;
|
|
entries: Array<{
|
|
jid: string;
|
|
name: string;
|
|
folder: string;
|
|
agentType: string;
|
|
status: 'processing' | 'waiting' | 'inactive';
|
|
elapsedMs: number | null;
|
|
pendingMessages: boolean;
|
|
pendingTasks: number;
|
|
}>;
|
|
}
|
|
|
|
export interface DashboardRoomActivity {
|
|
serviceId: string;
|
|
jid: string;
|
|
name: string;
|
|
folder: string;
|
|
agentType: string;
|
|
status: 'processing' | 'waiting' | 'inactive';
|
|
elapsedMs: number | null;
|
|
pendingMessages: boolean;
|
|
pendingTasks: number;
|
|
messages: Array<{
|
|
id: string;
|
|
sender: string;
|
|
senderName: string;
|
|
content: string;
|
|
attachments?: Array<{
|
|
path: string;
|
|
name?: string;
|
|
mime?: string;
|
|
}>;
|
|
timestamp: string;
|
|
isFromMe: boolean;
|
|
isBotMessage: boolean;
|
|
sourceKind: string;
|
|
}>;
|
|
pairedTask: {
|
|
id: string;
|
|
title: string | null;
|
|
status: string;
|
|
roundTripCount: number;
|
|
updatedAt: string;
|
|
currentTurn: {
|
|
turnId: string;
|
|
role: string;
|
|
intentKind: string;
|
|
state: string;
|
|
attemptNo: number;
|
|
executorServiceId: string | null;
|
|
executorAgentType: string | null;
|
|
activeRunId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
completedAt: string | null;
|
|
lastError: string | null;
|
|
progressText: string | null;
|
|
progressUpdatedAt: string | null;
|
|
} | null;
|
|
outputs: Array<{
|
|
id: number;
|
|
turnNumber: number;
|
|
role: string;
|
|
verdict: string | null;
|
|
createdAt: string;
|
|
outputText: string;
|
|
attachments?: Array<{
|
|
path: string;
|
|
name?: string;
|
|
mime?: string;
|
|
}>;
|
|
}>;
|
|
} | null;
|
|
}
|
|
|
|
export interface DashboardTask {
|
|
id: string;
|
|
groupFolder: string;
|
|
chatJid: string;
|
|
agentType: string | null;
|
|
ciProvider: string | null;
|
|
ciMetadata: string | null;
|
|
scheduleType: string;
|
|
scheduleValue: string;
|
|
contextMode: string;
|
|
nextRun: string | null;
|
|
lastRun: string | null;
|
|
lastResult: string | null;
|
|
status: 'active' | 'paused' | 'completed';
|
|
suspendedUntil: string | null;
|
|
createdAt: string;
|
|
promptPreview: string;
|
|
promptLength: number;
|
|
isWatcher: boolean;
|
|
}
|
|
|
|
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
|
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
|
|
export type DashboardServiceAction = 'restart';
|
|
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
|
export type DashboardTaskContextMode = 'group' | 'isolated';
|
|
|
|
export interface CreateScheduledTaskInput {
|
|
roomJid: string;
|
|
prompt: string;
|
|
scheduleType: DashboardTaskScheduleType;
|
|
scheduleValue: string;
|
|
contextMode: DashboardTaskContextMode;
|
|
requestId?: string;
|
|
}
|
|
|
|
export interface UpdateScheduledTaskInput {
|
|
prompt?: string;
|
|
scheduleType: DashboardTaskScheduleType;
|
|
scheduleValue: string;
|
|
}
|
|
|
|
async function fetchJson<T>(path: string): Promise<T> {
|
|
const response = await fetch(path, {
|
|
headers: { accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${path} failed: ${response.status}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
const response = await fetch(path, {
|
|
method: 'POST',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) {
|
|
let message = `${path} failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) message = payload.error;
|
|
} catch {
|
|
// Keep the status-based message when the server body is not JSON.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
export async function fetchDashboardData(): Promise<{
|
|
overview: DashboardOverview;
|
|
snapshots: StatusSnapshot[];
|
|
tasks: DashboardTask[];
|
|
}> {
|
|
const [overview, snapshots, tasks] = await Promise.all([
|
|
fetchJson<DashboardOverview>('/api/overview'),
|
|
fetchJson<StatusSnapshot[]>('/api/status-snapshots'),
|
|
fetchJson<DashboardTask[]>('/api/tasks'),
|
|
]);
|
|
|
|
return { overview, snapshots, tasks };
|
|
}
|
|
|
|
export async function fetchRoomTimeline(
|
|
roomJid: string,
|
|
): Promise<DashboardRoomActivity> {
|
|
return fetchJson<DashboardRoomActivity>(
|
|
`/api/rooms/${encodeURIComponent(roomJid)}/timeline`,
|
|
);
|
|
}
|
|
|
|
export async function fetchRoomsTimelineBatch(): Promise<
|
|
Record<string, DashboardRoomActivity>
|
|
> {
|
|
return fetchJson<Record<string, DashboardRoomActivity>>(
|
|
'/api/rooms-timeline',
|
|
);
|
|
}
|
|
|
|
export async function runScheduledTaskAction(
|
|
taskId: string,
|
|
action: DashboardTaskAction,
|
|
): Promise<{
|
|
ok: true;
|
|
id?: string;
|
|
deleted?: boolean;
|
|
task?: DashboardTask | null;
|
|
}> {
|
|
return postJson(`/api/tasks/${encodeURIComponent(taskId)}/actions`, {
|
|
action,
|
|
});
|
|
}
|
|
|
|
export async function createScheduledTask(
|
|
input: CreateScheduledTaskInput,
|
|
): Promise<{ ok: true; task: DashboardTask | null }> {
|
|
return postJson('/api/tasks', input);
|
|
}
|
|
|
|
export async function updateScheduledTask(
|
|
taskId: string,
|
|
input: UpdateScheduledTaskInput,
|
|
): Promise<{ ok: true; task: DashboardTask | null }> {
|
|
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!response.ok) {
|
|
let message = `/api/tasks/${taskId} failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) message = payload.error;
|
|
} catch {
|
|
// Keep the status-based message when the server body is not JSON.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
return (await response.json()) as { ok: true; task: DashboardTask | null };
|
|
}
|
|
|
|
export async function runInboxAction(
|
|
inboxId: string,
|
|
action: DashboardInboxAction,
|
|
options: { requestId?: string; lastOccurredAt?: string } = {},
|
|
): Promise<{
|
|
ok: true;
|
|
id: string;
|
|
taskId?: string;
|
|
intentKind?: string;
|
|
status?: string;
|
|
queued?: boolean;
|
|
dismissed?: boolean;
|
|
duplicate?: boolean;
|
|
}> {
|
|
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
|
|
action,
|
|
lastOccurredAt: options.lastOccurredAt,
|
|
requestId: options.requestId,
|
|
});
|
|
}
|
|
|
|
export async function runServiceAction(
|
|
serviceId: 'stack',
|
|
action: DashboardServiceAction,
|
|
options: { requestId?: string } = {},
|
|
): Promise<{
|
|
ok: true;
|
|
duplicate?: boolean;
|
|
restart: DashboardServiceRestart;
|
|
}> {
|
|
return postJson(`/api/services/${encodeURIComponent(serviceId)}/actions`, {
|
|
action,
|
|
requestId: options.requestId,
|
|
});
|
|
}
|
|
|
|
export async function sendRoomMessage(
|
|
roomJid: string,
|
|
text: string,
|
|
requestId: string,
|
|
nickname?: string | null,
|
|
): Promise<{ ok: true; id: string; queued: boolean }> {
|
|
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
|
|
requestId,
|
|
text,
|
|
nickname: nickname ?? undefined,
|
|
});
|
|
}
|
|
|
|
export interface ClaudeAccountSummary {
|
|
index: number;
|
|
expiresAt: number | null;
|
|
scopes: string[];
|
|
subscriptionType?: string;
|
|
rateLimitTier?: string;
|
|
exists: boolean;
|
|
}
|
|
|
|
export interface CodexAccountSummary {
|
|
index: number;
|
|
accountId: string | null;
|
|
email: string | null;
|
|
planType: string | null;
|
|
subscriptionUntil: string | null;
|
|
subscriptionLastChecked: string | null;
|
|
exists: boolean;
|
|
}
|
|
|
|
export interface ModelRoleConfig {
|
|
model: string;
|
|
effort: string;
|
|
}
|
|
|
|
export interface ModelConfigSnapshot {
|
|
owner: ModelRoleConfig;
|
|
reviewer: ModelRoleConfig;
|
|
arbiter: ModelRoleConfig;
|
|
}
|
|
|
|
export interface FastModeSnapshot {
|
|
codex: boolean;
|
|
claude: boolean;
|
|
}
|
|
|
|
export async function fetchAccounts(): Promise<{
|
|
claude: ClaudeAccountSummary[];
|
|
codex: CodexAccountSummary[];
|
|
codexCurrentIndex?: number;
|
|
}> {
|
|
return fetchJson('/api/settings/accounts');
|
|
}
|
|
|
|
export async function fetchModelConfig(): Promise<ModelConfigSnapshot> {
|
|
return fetchJson('/api/settings/models');
|
|
}
|
|
|
|
export async function updateModels(
|
|
input: Partial<{
|
|
owner: Partial<ModelRoleConfig>;
|
|
reviewer: Partial<ModelRoleConfig>;
|
|
arbiter: Partial<ModelRoleConfig>;
|
|
}>,
|
|
): Promise<ModelConfigSnapshot> {
|
|
const response = await fetch('/api/settings/models', {
|
|
method: 'PUT',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!response.ok) {
|
|
let msg = `update models failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) msg = payload.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
return (await response.json()) as ModelConfigSnapshot;
|
|
}
|
|
|
|
export async function refreshCodexAccount(
|
|
index: number,
|
|
): Promise<CodexAccountSummary> {
|
|
const response = await fetch(
|
|
`/api/settings/accounts/codex/${index}/refresh`,
|
|
{ method: 'POST' },
|
|
);
|
|
if (!response.ok) {
|
|
let msg = `refresh failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) msg = payload.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
const json = (await response.json()) as { account: CodexAccountSummary };
|
|
return json.account;
|
|
}
|
|
|
|
export async function refreshAllCodexAccounts(): Promise<{
|
|
refreshed: number[];
|
|
failed: Array<{ index: number; error: string }>;
|
|
}> {
|
|
const response = await fetch('/api/settings/accounts/codex/refresh-all', {
|
|
method: 'POST',
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`refresh-all failed: ${response.status}`);
|
|
}
|
|
return (await response.json()) as {
|
|
refreshed: number[];
|
|
failed: Array<{ index: number; error: string }>;
|
|
};
|
|
}
|
|
|
|
export async function setCurrentCodexAccount(
|
|
index: number,
|
|
): Promise<{ codexCurrentIndex: number }> {
|
|
const response = await fetch('/api/settings/accounts/codex/current', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ index }),
|
|
});
|
|
if (!response.ok) {
|
|
let msg = `switch failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) msg = payload.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
return (await response.json()) as { codexCurrentIndex: number };
|
|
}
|
|
|
|
export async function fetchFastMode(): Promise<FastModeSnapshot> {
|
|
return fetchJson('/api/settings/fast-mode');
|
|
}
|
|
|
|
export async function updateFastMode(
|
|
input: Partial<FastModeSnapshot>,
|
|
): Promise<FastModeSnapshot> {
|
|
const response = await fetch('/api/settings/fast-mode', {
|
|
method: 'PUT',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!response.ok) {
|
|
let msg = `update fast mode failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) msg = payload.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
return (await response.json()) as FastModeSnapshot;
|
|
}
|
|
|
|
export async function deleteAccount(
|
|
provider: 'claude' | 'codex',
|
|
index: number,
|
|
): Promise<{ ok: true; provider: string; index: number }> {
|
|
const response = await fetch(`/api/settings/accounts/${provider}/${index}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!response.ok) {
|
|
let msg = `delete account failed: ${response.status}`;
|
|
try {
|
|
const payload = (await response.json()) as { error?: string };
|
|
if (payload.error) msg = payload.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
return (await response.json()) as {
|
|
ok: true;
|
|
provider: string;
|
|
index: number;
|
|
};
|
|
}
|
|
|
|
export async function addClaudeAccount(
|
|
token: string,
|
|
): Promise<{ ok: true; index: number; accountId: string | null }> {
|
|
return postJson('/api/settings/accounts/claude', { token });
|
|
}
|