From b061cc47f3c5b3cca7c9ddc2d256a8f1e85a5cd2 Mon Sep 17 00:00:00 2001 From: Saravanakumar D Date: Sat, 30 May 2026 09:47:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(tracker-web):=20fleet=20control=20plane=20?= =?UTF-8?q?UI=20=E2=80=94=20overview,=20jobs,=20budget,=20detail=20pages?= =?UTF-8?q?=20(Phase=203=20Slice=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fleet overview page with factory cards + recent jobs polling - Job table with stage filter tabs - Job detail page with events timeline, runs, artifacts, DAG subtree, SHIP action - Budget page with usage bar, pause/resume controls - API proxy route forwarding /api/fleet/* to platform-service - Typed fleet-client.ts with graceful 404 degradation - 16 unit tests for fleet-client (198 total tracker-web tests green) - Added Fleet nav item to dashboard layout - Full monorepo build + test green Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/__tests__/fleet-client.test.ts | 171 +++++++++++++ .../src/app/api/fleet/[...path]/route.ts | 58 +++++ .../src/app/dashboard/fleet/budget/page.tsx | 157 ++++++++++++ .../app/dashboard/fleet/jobs/[id]/page.tsx | 230 ++++++++++++++++++ .../src/app/dashboard/fleet/jobs/page.tsx | 122 ++++++++++ .../src/app/dashboard/fleet/page.tsx | 175 +++++++++++++ .../tracker-web/src/app/dashboard/layout.tsx | 1 + .../tracker-web/src/lib/fleet-client.ts | 193 +++++++++++++++ .../.data/platform-events.json | 67 +++++ 9 files changed, 1174 insertions(+) create mode 100644 dashboards/tracker-web/src/__tests__/fleet-client.test.ts create mode 100644 dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts create mode 100644 dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx create mode 100644 dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx create mode 100644 dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx create mode 100644 dashboards/tracker-web/src/app/dashboard/fleet/page.tsx create mode 100644 dashboards/tracker-web/src/lib/fleet-client.ts create mode 100644 services/platform-service/.data/platform-events.json diff --git a/dashboards/tracker-web/src/__tests__/fleet-client.test.ts b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts new file mode 100644 index 00000000..1ff89021 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/fleet-client.test.ts @@ -0,0 +1,171 @@ +/** + * Fleet client unit tests — verifies correct URL construction, + * method usage, and graceful degradation on errors. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() })); + +vi.mock('@bytelyst/api-client', () => ({ + createApiClient: () => ({ fetch: fetchSpy }), +})); + +import { + listJobs, + getJob, + patchJob, + getJobRuns, + getJobEvents, + getJobArtifacts, + getJobDag, + listFactories, + getBudget, + upsertBudget, + pauseBudget, + resumeBudget, +} from '@/lib/fleet-client'; + +describe('fleet-client', () => { + beforeEach(() => { + fetchSpy.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('listJobs', () => { + it('calls /jobs with query params', async () => { + fetchSpy.mockResolvedValue({ jobs: [] }); + const res = await listJobs({ stage: 'queued', limit: 10 }); + expect(res.jobs).toEqual([]); + expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining('/jobs'), expect.anything()); + }); + + it('calls /jobs without params when none provided', async () => { + fetchSpy.mockResolvedValue({ jobs: [{ id: 'j1' }] }); + const res = await listJobs(); + expect(res.jobs).toHaveLength(1); + }); + }); + + describe('getJob', () => { + it('returns job on success', async () => { + fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' }); + const job = await getJob('j1'); + expect(job?.id).toBe('j1'); + }); + + it('returns null on 404', async () => { + fetchSpy.mockRejectedValue(new Error('404 Not Found')); + const job = await getJob('missing'); + expect(job).toBeNull(); + }); + }); + + describe('patchJob', () => { + it('sends PATCH with correct body', async () => { + fetchSpy.mockResolvedValue({ id: 'j1', stage: 'shipped' }); + const res = await patchJob('j1', { leaseEpoch: 1, stage: 'shipped' }); + expect(res.stage).toBe('shipped'); + expect(fetchSpy).toHaveBeenCalledWith( + '/jobs/j1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + }); + + describe('getJobRuns', () => { + it('returns runs array', async () => { + fetchSpy.mockResolvedValue({ runs: [{ id: 'r1', attempt: 1 }] }); + const res = await getJobRuns('j1'); + expect(res.runs).toHaveLength(1); + }); + }); + + describe('getJobEvents', () => { + it('returns events array', async () => { + fetchSpy.mockResolvedValue({ events: [{ id: 'e1', type: 'submitted' }] }); + const res = await getJobEvents('j1'); + expect(res.events).toHaveLength(1); + }); + }); + + describe('getJobArtifacts', () => { + it('returns artifacts array', async () => { + fetchSpy.mockResolvedValue({ artifacts: [] }); + const res = await getJobArtifacts('j1'); + expect(res.artifacts).toHaveLength(0); + }); + }); + + describe('getJobDag', () => { + it('returns dag on success', async () => { + fetchSpy.mockResolvedValue({ dag: { id: 'j1', children: [] } }); + const res = await getJobDag('j1'); + expect(res?.dag.id).toBe('j1'); + }); + + it('returns null on 404 (leaf job with no children)', async () => { + fetchSpy.mockRejectedValue(new Error('404 Not Found')); + const res = await getJobDag('leaf'); + expect(res).toBeNull(); + }); + }); + + describe('listFactories', () => { + it('returns factories on success', async () => { + fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] }); + const res = await listFactories(); + expect(res.factories).toHaveLength(1); + }); + + it('returns empty array on error (graceful degradation)', async () => { + fetchSpy.mockRejectedValue(new Error('Network error')); + const res = await listFactories(); + expect(res.factories).toEqual([]); + }); + }); + + describe('budget operations', () => { + it('getBudget returns budget or null', async () => { + fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 }); + const b = await getBudget('lysnrai'); + expect(b?.ceilingUsd).toBe(100); + + fetchSpy.mockRejectedValue(new Error('404')); + const missing = await getBudget('unknown'); + expect(missing).toBeNull(); + }); + + it('upsertBudget sends PUT', async () => { + fetchSpy.mockResolvedValue({ id: 'p1', ceilingUsd: 50 }); + await upsertBudget('p1', 50, 'monthly'); + expect(fetchSpy).toHaveBeenCalledWith( + '/budgets/p1', + expect.objectContaining({ method: 'PUT' }) + ); + }); + + it('pauseBudget sends POST to /pause', async () => { + fetchSpy.mockResolvedValue({ status: 'paused' }); + const res = await pauseBudget('p1'); + expect(res.status).toBe('paused'); + expect(fetchSpy).toHaveBeenCalledWith( + '/budgets/p1/pause', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('resumeBudget sends POST to /resume', async () => { + fetchSpy.mockResolvedValue({ status: 'active' }); + const res = await resumeBudget('p1'); + expect(res.status).toBe('active'); + expect(fetchSpy).toHaveBeenCalledWith( + '/budgets/p1/resume', + expect.objectContaining({ method: 'POST' }) + ); + }); + }); +}); diff --git a/dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts b/dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts new file mode 100644 index 00000000..fab494a1 --- /dev/null +++ b/dashboards/tracker-web/src/app/api/fleet/[...path]/route.ts @@ -0,0 +1,58 @@ +/** + * Catch-all proxy to platform-service fleet endpoints. + * Forwards all /api/fleet/* requests to the fleet backend. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; + +async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + const targetPath = `/fleet/${path.join('/')}`; + const url = new URL(targetPath, PLATFORM_API); + + req.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + + try { + const headers: Record = { 'Content-Type': 'application/json' }; + const auth = req.headers.get('authorization'); + if (auth) headers['Authorization'] = auth; + + const tokenHeader = req.headers.get('x-tracker-token'); + if (tokenHeader && !auth) { + headers['Authorization'] = `Bearer ${tokenHeader}`; + } + + const productId = req.headers.get('x-product-id'); + if (productId) headers['x-product-id'] = productId; + + const fetchOptions: RequestInit = { + method: req.method, + headers, + }; + + if (req.method !== 'GET' && req.method !== 'HEAD') { + const body = await req.text(); + if (body) fetchOptions.body = body; + } + + const res = await fetch(url.toString(), fetchOptions); + const data = await res.text(); + + return new NextResponse(data, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch { + return NextResponse.json({ error: 'Fleet service unavailable' }, { status: 502 }); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx new file mode 100644 index 00000000..6b0bb5ef --- /dev/null +++ b/dashboards/tracker-web/src/app/dashboard/fleet/budget/page.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { PageHeader } from '@bytelyst/dashboard-components'; +import { Button } from '@/components/ui/Primitives'; +import { useAuth } from '@/lib/auth-context'; +import { getBudget, pauseBudget, resumeBudget, type FleetBudget } from '@/lib/fleet-client'; + +export default function FleetBudgetPage() { + const { token } = useAuth(); + const [budget, setBudget] = useState(undefined); + const [acting, setActing] = useState(false); + + const productId = + typeof window !== 'undefined' ? (localStorage.getItem('tracker_selected_product') ?? '') : ''; + + const refresh = useCallback(async () => { + if (!productId) { + setBudget(null); + return; + } + try { + const b = await getBudget(productId); + setBudget(b); + } catch { + setBudget(null); + } + }, [productId]); + + useEffect(() => { + if (!token) return; + refresh(); + }, [token, refresh]); + + const handlePause = async () => { + if (!productId) return; + setActing(true); + try { + const updated = await pauseBudget(productId); + setBudget(updated); + } catch { + /* degrade */ + } finally { + setActing(false); + } + }; + + const handleResume = async () => { + if (!productId) return; + setActing(true); + try { + const updated = await resumeBudget(productId); + setBudget(updated); + } catch { + /* degrade */ + } finally { + setActing(false); + } + }; + + if (budget === undefined) { + return ( +
+ +

Loading budget...

+
+ ); + } + + return ( +
+ + + {!productId && ( +

+ Select a product from the sidebar to view its budget. +

+ )} + + {productId && budget === null && ( +
+

+ No budget configured for {productId}. +

+

+ Use the API (PUT /fleet/budgets/{productId}) to set a ceiling. +

+
+ )} + + {budget && ( +
+
+

{budget.productId}

+ + {budget.status} + +
+ + {/* Spend bar */} +
+
+ Spent + + ${budget.spentUsd.toFixed(2)} / ${budget.ceilingUsd.toFixed(2)} + +
+
+
= budget.ceilingUsd ? 'bg-red-500' : 'bg-blue-500' + }`} + style={{ width: `${Math.min(100, (budget.spentUsd / budget.ceilingUsd) * 100)}%` }} + /> +
+
+ +
+

Window: {budget.window}

+

Last updated: {new Date(budget.updatedAt).toLocaleString()}

+
+ + {/* Controls */} +
+ {budget.status === 'active' ? ( + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx new file mode 100644 index 00000000..2909428f --- /dev/null +++ b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { PageHeader } from '@bytelyst/dashboard-components'; +import { Button } from '@/components/ui/Primitives'; +import { useAuth } from '@/lib/auth-context'; +import { + getJob, + getJobRuns, + getJobEvents, + getJobArtifacts, + getJobDag, + patchJob, + type FleetJob, + type FleetRun, + type FleetEvent, + type FleetArtifact, + type DagNode, +} from '@/lib/fleet-client'; + +export default function FleetJobDetailPage() { + const { token } = useAuth(); + const params = useParams(); + const jobId = params.id as string; + + const [job, setJob] = useState(null); + const [runs, setRuns] = useState([]); + const [events, setEvents] = useState([]); + const [artifacts, setArtifacts] = useState([]); + const [dag, setDag] = useState(null); + const [loading, setLoading] = useState(true); + const [shipping, setShipping] = useState(false); + + const refresh = useCallback(async () => { + try { + const [j, r, e, a, d] = await Promise.all([ + getJob(jobId), + getJobRuns(jobId), + getJobEvents(jobId), + getJobArtifacts(jobId), + getJobDag(jobId), + ]); + setJob(j); + setRuns(r.runs); + setEvents(e.events); + setArtifacts(a.artifacts); + setDag(d?.dag ?? null); + } catch { + /* degrade */ + } finally { + setLoading(false); + } + }, [jobId]); + + useEffect(() => { + if (!token || !jobId) return; + refresh(); + }, [token, jobId, refresh]); + + const handleShip = async () => { + if (!job) return; + setShipping(true); + try { + const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' }); + setJob(updated); + } catch { + /* show error in production */ + } finally { + setShipping(false); + } + }; + + if (loading) { + return ( +
+ +

Loading...

+
+ ); + } + + if (!job) { + return ( +
+ +

The requested job does not exist.

+ + ← Back to jobs + +
+ ); + } + + return ( +
+
+ + {job.stage !== 'shipped' && job.stage !== 'failed' && ( + + )} +
+ + {/* Job metadata */} +
+ + + + +
+ + {/* DAG subtree (if present) */} + {dag && dag.children.length > 0 && ( +
+

DAG Subtree

+ +
+ )} + + {/* Event timeline */} +
+

Event Timeline

+ {events.length === 0 ? ( +

No events recorded.

+ ) : ( +
    + {events.map(e => ( +
  • + + {new Date(e.at).toLocaleTimeString()} + + {e.type} + {e.actor && by {e.actor}} +
  • + ))} +
+ )} +
+ + {/* Runs */} +
+

Runs

+ {runs.length === 0 ? ( +

No runs yet.

+ ) : ( + + + + + + + + + + + + {runs.map(r => ( + + + + + + + + ))} + +
AttemptEngineFactoryResultStarted
#{r.attempt}{r.engine}{r.factoryId ?? '—'}{r.result ?? 'running'} + {new Date(r.startedAt).toLocaleString()} +
+ )} +
+ + {/* Artifacts */} +
+

Artifacts

+ {artifacts.length === 0 ? ( +

No artifacts.

+ ) : ( +
    + {artifacts.map(a => ( +
  • + {a.kind} + {a.contentType} + + ({(a.sizeBytes / 1024).toFixed(1)} KB) + +
  • + ))} +
+ )} +
+ + + ← Back to jobs + +
+ ); +} + +function MetaCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function DagTree({ node, depth = 0 }: { node: DagNode; depth?: number }) { + return ( +
0 ? 'ml-4 border-l pl-3' : ''}`}> +
+ {node.idempotencyKey} + {node.stage} + {node.kind === 'composite' && ( + (composite) + )} +
+ {node.children.map(child => ( + + ))} +
+ ); +} diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx new file mode 100644 index 00000000..fc8825ba --- /dev/null +++ b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { PageHeader } from '@bytelyst/dashboard-components'; +import { useAuth } from '@/lib/auth-context'; +import { listJobs, type FleetJob } from '@/lib/fleet-client'; + +const STAGES = [ + '', + 'queued', + 'blocked', + 'assigned', + 'building', + 'review', + 'testing', + 'shipped', + 'failed', + 'dead_letter', +]; +const POLL_INTERVAL = 30_000; + +export default function FleetJobsPage() { + const { token } = useAuth(); + const [jobs, setJobs] = useState([]); + const [stage, setStage] = useState(''); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const params: Record = { limit: '50' }; + if (stage) params.stage = stage; + const res = await listJobs(params as never); + setJobs(res.jobs); + } catch { + /* degrade */ + } finally { + setLoading(false); + } + }, [stage]); + + useEffect(() => { + if (!token) return; + refresh(); + const id = setInterval(refresh, POLL_INTERVAL); + return () => clearInterval(id); + }, [token, refresh]); + + return ( +
+ + + {/* Filters */} +
+ + +
+ + {/* Table */} + {loading ? ( +

Loading jobs...

+ ) : jobs.length === 0 ? ( +

No jobs match the current filter.

+ ) : ( +
+ + + + + + + + + + + + + {jobs.map(j => ( + + + + + + + + + ))} + +
Idempotency KeyStagePriorityKindAttemptsCreated
+ + {j.idempotencyKey} + + + + {j.stage} + + {j.priority}{j.kind}{j.attempts} + {new Date(j.createdAt).toLocaleString()} +
+
+ )} +
+ ); +} diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/page.tsx new file mode 100644 index 00000000..5763df0e --- /dev/null +++ b/dashboards/tracker-web/src/app/dashboard/fleet/page.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { PageHeader } from '@bytelyst/dashboard-components'; +import { useAuth } from '@/lib/auth-context'; +import { listFactories, listJobs, type FleetFactory, type FleetJob } from '@/lib/fleet-client'; + +const POLL_INTERVAL = 30_000; + +function HealthBadge({ health }: { health: string }) { + const colors: Record = { + ok: 'bg-green-500/20 text-green-700 dark:text-green-400', + degraded: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400', + down: 'bg-red-500/20 text-red-700 dark:text-red-400', + }; + return ( + + {health} + + ); +} + +function StageBadge({ stage }: { stage: string }) { + const colors: Record = { + queued: 'bg-blue-500/20 text-blue-700 dark:text-blue-400', + assigned: 'bg-purple-500/20 text-purple-700 dark:text-purple-400', + building: 'bg-orange-500/20 text-orange-700 dark:text-orange-400', + shipped: 'bg-green-500/20 text-green-700 dark:text-green-400', + failed: 'bg-red-500/20 text-red-700 dark:text-red-400', + }; + return ( + + {stage} + + ); +} + +export default function FleetOverviewPage() { + const { token } = useAuth(); + const [factories, setFactories] = useState([]); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const [facRes, jobRes] = await Promise.all([listFactories(), listJobs({ limit: 10 })]); + setFactories(facRes.factories); + setJobs(jobRes.jobs); + } catch { + /* degrade gracefully */ + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!token) return; + refresh(); + const id = setInterval(refresh, POLL_INTERVAL); + return () => clearInterval(id); + }, [token, refresh]); + + if (loading) { + return ( +
+ +

Loading fleet data...

+
+ ); + } + + return ( +
+ + + {/* Factory cards */} +
+

Factories

+ {factories.length === 0 ? ( +

+ No factories registered. Factories appear after their first heartbeat. +

+ ) : ( +
+ {factories.map(f => ( +
+
+ {f.factoryId} + +
+
+

Capabilities: {f.capabilities.length > 0 ? f.capabilities.join(', ') : '—'}

+

+ Load: {f.load} / {f.seatLimit} seats +

+

Last heartbeat: {new Date(f.lastHeartbeatAt).toLocaleTimeString()}

+
+
+ ))} +
+ )} +
+ + {/* Recent jobs summary */} +
+
+

Recent Jobs

+ + View all → + +
+ {jobs.length === 0 ? ( +

No jobs found.

+ ) : ( +
+ + + + + + + + + + + {jobs.map(j => ( + + + + + + + ))} + +
KeyStagePriorityCreated
+ + {j.idempotencyKey} + + + + {j.priority} + {new Date(j.createdAt).toLocaleDateString()} +
+
+ )} +
+ + {/* Quick links */} + +
+ ); +} diff --git a/dashboards/tracker-web/src/app/dashboard/layout.tsx b/dashboards/tracker-web/src/app/dashboard/layout.tsx index 0b35aa43..664bdbf1 100644 --- a/dashboards/tracker-web/src/app/dashboard/layout.tsx +++ b/dashboards/tracker-web/src/app/dashboard/layout.tsx @@ -23,6 +23,7 @@ const NAV_ITEMS = [ { href: '/dashboard', label: 'Overview' }, { href: '/dashboard/items', label: 'Items' }, { href: '/dashboard/board', label: 'Board' }, + { href: '/dashboard/fleet', label: 'Fleet' }, ]; /** Open the ⌘K command palette by replaying the global hotkey. */ diff --git a/dashboards/tracker-web/src/lib/fleet-client.ts b/dashboards/tracker-web/src/lib/fleet-client.ts new file mode 100644 index 00000000..3ba0fbb1 --- /dev/null +++ b/dashboards/tracker-web/src/lib/fleet-client.ts @@ -0,0 +1,193 @@ +/** + * Fleet API client — typed wrapper for the fleet coordinator endpoints. + * Follows the same pattern as tracker-client.ts. + * Degrades gracefully: 404s return null, network errors return defaults. + */ + +import { createApiClient } from '@bytelyst/api-client'; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface FleetJob { + id: string; + productId: string; + stage: string; + idempotencyKey: string; + bodyMd: string; + priority: string; + priorityOrder: number; + capabilities: string[]; + kind: string; + parentId?: string; + attempts: number; + leaseEpoch: number; + createdAt: string; + updatedAt: string; +} + +export interface FleetFactory { + id: string; + productId: string; + factoryId: string; + capabilities: string[]; + health: 'ok' | 'degraded' | 'down'; + load: number; + seatLimit: number; + lastHeartbeatAt: string; +} + +export interface FleetRun { + id: string; + jobId: string; + attempt: number; + factoryId?: string; + engine: string; + startedAt: string; + endedAt?: string; + result?: string; + insights: Record; +} + +export interface FleetEvent { + id: string; + jobId: string; + seq: number; + type: string; + at: string; + actor?: string; + data: Record; +} + +export interface FleetArtifact { + id: string; + jobId: string; + kind: string; + contentType: string; + sizeBytes: number; + createdAt: string; +} + +export interface FleetBudget { + id: string; + productId: string; + ceilingUsd: number; + window: string; + spentUsd: number; + status: 'active' | 'paused'; + updatedAt: string; +} + +export interface DagNode { + id: string; + idempotencyKey: string; + stage: string; + priority: string; + kind: string; + parentId?: string; + children: DagNode[]; +} + +// ── Client ────────────────────────────────────────────────────────────────── + +const fleetApi = createApiClient({ + baseUrl: '/api/fleet', + getToken: () => (typeof window !== 'undefined' ? localStorage.getItem('tracker_token') : null), +}); + +function apiFetch(path: string, options?: RequestInit): Promise { + const extra: Record = {}; + if (typeof window !== 'undefined') { + const pid = localStorage.getItem('tracker_selected_product'); + if (pid) extra['x-product-id'] = pid; + } + return fleetApi.fetch(path, { + ...options, + headers: { ...extra, ...(options?.headers as Record) }, + }); +} + +/** Graceful fetch — returns null on 404 instead of throwing. */ +async function apiFetchOptional(path: string, options?: RequestInit): Promise { + try { + return await apiFetch(path, options); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('404')) return null; + throw err; + } +} + +// ── Jobs ──────────────────────────────────────────────────────────────────── + +export interface ListJobsParams { + stage?: string; + productId?: string; + limit?: number; + offset?: number; +} + +export async function listJobs(params?: ListJobsParams): Promise<{ jobs: FleetJob[] }> { + const qs = params ? `?${new URLSearchParams(params as Record).toString()}` : ''; + return apiFetch(`/jobs${qs}`); +} + +export async function getJob(id: string): Promise { + return apiFetchOptional(`/jobs/${id}`); +} + +export async function patchJob( + id: string, + body: { leaseEpoch: number; stage: string } +): Promise { + return apiFetch(`/jobs/${id}`, { method: 'PATCH', body: JSON.stringify(body) }); +} + +export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> { + return apiFetch(`/jobs/${jobId}/runs`); +} + +export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> { + return apiFetch(`/jobs/${jobId}/events`); +} + +export async function getJobArtifacts(jobId: string): Promise<{ artifacts: FleetArtifact[] }> { + return apiFetch(`/jobs/${jobId}/artifacts`); +} + +export async function getJobDag(jobId: string): Promise<{ dag: DagNode } | null> { + return apiFetchOptional(`/jobs/${jobId}/dag`); +} + +// ── Factories ─────────────────────────────────────────────────────────────── + +export async function listFactories(): Promise<{ factories: FleetFactory[] }> { + try { + return await apiFetch('/factories'); + } catch { + return { factories: [] }; + } +} + +// ── Budgets ───────────────────────────────────────────────────────────────── + +export async function getBudget(productId: string): Promise { + return apiFetchOptional(`/budgets/${productId}`); +} + +export async function upsertBudget( + productId: string, + ceilingUsd: number, + window: string +): Promise { + return apiFetch(`/budgets/${productId}`, { + method: 'PUT', + body: JSON.stringify({ ceilingUsd, window }), + }); +} + +export async function pauseBudget(productId: string): Promise { + return apiFetch(`/budgets/${productId}/pause`, { method: 'POST' }); +} + +export async function resumeBudget(productId: string): Promise { + return apiFetch(`/budgets/${productId}/resume`, { method: 'POST' }); +} diff --git a/services/platform-service/.data/platform-events.json b/services/platform-service/.data/platform-events.json new file mode 100644 index 00000000..637c238d --- /dev/null +++ b/services/platform-service/.data/platform-events.json @@ -0,0 +1,67 @@ +{ + "platform-events": [ + { + "id": "5be64f54-5c39-4f0f-af7c-ac7069121a11", + "queueName": "platform-events", + "type": "user.created", + "payload": { + "event": { + "id": "37c00297-57b4-4024-a946-82a2067f350b", + "type": "user.created", + "payload": { + "userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93", + "email": "test@chronomind.app", + "plan": "free", + "productId": "chronomind" + }, + "timestamp": "2026-05-30T06:45:48.670Z", + "source": "auth/register" + } + }, + "status": "succeeded", + "attempts": 1, + "maxAttempts": 3, + "createdAt": "2026-05-30T06:45:48.671Z", + "scheduledAt": "2026-05-30T06:45:48.671Z", + "metadata": { + "source": "auth/register" + }, + "idempotencyKey": "37c00297-57b4-4024-a946-82a2067f350b", + "productId": "chronomind", + "startedAt": "2026-05-30T06:45:48.759Z", + "completedAt": "2026-05-30T06:45:51.756Z" + }, + { + "id": "7438ea3f-fc58-4d04-b58b-628dec76f1ce", + "queueName": "platform-events", + "type": "user.email_verification_requested", + "payload": { + "event": { + "id": "84156892-12f7-448d-9579-7f759af83bbf", + "type": "user.email_verification_requested", + "payload": { + "userId": "usr_8f2f412f-69f1-4b5f-949c-d40e175b7a93", + "email": "test@chronomind.app", + "verificationToken": "4d96e404-8c86-443c-bb95-cf68b7cc194c", + "displayName": "Test User", + "productId": "chronomind" + }, + "timestamp": "2026-05-30T06:45:48.822Z", + "source": "auth/register" + } + }, + "status": "succeeded", + "attempts": 1, + "maxAttempts": 3, + "createdAt": "2026-05-30T06:45:48.828Z", + "scheduledAt": "2026-05-30T06:45:48.828Z", + "metadata": { + "source": "auth/register" + }, + "idempotencyKey": "84156892-12f7-448d-9579-7f759af83bbf", + "productId": "chronomind", + "startedAt": "2026-05-30T06:45:51.771Z", + "completedAt": "2026-05-30T06:45:54.163Z" + } + ] +}