mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
## Summary
Stage detail now loads activity from a canonical stage-scoped events
endpoint instead of falling back to the first 1000 run-wide events. This
fixes empty panes for late stages in long runs and removes the
presentation-shaped `StageTurn` API from the wire.
### Plan Summary
- Add `GET /runs/{id}/stages/{stageId}/events` with cursor pagination
and server-side `node_id` filtering.
- Replace frontend stage-turn/fallback loading with paginated
stage-events loading and local event-to-activity projection.
- Broaden SSE/SWR invalidation so every activity event consumed by the
reducer refreshes the per-stage cache.
- Remove `StageTurn` schemas/client models and update demo fixtures plus
pagination/handler/reducer tests.
## What changed and why
The store now scans the run event prefix and filters by `node_id` before
applying the `limit + 1` cutoff. That preserves sparse late-stage
matches that would otherwise be dropped if we reused the run-wide
limited scan and filtered afterward. The real-mode handler returns an
empty page for an unknown stage id in an existing run, while preserving
404 for missing runs.
On the frontend, `run-stages` fetches all pages for the selected stage
and feeds them through `eventsToActivity`, keeping `TurnType` as a local
presentation model. Invalidation now targets `runs.stageEvents(runId,
stageId)` for lifecycle and reducer-consumed activity events
(`stage.prompt`, agent messages/tools, and command events), so active
panes refresh from the existing run event subscription.
The OpenAPI document and generated TS client now expose
`listStageEvents` and drop stale `StageTurn` models. Demo mode serves a
`detect-drift` stage-events fixture using the same cursor semantics as
the real endpoint.
## API notes
`/runs/{id}/stages/{stageId}/turns` is removed; clients should use
`/runs/{id}/stages/{stageId}/events?since_seq=&limit=` and project
events locally. The `stageId` path segment for this endpoint is the
workflow node id, not the visit-qualified `node_id@visit` form used by
command logs/artifacts.
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
299 lines
No EOL
8.7 KiB
TypeScript
299 lines
No EOL
8.7 KiB
TypeScript
export interface ApiOptions {
|
|
init?: RequestInit;
|
|
request?: Request;
|
|
}
|
|
|
|
export interface PaginatedEnvelope<T> {
|
|
data: T[];
|
|
meta: { has_more: boolean };
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly requestId: string | null;
|
|
readonly body: unknown;
|
|
|
|
constructor({
|
|
status,
|
|
message,
|
|
requestId,
|
|
body,
|
|
}: {
|
|
status: number;
|
|
message: string;
|
|
requestId: string | null;
|
|
body: unknown;
|
|
}) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.requestId = requestId;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
const API_PREFIX = "/api/v1";
|
|
const PAGINATED_API_MAX_PAGES = 50;
|
|
const PAGINATED_API_MAX_ITEMS = 5000;
|
|
|
|
export function apiPath(path: string): string {
|
|
return path.startsWith(API_PREFIX) ? path : `${API_PREFIX}${path}`;
|
|
}
|
|
|
|
export function isNotAvailable(status: number): boolean {
|
|
return status === 404 || status === 501;
|
|
}
|
|
|
|
export function extractRequestId(body: unknown): string | null {
|
|
if (!body || typeof body !== "object") return null;
|
|
const record = body as Record<string, unknown>;
|
|
if (typeof record.request_id === "string") return record.request_id;
|
|
if (typeof record.requestId === "string") return record.requestId;
|
|
|
|
const errors = record.errors;
|
|
if (!Array.isArray(errors) || errors.length === 0) return null;
|
|
|
|
const first = errors[0];
|
|
if (!first || typeof first !== "object") return null;
|
|
const error = first as Record<string, unknown>;
|
|
if (typeof error.request_id === "string") return error.request_id;
|
|
if (typeof error.requestId === "string") return error.requestId;
|
|
if (typeof error.detail === "string") {
|
|
const match = error.detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
|
|
if (match) return match[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function requestIdFromHeaders(headers: Headers): string | null {
|
|
return (
|
|
headers.get("x-request-id") ??
|
|
headers.get("x-fabro-request-id") ??
|
|
headers.get("request-id")
|
|
);
|
|
}
|
|
|
|
async function parseResponseBody(response: Response): Promise<unknown> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
const text = await response.text().catch(() => "");
|
|
if (!text) return null;
|
|
if (contentType.includes("json")) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
async function apiErrorFromResponse(response: Response): Promise<ApiError> {
|
|
const body = await parseResponseBody(response);
|
|
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body);
|
|
return new ApiError({
|
|
status: response.status,
|
|
message: response.statusText || `HTTP ${response.status}`,
|
|
requestId,
|
|
body,
|
|
});
|
|
}
|
|
|
|
export async function apiRequest(path: string, options?: ApiOptions): Promise<Response> {
|
|
const { init, request } = options ?? {};
|
|
const response = await fetch(apiPath(path), {
|
|
...init,
|
|
credentials: "include",
|
|
headers: init?.headers,
|
|
...(request?.signal ? { signal: request.signal } : {}),
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
if (typeof window !== "undefined") {
|
|
window.location.href = "/login";
|
|
}
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export async function apiFetcher<T>(key: string): Promise<T> {
|
|
const response = await apiRequest(key);
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
if (response.status === 204) return undefined as T;
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export async function apiTextFetcher(key: string): Promise<string> {
|
|
const response = await apiRequest(key);
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
export async function apiNullableFetcher<T>(key: string): Promise<T | null> {
|
|
const response = await apiRequest(key);
|
|
if (isNotAvailable(response.status)) return null;
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export async function apiNullableTextFetcher(key: string): Promise<string | null> {
|
|
const response = await apiRequest(key);
|
|
if (isNotAvailable(response.status)) return null;
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
function paginatedApiPath(key: string, limit: number, offset: number): string {
|
|
const url = new URL(apiPath(key), "http://fabro.local");
|
|
url.searchParams.set("page[limit]", String(limit));
|
|
url.searchParams.set("page[offset]", String(offset));
|
|
return `${url.pathname}${url.search}`;
|
|
}
|
|
|
|
export async function apiPaginatedFetcher<TItem, TExtra extends object = {}>(
|
|
key: string,
|
|
): Promise<PaginatedEnvelope<TItem> & TExtra> {
|
|
const limit = 100;
|
|
let offset = 0;
|
|
const data: TItem[] = [];
|
|
let extras: TExtra | null = null;
|
|
let pagesLoaded = 0;
|
|
|
|
while (true) {
|
|
const response = await apiRequest(paginatedApiPath(key, limit, offset));
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
|
|
const page = (await response.json()) as PaginatedEnvelope<TItem> & TExtra;
|
|
if (extras == null) {
|
|
const { data: _data, meta: _meta, ...rest } = page as PaginatedEnvelope<TItem> &
|
|
Record<string, unknown>;
|
|
extras = rest as TExtra;
|
|
}
|
|
|
|
pagesLoaded += 1;
|
|
const remainingItemBudget = PAGINATED_API_MAX_ITEMS - data.length;
|
|
const pageItems = remainingItemBudget > 0 ? page.data.slice(0, remainingItemBudget) : [];
|
|
data.push(...pageItems);
|
|
|
|
if (!page.meta.has_more || page.data.length === 0) {
|
|
return {
|
|
...(extras ?? ({} as TExtra)),
|
|
data,
|
|
meta: { has_more: false },
|
|
};
|
|
}
|
|
|
|
if (
|
|
pagesLoaded >= PAGINATED_API_MAX_PAGES ||
|
|
pageItems.length < page.data.length ||
|
|
data.length >= PAGINATED_API_MAX_ITEMS
|
|
) {
|
|
console.warn(
|
|
`Stopped paginated API fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
|
);
|
|
return {
|
|
...(extras ?? ({} as TExtra)),
|
|
data,
|
|
meta: { has_more: true },
|
|
};
|
|
}
|
|
|
|
offset += page.data.length;
|
|
}
|
|
}
|
|
|
|
function stageEventsPagePath(key: string, sinceSeq: number, limit: number): string {
|
|
const url = new URL(apiPath(key), "http://fabro.local");
|
|
url.searchParams.set("since_seq", String(sinceSeq));
|
|
url.searchParams.set("limit", String(limit));
|
|
return `${url.pathname}${url.search}`;
|
|
}
|
|
|
|
/**
|
|
* Cursor-paginated fetcher for `/runs/{id}/stages/{stageId}/events`.
|
|
*
|
|
* Loops from `since_seq=1` with a 1000-event page size, advancing the cursor
|
|
* to `highestSeq + 1` until the server reports `meta.has_more === false`.
|
|
* The empty-page guard mirrors `apiPaginatedFetcher`: if the server claims
|
|
* `has_more` but returns no rows we exit and `console.warn` to surface the
|
|
* server invariant violation without spinning the UI.
|
|
*/
|
|
export async function fetchAllStageEvents<TItem extends { seq: number }>(
|
|
key: string,
|
|
): Promise<TItem[]> {
|
|
const PAGE_LIMIT = 1000;
|
|
const MAX_PAGES = 50;
|
|
const data: TItem[] = [];
|
|
let sinceSeq = 1;
|
|
let pagesLoaded = 0;
|
|
|
|
while (true) {
|
|
const response = await apiRequest(stageEventsPagePath(key, sinceSeq, PAGE_LIMIT));
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
const page = (await response.json()) as PaginatedEnvelope<TItem>;
|
|
pagesLoaded += 1;
|
|
|
|
if (page.data.length === 0) {
|
|
if (page.meta.has_more) {
|
|
console.warn(
|
|
`Stage events fetch for ${key} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`,
|
|
);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
data.push(...page.data);
|
|
if (!page.meta.has_more) return data;
|
|
|
|
if (pagesLoaded >= MAX_PAGES) {
|
|
console.warn(
|
|
`Stopped stage events fetch for ${key} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1);
|
|
if (highestSeq < sinceSeq) {
|
|
console.warn(
|
|
`Stage events fetch for ${key} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`,
|
|
);
|
|
return data;
|
|
}
|
|
sinceSeq = highestSeq + 1;
|
|
}
|
|
}
|
|
|
|
export async function apiJsonMutation<TResponse, TArg = unknown>(
|
|
key: string,
|
|
{ arg }: { arg: TArg },
|
|
): Promise<TResponse> {
|
|
const response = await apiRequest(key, {
|
|
init: {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: arg === undefined ? undefined : JSON.stringify(arg),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw await apiErrorFromResponse(response);
|
|
}
|
|
if (response.status === 204) return undefined as TResponse;
|
|
return response.json() as Promise<TResponse>;
|
|
} |