Files
amcs/ui/src/api.ts
Hein 927a118338
Some checks failed
CI / build-and-test (push) Failing after -31m53s
feat(ui): add maintenance page for task management
* Implement maintenance page with task and log display
* Add backfill and metadata retry functionality
* Integrate grid component for project display in thoughts page
* Update types for maintenance tasks and logs
* Enhance sidebar and shell for new maintenance navigation
2026-04-26 23:13:41 +02:00

367 lines
13 KiB
TypeScript

import { GlobalStateStore } from './shellState';
function authHeaders(): HeadersInit {
const token = GlobalStateStore.getState().session.authToken;
return token ? { Authorization: `Bearer ${token}` } : {};
}
type ResolveSpecResponse<T> = {
success: boolean;
data: T;
metadata?: unknown;
error?: { code: string; message: string; detail?: string };
};
type ResolveSpecFilter = {
column: string;
operator: string;
value?: unknown;
};
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((tag) => String(tag).trim()).filter(Boolean);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
if (!trimmed) return [];
// Handle Postgres text[] wire shape: "{tag1,tag2}".
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
return trimmed
.slice(1, -1)
.split(',')
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
.filter(Boolean);
}
if (trimmed.includes(',')) {
return trimmed
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
return [trimmed];
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: authHeaders() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
async function del(path: string): Promise<void> {
const res = await fetch(path, { method: 'DELETE', headers: authHeaders() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
async function rsCall<T>(
path: string,
operation: 'read' | 'create' | 'update' | 'delete',
payload?: { data?: unknown; options?: unknown }
): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
operation,
...(payload?.data !== undefined ? { data: payload.data } : {}),
...(payload?.options !== undefined ? { options: payload.options } : {})
})
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const body = (await res.json()) as ResolveSpecResponse<T>;
if (!body.success) {
throw new Error(body.error?.message ?? 'ResolveSpec request failed');
}
return body.data;
}
function rsReadMany<T>(
entity: string,
options?: { filters?: ResolveSpecFilter[]; limit?: number; offset?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
): Promise<T[]> {
return rsCall<T[] | null>(`/api/rs/public/${entity}`, 'read', {
options: {
...(options?.filters?.length ? { filters: options.filters } : {}),
...(options?.sort?.length ? { sort: options.sort } : {}),
...(options?.limit ? { limit: options.limit } : {}),
...(options?.offset ? { offset: options.offset } : {})
}
}).then((rows) => (Array.isArray(rows) ? rows : []));
}
export const api = {
projects: {
list: async (params?: { limit?: number; offset?: number; q?: string }) => {
type ProjectRow = {
guid: string;
name: string;
description: string | null;
created_at: string;
last_active_at: string;
thought_count?: number;
};
const filters: ResolveSpecFilter[] = [];
if (params?.q) {
filters.push({ column: 'name', operator: 'ilike', value: `%${params.q}%` });
}
const projectRows = await rsCall<ProjectRow[] | null>('/api/rs/public/projects', 'read', {
options: {
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
computedColumns: [
{
name: 'thought_count',
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
}
],
...(filters.length ? { filters } : {}),
sort: [{ column: 'created_at', direction: 'desc' }],
limit: params?.limit ?? 100,
offset: params?.offset ?? 0
}
});
const projects = Array.isArray(projectRows) ? projectRows : [];
return projects.map((project) => ({
id: project.guid,
name: project.name,
description: project.description ?? '',
created_at: project.created_at,
last_active_at: project.last_active_at,
thought_count: project.thought_count ?? 0
}));
},
create: (name: string, description: string) =>
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
data: { name, description }
})
},
thoughts: {
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
const filters: ResolveSpecFilter[] = [];
if (params.q) {
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
}
if (params.project_id) {
filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
}
if (!params.include_archived) {
filters.push({ column: 'archived_at', operator: 'empty' });
}
return rsReadMany<import('./types').Thought>('thoughts', {
filters,
limit: params.limit ?? 100,
...(params.offset !== undefined ? { offset: params.offset } : {}),
sort: [{ column: 'created_at', direction: 'desc' }]
}).then((rows) =>
rows.map((row) => ({
...row,
content: typeof row.content === 'string' ? row.content : '',
metadata: {
...(row.metadata ?? {}),
people: row.metadata?.people ?? [],
action_items: row.metadata?.action_items ?? [],
dates_mentioned: row.metadata?.dates_mentioned ?? [],
topics: row.metadata?.topics ?? [],
type: row.metadata?.type ?? '',
source: row.metadata?.source ?? '',
metadata_status: row.metadata?.metadata_status ?? ''
}
}))
);
},
links: async (thoughtID: string) => {
const numericID = Number.parseInt(thoughtID, 10);
if (Number.isNaN(numericID)) return [];
const [outbound, inbound] = await Promise.all([
rsReadMany<import('./types').ThoughtLink>('thought_links', {
filters: [{ column: 'from_id', operator: 'eq', value: numericID }],
limit: 200,
sort: [{ column: 'created_at', direction: 'desc' }]
}),
rsReadMany<import('./types').ThoughtLink>('thought_links', {
filters: [{ column: 'to_id', operator: 'eq', value: numericID }],
limit: 200,
sort: [{ column: 'created_at', direction: 'desc' }]
})
]);
const byID = new Map<number, import('./types').ThoughtLink>();
for (const link of outbound) byID.set(link.id, link);
for (const link of inbound) byID.set(link.id, link);
return Array.from(byID.values()).sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
},
get: (id: string) =>
rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read').then((row) => ({
...row,
content: typeof row.content === 'string' ? row.content : '',
metadata: {
...(row.metadata ?? {}),
people: row.metadata?.people ?? [],
action_items: row.metadata?.action_items ?? [],
dates_mentioned: row.metadata?.dates_mentioned ?? [],
topics: row.metadata?.topics ?? [],
type: row.metadata?.type ?? '',
source: row.metadata?.source ?? '',
metadata_status: row.metadata?.metadata_status ?? ''
}
})),
delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
archive: (id: string) =>
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
data: { archived_at: new Date().toISOString() }
})
},
skills: {
list: async (tag?: string) => {
const rows = await rsReadMany<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>('agent_skills', {
filters: tag ? [{ column: 'tags', operator: 'contains', value: tag }] : undefined,
limit: 500,
sort: [{ column: 'created_at', direction: 'desc' }]
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
},
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete')
},
guardrails: {
list: async (params?: { tag?: string; severity?: string }) => {
const filters: ResolveSpecFilter[] = [];
if (params?.tag) filters.push({ column: 'tags', operator: 'contains', value: params.tag });
if (params?.severity) filters.push({ column: 'severity', operator: 'eq', value: params.severity });
const rows = await rsReadMany<Omit<import('./types').AgentGuardrail, 'tags'> & { tags?: unknown }>('agent_guardrails', {
filters,
limit: 500,
sort: [{ column: 'created_at', direction: 'desc' }]
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
},
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete')
},
files: {
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
const filters: ResolveSpecFilter[] = [];
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
if (params?.thought_id) filters.push({ column: 'thought_id', operator: 'eq', value: params.thought_id });
if (params?.kind) filters.push({ column: 'kind', operator: 'eq', value: params.kind });
return rsReadMany<import('./types').StoredFile>('stored_files', {
filters,
limit: 500,
sort: [{ column: 'created_at', direction: 'desc' }]
});
}
},
maintenance: {
tasks: () =>
rsReadMany<import('./types').MaintenanceTask>('maintenance_tasks', {
limit: 200,
sort: [{ column: 'next_due', direction: 'asc' }]
}),
logs: () =>
rsReadMany<import('./types').MaintenanceLog>('maintenance_logs', {
limit: 200,
sort: [{ column: 'completed_at', direction: 'desc' }]
}),
runBackfill: (input?: {
project?: string;
limit?: number;
include_archived?: boolean;
older_than_days?: number;
dry_run?: boolean;
}) =>
post<import('./types').BackfillResult>('/api/admin/actions/backfill', {
limit: input?.limit ?? 100,
project: input?.project,
include_archived: input?.include_archived ?? false,
older_than_days: input?.older_than_days ?? 0,
dry_run: input?.dry_run ?? false
}),
runRetryMetadata: (input?: {
project?: string;
limit?: number;
include_archived?: boolean;
older_than_days?: number;
dry_run?: boolean;
}) =>
post<import('./types').MetadataRetryResult>('/api/admin/actions/retry-metadata', {
limit: input?.limit ?? 100,
project: input?.project,
include_archived: input?.include_archived ?? false,
older_than_days: input?.older_than_days ?? 1,
dry_run: input?.dry_run ?? false
})
},
stats: async () => {
type StatsThoughtRow = {
metadata?: {
type?: string;
topics?: string[];
people?: string[];
};
};
const thoughts = await rsReadMany<StatsThoughtRow>('thoughts', {
limit: 5000,
sort: [{ column: 'created_at', direction: 'desc' }]
});
const typeCounts: Record<string, number> = {};
const topicCounts: Record<string, number> = {};
const peopleCounts: Record<string, number> = {};
for (const thought of thoughts) {
const type = thought.metadata?.type?.trim() || 'unknown';
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
for (const topic of thought.metadata?.topics ?? []) {
const key = topic.trim();
if (!key) continue;
topicCounts[key] = (topicCounts[key] ?? 0) + 1;
}
for (const person of thought.metadata?.people ?? []) {
const key = person.trim();
if (!key) continue;
peopleCounts[key] = (peopleCounts[key] ?? 0) + 1;
}
}
const topEntries = (map: Record<string, number>) =>
Object.entries(map)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([key, count]) => ({ key, count }));
const stats: import('./types').ThoughtStats = {
total_count: thoughts.length,
type_counts: typeCounts,
top_topics: topEntries(topicCounts),
top_people: topEntries(peopleCounts)
};
return stats;
}
};