mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 18:36:23 +00:00
feat(app): replace mock data with live Handler API
Wire the mobile app to the real Handler API instead of the transcribed prototype data: - src/api/client.ts: typed client ported from frontend/lib/api.ts (bearer auth, AuthError/ApiError, base URL param, allow401 for admin-only resume) - src/api/format.ts: relative-time + status label/tone/color helpers - src/state/ServerConfig.tsx: endpoint+token persisted to AsyncStorage - src/screens/ConnectScreen.tsx: first-open config, validated via /health then /projects - src/state/AppState.tsx: data-driven store polling projects -> agents -> checkmarks -> logs every 10s with per-item failure isolation; derives fleet counts, waiting list, recent checkmarks, merged log; answer+resume, spawn, kill mutations; 401 routes back to ConnectScreen - screens render live data; detail meta is Started/Status/Tests/Build; Pause removed (no endpoint); Kill confirms; log filters are per-project - delete src/data/mock.ts tsc clean; Hermes bundle builds (200).
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
|
||||
* pydantic schemas in src/handler/api/schemas.py). Ported from frontend/lib/api.ts; the
|
||||
* one adaptation for mobile is that the base URL is passed in (the phone talks to a
|
||||
* user-configured endpoint rather than same-origin), and `api()` takes an `allow401`
|
||||
* escape hatch so the admin-only /resume call can handle its own 401/403 without
|
||||
* tripping the global sign-out. */
|
||||
|
||||
export type CommandStatus = "queued" | "running" | "done" | "failed";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
root_dir: string;
|
||||
git_remote?: string | null;
|
||||
credential_ref?: string | null;
|
||||
created_at: string;
|
||||
/* Present on the registration response in git-server mode: the enqueued clone. */
|
||||
sync_command_id?: number | null;
|
||||
/* Present on the registration response when "Initialize mise" was ticked: the
|
||||
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
|
||||
mise_init_command_id?: number | null;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name: string;
|
||||
working_dir: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
/* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
|
||||
* agent is actually doing (and expose one wedged on an interactive prompt). */
|
||||
last_output?: string | null;
|
||||
output_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Checkmark {
|
||||
agent_id: number;
|
||||
checkpoint_at: string;
|
||||
status: string;
|
||||
where_it_stopped?: string | null;
|
||||
next_steps?: string[] | null;
|
||||
open_question?: string | null;
|
||||
log_entry_id?: number | null;
|
||||
tests_status: string;
|
||||
tested_at?: string | null;
|
||||
build_status: string;
|
||||
built_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
agent_id: number;
|
||||
created_at: string;
|
||||
session_id?: string | null;
|
||||
status: string;
|
||||
summary?: string | null;
|
||||
decisions?: string | null;
|
||||
question?: string | null;
|
||||
answer?: string | null;
|
||||
visibility: string;
|
||||
push_sha?: string | null;
|
||||
ci_status: string;
|
||||
ci_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: number;
|
||||
project_id: string;
|
||||
branch: string;
|
||||
approved_sha?: string | null;
|
||||
pr_ref?: string | null;
|
||||
status: string;
|
||||
approved_by_agent_id?: number | null;
|
||||
actor?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
hostname: string;
|
||||
forge_type: string;
|
||||
token_env_var?: string | null;
|
||||
base_url?: string | null;
|
||||
ssh_public_key?: string | null;
|
||||
has_token: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: number;
|
||||
project_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
status: CommandStatus;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
requested_by?: string | null;
|
||||
claimed_by?: string | null;
|
||||
created_at: string;
|
||||
claimed_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name_prefix: string;
|
||||
task: string;
|
||||
role?: string | null;
|
||||
worktree?: string | null;
|
||||
subdir?: string | null;
|
||||
interval_seconds: number;
|
||||
enabled: boolean;
|
||||
next_run_at: string;
|
||||
last_run_at?: string | null;
|
||||
last_command_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SharedContext {
|
||||
key: string;
|
||||
value: string;
|
||||
set_by_agent_id?: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
|
||||
* quiet while the app re-prompts for a token. */
|
||||
export class AuthError extends Error {
|
||||
constructor(message = "unauthorized") {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
/* When set, a 401 throws an ApiError(status 401) like any other error instead of firing
|
||||
* onUnauthorized — used by the admin-only /resume so a missing admin grant surfaces
|
||||
* inline rather than signing the whole session out. */
|
||||
allow401?: boolean;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
attempts?: number;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
baseUrl: string;
|
||||
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
|
||||
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
|
||||
* budget (worker down or a very slow command). */
|
||||
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
|
||||
}
|
||||
|
||||
/* Strip a trailing slash so `baseUrl + "/projects"` never double-slashes. */
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
onUnauthorized: () => void,
|
||||
): ApiClient {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
|
||||
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
const hasBody = opts?.body !== undefined && opts?.body !== null;
|
||||
const res = await fetch(base + path, {
|
||||
method: opts?.method ?? (hasBody ? "POST" : "GET"),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(opts!.body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401 && !opts?.allow401) {
|
||||
onUnauthorized();
|
||||
throw new AuthError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail: string = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && typeof j.detail !== "undefined") {
|
||||
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body; keep statusText */
|
||||
}
|
||||
const err = new Error(detail) as ApiError;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
|
||||
const attempts = opts?.attempts ?? 60;
|
||||
const intervalMs = opts?.intervalMs ?? 500;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const cmd = await api<Command>(`/commands/${id}`);
|
||||
if (cmd.status === "done" || cmd.status === "failed") return cmd;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { baseUrl: base, api, trackCommand };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Formatting + status helpers shared by the screens. Pure functions, no API access.
|
||||
* timeAgo is ported from frontend/lib/format.ts; the tone/colour mappers translate a raw
|
||||
* handler status string into the app's design-system vocabulary (BadgeTone for pills,
|
||||
* a ThemeColors key for log lines). */
|
||||
|
||||
import type { ThemeColors } from "../theme/tokens";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. Timestamps from the API are
|
||||
* ISO UTC strings; new Date() parses them. */
|
||||
export function timeAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "—";
|
||||
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo`;
|
||||
return `${Math.floor(months / 12)}y`;
|
||||
}
|
||||
|
||||
/* Local clock time (HH:MM:SS) for a log line. "—" for empty. */
|
||||
export function clockTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
paused_for_input: "Waiting",
|
||||
not_applicable: "N/A",
|
||||
};
|
||||
|
||||
/* A tidy, human-readable label for a status string. */
|
||||
export function statusLabel(status: string | null | undefined): string {
|
||||
const raw = (status ?? "").trim();
|
||||
if (!raw) return "—";
|
||||
const key = raw.toLowerCase();
|
||||
if (LABELS[key]) return LABELS[key];
|
||||
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* Map a raw handler status (agent status, checkmark status, CI status) to a badge tone in
|
||||
* the app's four-tone vocabulary. */
|
||||
export function statusTone(status: string | null | undefined): BadgeTone {
|
||||
switch ((status ?? "").toLowerCase()) {
|
||||
case "pass":
|
||||
case "done":
|
||||
case "completed":
|
||||
case "approved":
|
||||
case "success":
|
||||
return "positive";
|
||||
case "fail":
|
||||
case "failed":
|
||||
case "blocked":
|
||||
case "rejected":
|
||||
case "error":
|
||||
return "danger";
|
||||
case "pending":
|
||||
case "queued":
|
||||
case "running":
|
||||
case "working":
|
||||
case "paused_for_input":
|
||||
return "warning";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/* Pick a ThemeColors key for a log line, given its status. */
|
||||
export function statusColor(status: string | null | undefined): keyof ThemeColors {
|
||||
switch (statusTone(status)) {
|
||||
case "positive":
|
||||
return "positive";
|
||||
case "danger":
|
||||
return "danger";
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "textBody";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user