409 lines
13 KiB
TypeScript
409 lines
13 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>;
|
|
// Browser-preview target the daemon detected/served for this session (e.g. a
|
|
// dist/index.html entrypoint). Consumed by the in-app browser.
|
|
previewUrl?: string | null;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
// ---- Wire types (this repo's Go daemon, /api/v1/*) --------------------------
|
|
//
|
|
// The app UI speaks AO's OG "DashboardSession" shape; this daemon speaks a
|
|
// leaner read model. The maps below translate the daemon's SessionView/PR facts
|
|
// into the shapes the screens expect, so the rest of the app is unchanged.
|
|
|
|
const API = "/api/v1";
|
|
|
|
type WirePR = {
|
|
url: string;
|
|
number: number;
|
|
state?: string; // draft | open | merged | closed
|
|
ci?: string; // unknown | pending | passing | failing
|
|
review?: string; // none | approved | changes_requested | review_required
|
|
mergeability?: string; // unknown | mergeable | conflicting | blocked | unstable
|
|
reviewComments?: boolean;
|
|
};
|
|
|
|
type WireSession = {
|
|
id: string;
|
|
projectId: string;
|
|
issueId?: string;
|
|
kind?: string; // worker | orchestrator
|
|
harness?: string;
|
|
displayName?: string;
|
|
activity?: unknown;
|
|
isTerminated?: boolean;
|
|
status?: string | null;
|
|
branch?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
previewUrl?: string;
|
|
prs?: WirePR[];
|
|
};
|
|
|
|
function mapPR(pr: WirePR): DashboardPR {
|
|
const ci = pr.ci === "passing" || pr.ci === "failing" || pr.ci === "pending" ? pr.ci : "none";
|
|
const review =
|
|
pr.review === "approved"
|
|
? "approved"
|
|
: pr.review === "changes_requested"
|
|
? "changes_requested"
|
|
: pr.review === "review_required"
|
|
? "pending"
|
|
: "none";
|
|
const state = pr.state === "merged" ? "merged" : pr.state === "closed" ? "closed" : "open";
|
|
return {
|
|
number: pr.number,
|
|
url: pr.url,
|
|
state,
|
|
isDraft: pr.state === "draft",
|
|
ciStatus: ci,
|
|
reviewDecision: review,
|
|
mergeability: { mergeable: pr.mergeability === "mergeable" },
|
|
unresolvedThreads: pr.reviewComments ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
function activityString(a: unknown): string | null {
|
|
if (typeof a === "string") return a || null;
|
|
if (a && typeof a === "object" && "state" in a && typeof (a as { state: unknown }).state === "string") {
|
|
return (a as { state: string }).state || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mapSession(s: WireSession): DashboardSession {
|
|
const prs = (s.prs ?? []).map(mapPR);
|
|
return {
|
|
id: s.id,
|
|
projectId: s.projectId,
|
|
status: s.status ?? null,
|
|
activity: activityString(s.activity),
|
|
branch: s.branch ?? null,
|
|
issueId: s.issueId ?? null,
|
|
issueTitle: null,
|
|
userPrompt: null,
|
|
displayName: s.displayName ?? null,
|
|
summary: null,
|
|
createdAt: s.createdAt ?? "",
|
|
lastActivityAt: s.updatedAt ?? s.createdAt ?? "",
|
|
pr: prs[0] ?? null,
|
|
prs,
|
|
previewUrl: s.previewUrl ?? null,
|
|
};
|
|
}
|
|
|
|
function mapOrchestrator(s: WireSession, projectName: string): OrchestratorLink {
|
|
return {
|
|
id: s.id,
|
|
projectId: s.projectId,
|
|
projectName,
|
|
status: s.status ?? null,
|
|
activity: activityString(s.activity),
|
|
hasRuntime: !s.isTerminated,
|
|
isTerminal: !!s.isTerminated,
|
|
isRestorable: !!s.isTerminated,
|
|
};
|
|
}
|
|
|
|
// ---- 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) {
|
|
// The daemon returns a locked JSON envelope: { error, code, message, requestId }.
|
|
let detail = "";
|
|
try {
|
|
const body = await res.json();
|
|
detail = body?.message ?? body?.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();
|
|
const projects = Array.isArray(data?.projects) ? data.projects : [];
|
|
return projects.map((p: { id: string; name: string; sessionPrefix?: string }) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
sessionPrefix: p.sessionPrefix,
|
|
}));
|
|
}
|
|
|
|
export async function getSessions(cfg: ServerConfig, _projectId?: string): Promise<SessionsResponse> {
|
|
// The daemon exposes sessions and orchestrators as two lists. Fetch both,
|
|
// keep worker sessions for the board, and map orchestrators for their screen.
|
|
const [sessRes, orchRes, projects] = await Promise.all([
|
|
req(cfg, `${API}/sessions`),
|
|
req(cfg, `${API}/orchestrators`),
|
|
getProjects(cfg).catch(() => [] as ProjectInfo[]),
|
|
]);
|
|
const sessData = await sessRes.json();
|
|
const orchData = await orchRes.json();
|
|
const nameOf = new Map(projects.map((p) => [p.id, p.name]));
|
|
|
|
const rawSessions: WireSession[] = Array.isArray(sessData?.sessions) ? sessData.sessions : [];
|
|
const rawOrchestrators: WireSession[] = Array.isArray(orchData?.sessions) ? orchData.sessions : [];
|
|
|
|
const sessions = rawSessions.filter((s) => s.kind !== "orchestrator").map(mapSession);
|
|
|
|
// The daemon returns EVERY orchestrator session per project (one per past
|
|
// kill/respawn), so pick a single one per project - preferring the live
|
|
// (non-terminated) one, else the most recent. Otherwise the screen would
|
|
// grab a stale terminated orchestrator and show "Restart" while a live one
|
|
// is actually running.
|
|
const bestByProject = new Map<string, WireSession>();
|
|
for (const s of rawOrchestrators) {
|
|
const cur = bestByProject.get(s.projectId);
|
|
// Keep a live orchestrator once found; otherwise take the later entry
|
|
// (the daemon lists them oldest to newest).
|
|
if (!cur || cur.isTerminated) bestByProject.set(s.projectId, s);
|
|
}
|
|
const orchestrators = [...bestByProject.values()].map((s) =>
|
|
mapOrchestrator(s, nameOf.get(s.projectId) ?? s.projectId),
|
|
);
|
|
|
|
return { sessions, orchestrators, orchestratorId: null, stats: {} };
|
|
}
|
|
|
|
// ---- Preview (in-app browser) ----------------------------------------------
|
|
|
|
// Ask the daemon whether this session has a detectable static preview entry
|
|
// (index.html, or dist/build/public/index.html). Returns a URL the in-app
|
|
// WebView can load, or null when no entry exists yet - the button then reports
|
|
// "no preview". We build the URL from our own base (httpBase honors the TLS
|
|
// toggle) rather than the daemon's `previewUrl`, which hardcodes http:// + its
|
|
// request host and would break over a TLS tunnel (e.g. tailscale serve).
|
|
export async function getPreview(cfg: ServerConfig, id: string): Promise<{ entry: string; url: string } | null> {
|
|
const res = await req(cfg, `${API}/sessions/${encodeURIComponent(id)}/preview`);
|
|
const data = await res.json();
|
|
const entry = typeof data?.entry === "string" ? data.entry.trim() : "";
|
|
if (!entry) return null;
|
|
// Mirror the daemon's files route: /preview/files/<entry>, each segment escaped.
|
|
const escaped = entry.split("/").map(encodeURIComponent).join("/");
|
|
const url = `${httpBase(cfg)}${API}/sessions/${encodeURIComponent(id)}/preview/files/${escaped}`;
|
|
return { entry, url };
|
|
}
|
|
|
|
// ---- 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; harness?: string },
|
|
): Promise<DashboardSession> {
|
|
const res = await req(cfg, `${API}/sessions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
projectId: opts.projectId,
|
|
prompt: opts.prompt,
|
|
issueId: opts.issueId,
|
|
// The daemon needs an agent harness unless the project configures a
|
|
// default worker.agent; the spawn screen lets the user pick one.
|
|
harness: opts.harness || undefined,
|
|
kind: "worker",
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
return mapSession(data?.session ?? data);
|
|
}
|
|
|
|
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();
|
|
const o = data?.orchestrator ?? {};
|
|
return {
|
|
id: o.id,
|
|
projectId: o.projectId ?? projectId,
|
|
projectName: o.projectName ?? projectId,
|
|
hasRuntime: true,
|
|
isTerminal: false,
|
|
};
|
|
}
|
|
|
|
export async function mergePR(cfg: ServerConfig, pr: DashboardPR): Promise<void> {
|
|
await req(cfg, `${API}/prs/${pr.number}/merge`, { 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`);
|
|
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;
|
|
}
|