245 lines
7.6 KiB
TypeScript
245 lines
7.6 KiB
TypeScript
import { httpBase, type ServerConfig } from "./config";
|
|
import type { AttentionLevel } from "./theme";
|
|
|
|
// ---- Types (subset of AO's DashboardSession we use on the phone) ------------
|
|
|
|
export type DashboardPR = {
|
|
number: number;
|
|
url: string;
|
|
title?: string;
|
|
owner?: string;
|
|
repo?: string;
|
|
branch?: string;
|
|
baseBranch?: string;
|
|
isDraft?: boolean;
|
|
state?: "open" | "merged" | "closed";
|
|
additions?: number;
|
|
deletions?: number;
|
|
changedFiles?: number;
|
|
ciStatus?: "pending" | "passing" | "failing" | "none";
|
|
reviewDecision?: "approved" | "changes_requested" | "pending" | "none";
|
|
mergeability?: {
|
|
mergeable?: boolean;
|
|
ciPassing?: boolean;
|
|
approved?: boolean;
|
|
noConflicts?: boolean;
|
|
blockers?: string[];
|
|
};
|
|
unresolvedThreads?: number;
|
|
};
|
|
|
|
export type DashboardSession = {
|
|
id: string;
|
|
projectId: string;
|
|
status: string | null;
|
|
attentionLevel?: AttentionLevel | string | null;
|
|
activity?: string | null;
|
|
branch: string | null;
|
|
issueId: string | null;
|
|
issueUrl?: string | null;
|
|
issueLabel?: string | null;
|
|
issueTitle: string | null;
|
|
userPrompt: string | null;
|
|
displayName: string | null;
|
|
summary: string | null;
|
|
createdAt: string;
|
|
lastActivityAt: string;
|
|
pr?: DashboardPR | null;
|
|
prs?: DashboardPR[];
|
|
metadata?: Record<string, string>;
|
|
};
|
|
|
|
export type OrchestratorLink = {
|
|
id: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
status?: string | null;
|
|
activity?: string | null;
|
|
runtimeState?: string | null;
|
|
hasRuntime?: boolean;
|
|
isTerminal?: boolean;
|
|
isRestorable?: boolean;
|
|
};
|
|
|
|
export type ProjectInfo = {
|
|
id: string;
|
|
name: string;
|
|
sessionPrefix?: string;
|
|
};
|
|
|
|
export type DashboardStats = {
|
|
totalSessions?: number;
|
|
workingSessions?: number;
|
|
openPRs?: number;
|
|
needsReview?: number;
|
|
};
|
|
|
|
export type SessionsResponse = {
|
|
sessions: DashboardSession[];
|
|
orchestrators: OrchestratorLink[];
|
|
orchestratorId: string | null;
|
|
stats: DashboardStats;
|
|
};
|
|
|
|
// ---- Low-level fetch with friendly errors ----------------------------------
|
|
|
|
const REQUEST_TIMEOUT_MS = 12000;
|
|
|
|
async function req(cfg: ServerConfig, path: string, init?: RequestInit): Promise<Response> {
|
|
const url = `${httpBase(cfg)}${path}`;
|
|
// Without a timeout a sleeping/unreachable host (common over Tailscale) hangs
|
|
// the call for the OS TCP timeout (~75-120s), freezing Kill/send and the poll.
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
...init,
|
|
signal: controller.signal,
|
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
});
|
|
} catch (e) {
|
|
if ((e as { name?: string })?.name === "AbortError") {
|
|
throw new Error("Request timed out — is the server reachable?", { cause: e });
|
|
}
|
|
throw e;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
if (!res.ok) {
|
|
let detail = "";
|
|
try {
|
|
detail = (await res.json())?.error ?? "";
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
// ---- Reads ------------------------------------------------------------------
|
|
|
|
export async function getProjects(cfg: ServerConfig): Promise<ProjectInfo[]> {
|
|
const res = await req(cfg, "/api/projects");
|
|
const data = await res.json();
|
|
return Array.isArray(data?.projects) ? data.projects : [];
|
|
}
|
|
|
|
export async function getSessions(cfg: ServerConfig, projectId?: string): Promise<SessionsResponse> {
|
|
const q = projectId && projectId !== "all" ? `?project=${encodeURIComponent(projectId)}` : "?project=all";
|
|
const res = await req(cfg, `/api/sessions${q}`);
|
|
const data = await res.json();
|
|
return {
|
|
sessions: Array.isArray(data?.sessions) ? data.sessions : [],
|
|
orchestrators: Array.isArray(data?.orchestrators) ? data.orchestrators : [],
|
|
orchestratorId: data?.orchestratorId ?? null,
|
|
stats: data?.stats ?? {},
|
|
};
|
|
}
|
|
|
|
// ---- Writes / actions -------------------------------------------------------
|
|
|
|
export async function killSession(cfg: ServerConfig, id: string): Promise<void> {
|
|
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
|
|
}
|
|
|
|
export async function restoreSession(cfg: ServerConfig, id: string): Promise<void> {
|
|
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
|
|
}
|
|
|
|
export async function sendMessage(cfg: ServerConfig, id: string, message: string): Promise<void> {
|
|
await req(cfg, `/api/sessions/${encodeURIComponent(id)}/send`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
}
|
|
|
|
export async function spawnSession(
|
|
cfg: ServerConfig,
|
|
opts: { projectId: string; prompt?: string; issueId?: string },
|
|
): Promise<DashboardSession> {
|
|
const res = await req(cfg, "/api/spawn", {
|
|
method: "POST",
|
|
body: JSON.stringify(opts),
|
|
});
|
|
const data = await res.json();
|
|
return data?.session;
|
|
}
|
|
|
|
export async function launchOrchestrator(
|
|
cfg: ServerConfig,
|
|
projectId: string,
|
|
clean = false,
|
|
): Promise<OrchestratorLink> {
|
|
const res = await req(cfg, "/api/orchestrators", {
|
|
method: "POST",
|
|
body: JSON.stringify({ projectId, clean }),
|
|
});
|
|
const data = await res.json();
|
|
return data?.orchestrator;
|
|
}
|
|
|
|
export async function mergePR(cfg: ServerConfig, pr: DashboardPR): Promise<void> {
|
|
const params: string[] = [];
|
|
if (pr.owner) params.push(`owner=${encodeURIComponent(pr.owner)}`);
|
|
if (pr.repo) params.push(`repo=${encodeURIComponent(pr.repo)}`);
|
|
const q = params.length ? `?${params.join("&")}` : "";
|
|
await req(cfg, `/api/prs/${pr.number}/merge${q}`, { method: "POST" });
|
|
}
|
|
|
|
// Quick reachability probe for the Settings "Test connection" button.
|
|
export async function pingServer(cfg: ServerConfig): Promise<number> {
|
|
const res = await req(cfg, "/api/sessions?project=all");
|
|
const data = await res.json();
|
|
return Array.isArray(data?.sessions) ? data.sessions.length : 0;
|
|
}
|
|
|
|
// ---- Derived helpers --------------------------------------------------------
|
|
|
|
const TERMINAL_STATUSES = new Set(["killed", "terminated", "done", "cleanup", "errored", "merged"]);
|
|
|
|
export function isTerminalStatus(status?: string | null): boolean {
|
|
return !!status && TERMINAL_STATUSES.has(status);
|
|
}
|
|
|
|
// Fallback attention bucket when the server didn't compute attentionLevel.
|
|
export function attentionOf(s: DashboardSession): AttentionLevel {
|
|
if (s.attentionLevel) return s.attentionLevel as AttentionLevel;
|
|
const pr = s.pr ?? s.prs?.[0];
|
|
if (s.status === "merged" || s.status === "done" || isTerminalStatus(s.status)) return "done";
|
|
if (pr?.mergeability?.mergeable || s.status === "mergeable" || s.status === "approved") return "merge";
|
|
if (s.status === "needs_input" || s.status === "stuck" || s.status === "errored") return "respond";
|
|
if (
|
|
pr?.ciStatus === "failing" ||
|
|
pr?.reviewDecision === "changes_requested" ||
|
|
s.status === "ci_failed" ||
|
|
s.status === "changes_requested"
|
|
)
|
|
return "review";
|
|
if (s.status === "pr_open" || s.status === "review_pending") return "pending";
|
|
return "working";
|
|
}
|
|
|
|
export function sessionTitle(s: DashboardSession): string {
|
|
return s.displayName || s.issueTitle || s.userPrompt || s.summary || s.id;
|
|
}
|
|
|
|
// All PRs across sessions, de-duplicated by number+repo.
|
|
export function collectPRs(sessions: DashboardSession[]): { pr: DashboardPR; session: DashboardSession }[] {
|
|
const seen = new Set<string>();
|
|
const out: { pr: DashboardPR; session: DashboardSession }[] = [];
|
|
for (const s of sessions) {
|
|
const list = s.prs && s.prs.length ? s.prs : s.pr ? [s.pr] : [];
|
|
for (const pr of list) {
|
|
// Real GitHub/GitLab PR numbers are >= 1; 0/missing signals a placeholder.
|
|
if (!pr || !pr.number || pr.number <= 0) continue;
|
|
const key = `${pr.owner ?? ""}/${pr.repo ?? ""}#${pr.number}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push({ pr, session: s });
|
|
}
|
|
}
|
|
return out;
|
|
}
|