diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57ec9e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build + run: bun run build diff --git a/package.json b/package.json index 9b4fe9a..40209e8 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ "scripts": { "dev": "concurrently -n server,vite -c blue,magenta \"bun run src/server/index.ts\" \"cd src/frontend && bunx vite dev --port 3030\"", "build": "bun run scripts/build.ts", - "start": "./admiral" + "start": "./admiral", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p src/frontend/tsconfig.json", + "test": "bun test" }, "dependencies": { - "@mariozechner/pi-ai": "latest", + "@mariozechner/pi-ai": "^0.73.1", + "@opentelemetry/api": "^1.9.1", "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -24,6 +27,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.0", "@tailwindcss/vite": "^4.2.0", + "@types/bun": "^1.3.14", "@types/node": "^25.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/server/lib/agent-manager.ts b/src/server/lib/agent-manager.ts index ec8694c..0ebf787 100644 --- a/src/server/lib/agent-manager.ts +++ b/src/server/lib/agent-manager.ts @@ -22,10 +22,9 @@ type SlimGameState = { function slimGameState(raw: Record | null): SlimGameState { if (!raw) return null - const gs = raw as Record & { cargo?: unknown[]; current_ammo?: unknown; magazine_size?: unknown }> - const player = gs.player as Record | undefined - const ship = gs.ship as Record & { cargo?: unknown[] } | undefined - const modules = gs.modules as Array> | undefined + const player = raw.player as Record | undefined + const ship = raw.ship as (Record & { cargo?: unknown[] }) | undefined + const modules = raw.modules as Array & { current_ammo?: unknown; magazine_size?: unknown }> | undefined return { credits: player?.credits, system: player?.current_system, diff --git a/src/server/lib/connections/http.ts b/src/server/lib/connections/http.ts index a1a1cee..1da8c04 100644 --- a/src/server/lib/connections/http.ts +++ b/src/server/lib/connections/http.ts @@ -149,7 +149,7 @@ export class HttpConnection implements GameConnection { }) if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`) - const data = await resp.json() + const data = await resp.json() as { session?: ApiSession } if (data.session) { this.session = data.session } else { @@ -194,9 +194,9 @@ export class HttpConnection implements GameConnection { } try { - const data = await resp.json() + const data = await resp.json() as CommandResult & { session?: ApiSession } if (data.session) this.session = data.session - return data as CommandResult + return data } catch { return { error: { code: 'http_error', message: `HTTP ${resp.status}` } } } diff --git a/src/server/lib/connections/http_v2.ts b/src/server/lib/connections/http_v2.ts index ae9d002..d2578af 100644 --- a/src/server/lib/connections/http_v2.ts +++ b/src/server/lib/connections/http_v2.ts @@ -226,7 +226,7 @@ export class HttpV2Connection implements GameConnection { }) if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`) - const data = await resp.json() + const data = await resp.json() as { session?: ApiSession } if (data.session) { this.session = data.session } else { @@ -280,12 +280,12 @@ export class HttpV2Connection implements GameConnection { } try { - const data = await resp.json() + const data = await resp.json() as CommandResult & { session?: ApiSession } if (data.session) this.session = data.session // v2 returns { result: , structuredContent: } // Keep both: result (text) goes to the LLM, structuredContent is used // for cacheGameState and player data display. - return data as CommandResult + return data } catch { return { error: { code: 'http_error', message: `HTTP ${resp.status}` } } } diff --git a/src/server/lib/connections/mcp.ts b/src/server/lib/connections/mcp.ts index 8314208..3272793 100644 --- a/src/server/lib/connections/mcp.ts +++ b/src/server/lib/connections/mcp.ts @@ -160,7 +160,7 @@ export class McpConnection implements GameConnection { return { error: { message: 'No matching response in SSE stream' } } } - return await resp.json() + return await resp.json() as { result?: unknown; error?: { code?: number; message: string } } } private async sendNotification(method: string, params: unknown): Promise { diff --git a/src/server/lib/db.ts b/src/server/lib/db.ts index ebdc1ac..5af930d 100644 --- a/src/server/lib/db.ts +++ b/src/server/lib/db.ts @@ -1,4 +1,5 @@ import { Database } from 'bun:sqlite' +import type { SQLQueryBindings } from 'bun:sqlite' import path from 'path' import fs from 'fs' import type { Provider, Profile, LogEntry } from '../../shared/types' @@ -182,7 +183,7 @@ export function updateProfile(id: string, updates: Partial): Profile | sets.push("updated_at = datetime('now')") vals.push(id) - getDb().query(`UPDATE profiles SET ${sets.join(', ')} WHERE id = ?`).run(...vals) + getDb().query(`UPDATE profiles SET ${sets.join(', ')} WHERE id = ?`).run(...(vals as SQLQueryBindings[])) return getProfile(id) } @@ -192,11 +193,29 @@ export function deleteProfile(id: string): void { // --- Log CRUD --- +// Every LLM call, tool call, and server response gets logged here with no +// other cap, so admiral.db grows unbounded over a long-running agent +// (reported: 18GB). Trim old rows past this cap, checked probabilistically +// on insert so we're not running a DELETE on every single log write. +const LOG_ENTRY_CAP_PER_PROFILE = 20_000 +const LOG_TRIM_CHECK_INTERVAL = 200 + export function addLogEntry(profileId: string, type: string, summary: string, detail?: string): number { - const result = getDb().query( + const db = getDb() + const result = db.query( 'INSERT INTO log_entries (profile_id, type, summary, detail) VALUES (?, ?, ?, ?)' ).run(profileId, type, summary, detail ?? null) - return Number(result.lastInsertRowid) + const id = Number(result.lastInsertRowid) + if (id % LOG_TRIM_CHECK_INTERVAL === 0) trimLogEntries(profileId) + return id +} + +function trimLogEntries(profileId: string): void { + getDb().query( + `DELETE FROM log_entries WHERE profile_id = ? AND id NOT IN ( + SELECT id FROM log_entries WHERE profile_id = ? ORDER BY id DESC LIMIT ? + )` + ).run(profileId, profileId, LOG_ENTRY_CAP_PER_PROFILE) } export function getLogEntries(profileId: string, afterId?: number, limit: number = 100): LogEntry[] { diff --git a/src/server/lib/schema.ts b/src/server/lib/schema.ts index 466e198..0ccadef 100644 --- a/src/server/lib/schema.ts +++ b/src/server/lib/schema.ts @@ -42,7 +42,7 @@ export async function fetchOpenApiSpec( } throw new Error(`HTTP ${resp.status}`) } - const spec = await resp.json() + const spec = await resp.json() as Record // Cache on success try { setPreference(cacheKey, JSON.stringify(spec)) diff --git a/src/server/routes/logs.ts b/src/server/routes/logs.ts index 12473a3..9bdde2a 100644 --- a/src/server/routes/logs.ts +++ b/src/server/routes/logs.ts @@ -58,7 +58,7 @@ logs.get('/:id/logs', async (c) => { // Heartbeat - must be well under the Bun.serve idleTimeout (120s) const heartbeat = setInterval(() => { if (closed) { clearInterval(heartbeat); return } - stream.writeSSE({ data: '', comment: 'heartbeat' }).catch(() => { closed = true }) + stream.writeSSE({ data: '' }).catch(() => { closed = true }) }, 30000) // Keep the stream open until client disconnects diff --git a/src/server/routes/models.ts b/src/server/routes/models.ts index 53ed7ff..249d1a4 100644 --- a/src/server/routes/models.ts +++ b/src/server/routes/models.ts @@ -90,7 +90,7 @@ async function fetchOllamaModels(baseUrl?: string): Promise { try { const resp = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(5000) }) if (!resp.ok) return [] - const data = await resp.json() + const data = await resp.json() as Record const modelList = data.models as { name: string }[] | undefined return (modelList || []).map(m => m.name).sort() } catch { @@ -105,7 +105,7 @@ async function fetchOpenAICompatModels(apiUrl: string, apiKey?: string): Promise const resp = await fetch(apiUrl, { headers, signal: AbortSignal.timeout(10000) }) if (!resp.ok) return [] - const data = await resp.json() + const data = await resp.json() as Record const modelList = data.data as { id: string }[] | undefined return (modelList || []).map(m => m.id).sort() } catch {