From 6806fac5fd6228218c3adebf557b7760eec86969 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 17 Jul 2026 23:08:35 +0200 Subject: [PATCH] feat(web): define ChatEvent discriminated union, eliminate all `any` sites (R9) Created web/src/lib/types.ts with discriminated unions for SSE event payloads: ChatEvent (7 variants: session, tool_use, tool_result, text_delta, text, done, error), ToolCallResult, MessageContent, and typed data shapes for live events (PlanProposedData, PlanStepEventData, QuestionRaisedData, QuestionAnsweredData, EntityTouchedData, HealthChangedData) plus WailsGlobal for the desktop bridge. Replaced all ~15 `any` sites across 7 files: - api.ts: Message.content any -> MessageContent | string; removed local ChatEvent interface (now imported from types.ts as a discriminated union); JSON.parse cast to ChatEvent. - stores/chat.ts: removed local ToolCallResult interface (imported from types.ts, re-exported for backward compat); extractApprovals accesses args with typeof guards instead of implicit any access; toChatMessages handles string|object Message.content cleanly. - stores/activity.ts: update_plan_step seq/status extracted via typeof guards instead of `as any` casts; toolActivityLabel uses a str() helper for safe string extraction from unknown args. - stores/workspace.ts: applyPlanStepEvent takes PlanStepEventData; applyEvent casts data to Record; switch cases cast to typed interfaces (PlanProposedData, QuestionRaisedData, etc.) instead of `as any`; applyHealthChanged uses HealthChangedData. - Config.svelte: (window as any).wails -> typed WailsGlobal cast; catch (e: any) -> catch (e: unknown) with instanceof Error check. - utils.ts: WithoutChild/WithoutChildren `any` -> `unknown`. - vite.config.ts: authProxy proxy/proxyReq `any` -> ProxyOptions type. Result: eslint no-explicit-any warnings dropped 12 -> 0. Tests (6/6) and build pass. VERSION 0.7.10 -> 0.7.11. Plan R9 marked done. --- VERSION | 2 +- .../2026-07-17-codebase-review-and-cleanup.md | 2 +- web/src/lib/api.ts | 9 +- web/src/lib/stores/activity.ts | 15 +-- web/src/lib/stores/chat.ts | 27 ++-- web/src/lib/stores/workspace.ts | 61 ++++++---- web/src/lib/types.ts | 115 ++++++++++++++++++ web/src/lib/utils.ts | 6 +- web/src/pages/Config.svelte | 7 +- web/vite.config.ts | 7 +- 10 files changed, 184 insertions(+), 67 deletions(-) create mode 100644 web/src/lib/types.ts diff --git a/VERSION b/VERSION index 5b209ea..b4d6d12 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.10 +0.7.11 diff --git a/plans/2026-07-17-codebase-review-and-cleanup.md b/plans/2026-07-17-codebase-review-and-cleanup.md index eeb0428..773d274 100644 --- a/plans/2026-07-17-codebase-review-and-cleanup.md +++ b/plans/2026-07-17-codebase-review-and-cleanup.md @@ -430,7 +430,7 @@ the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `b | R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | ✅ done | | R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | ✅ partial — pure unit tests added for all 6 packages; remaining coverage needs integration tests (`make test-db`) | | R8 | Add `eslint`+`prettier`+`vitest` to `web/`; wire `svelte-check`+`tsc` into CI; add `web/` CI job | M | Low | ✅ done | -| R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low | +| R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low | ✅ done | | R10 | Replace `` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low | ✅ done | | R11 | Add the 8 manually-registered `serve*` routes to `openapi.yaml` (or document the carve-out) | S | Low | ✅ done — documented the carve-out | | R12 | Add `docs/mbse/README.md` "Last verified" header + scheduled re-verification; normalize ADR 0013/0014 template | S | Low | diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2845888..8cb04bb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -29,7 +29,7 @@ export interface Message { id: string session_id: string role: string - content: any + content: MessageContent | string created_at: string } @@ -102,13 +102,6 @@ export async function answerQuestion(sessionId: string, questionId: string, answ return res.ok } -export interface ChatEvent { - type: string - data: any - session_id?: string - iteration?: number -} - export function streamChat( message: string, sessionId: string | null, diff --git a/web/src/lib/stores/activity.ts b/web/src/lib/stores/activity.ts index 66f5cfd..058e7f8 100644 --- a/web/src/lib/stores/activity.ts +++ b/web/src/lib/stores/activity.ts @@ -70,8 +70,8 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, for (const t of $msgs[mi].tools) { // Track current step from update_plan_step calls if (t.type === 'tool_use' && t.name === 'update_plan_step') { - const s = (t.args as any)?.seq as number | undefined - const status = (t.args as any)?.status as string | undefined + const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined + const status = typeof t.args?.status === 'string' ? t.args.status : undefined if (s && status === 'running') currentStepSeq = s } else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') { currentStepSeq = 0 @@ -132,7 +132,7 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, for (let mi = 0; mi < $msgs.length; mi++) { for (const t of $msgs[mi].tools) { if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) { - const title = t.args?.title ?? '' + const title = typeof t.args?.title === 'string' ? t.args.title : '' entries.push({ id: `knowledge_${mi}`, type: 'knowledge', @@ -172,11 +172,12 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, function toolActivityLabel(t: ToolCallResult): string { const args = t.args ?? {} + const str = (v: unknown): string => typeof v === 'string' ? v : '' switch (t.name) { case 'set_goal': return 'Set goal' case 'propose_plan': return 'Proposed plan' - case 'search_knowledge': return `Research: ${args.query || ''}` - case 'get_entity': return `Lookup: ${args.slug_or_id || ''}` + case 'search_knowledge': return `Research: ${str(args.query)}` + case 'get_entity': return `Lookup: ${str(args.slug_or_id)}` case 'get_entity_knowledge': return 'Check prior knowledge' case 'get_relations': return 'Check relationships' case 'list_lxcs': return 'List containers' @@ -184,8 +185,8 @@ function toolActivityLabel(t: ToolCallResult): string { case 'get_health_summary': return 'Fleet health' case 'get_state_snapshot': return 'State snapshot' case 'run': { - const purpose = args.purpose as string || '' - const target = (args.target as string) || '' + const purpose = str(args.purpose) + const target = str(args.target) if (purpose) return purpose if (target) return `Run on ${target}` return 'Run command' diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index 59d7739..3039c6f 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -1,6 +1,9 @@ import { writable, get } from 'svelte/store' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api' +import type { ToolCallResult } from '$lib/types' + +export type { ToolCallResult } export interface PendingApproval { executionId: string @@ -38,28 +41,21 @@ function extractApprovals(tools: ToolCallResult[]): PendingApproval[] { if (!text.includes('requires approval')) continue const m = text.match(APPROVAL_RE) if (m) { + const args = t.args ?? {} + const purpose = typeof args.purpose === 'string' ? args.purpose : undefined out.push({ executionId: m[1], - action: t.args?.purpose?.slice(0, 60) ?? t.args?.action ?? t.name ?? 'unknown', - target: t.args?.target ?? 'unknown', + action: purpose ? purpose.slice(0, 60) : typeof args.action === 'string' ? args.action : t.name, + target: typeof args.target === 'string' ? args.target : 'unknown', destructive: /\bDESTRUCTIVE\b/.test(text), - command: t.args?.command, - purpose: t.args?.purpose + command: typeof args.command === 'string' ? args.command : undefined, + purpose }) } } return out } -export interface ToolCallResult { - type: 'tool_use' | 'tool_result' - name: string - id?: string - args?: any - result?: any - error?: string -} - function mid(): string { return crypto.randomUUID() } @@ -119,11 +115,12 @@ function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { function toChatMessages(msgs: Message[]): ChatMessage[] { return msgs.map((m) => { - const tools = mergeToolCalls(m.content?.tool_calls) + const content = typeof m.content === 'string' ? { text: m.content } : m.content + const tools = mergeToolCalls(content?.tool_calls) return { id: m.id, role: m.role as 'user' | 'assistant', - text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), + text: content?.text ?? '', tools, pendingApprovals: extractApprovals(tools) } diff --git a/web/src/lib/stores/workspace.ts b/web/src/lib/stores/workspace.ts index fbfd636..4f37c30 100644 --- a/web/src/lib/stores/workspace.ts +++ b/web/src/lib/stores/workspace.ts @@ -2,6 +2,14 @@ import { writable, derived, get } from 'svelte/store' import { liveEvents, subscribeEvents } from './events' import { currentSession, sessions, loadSessions } from './chat' import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api' +import type { + PlanProposedData, + PlanStepEventData, + QuestionRaisedData, + QuestionAnsweredData, + EntityTouchedData, + HealthChangedData +} from '$lib/types' // workspace.ts is the live "what is this task doing right now" surface for the // TaskContextPanel: plan progress, the pinned operator question, and entities @@ -65,9 +73,9 @@ async function hydrate(sessionId: string) { questions.set(qs) } -function applyPlanStepEvent(sessionId: string, type: string, data: any) { - const stepID = data?.step_id as string | undefined - const seq = data?.seq as number | undefined +function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) { + const stepID = data?.step_id + const seq = data?.seq planSteps.update((steps) => { const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq)) if (i === -1) return steps @@ -80,7 +88,7 @@ function applyPlanStepEvent(sessionId: string, type: string, data: any) { function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) { const sid = get(currentSession) if (!sid || ev.correlation_id !== sid) return - const data = (ev.data ?? {}) as any + const data = (ev.data ?? {}) as Record // Task fields (status/goal/outcome) live on the session row — refetch the // (cheap) session list so the UI picks up the change without a @@ -99,46 +107,49 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u } switch (ev.type) { - case 'plan.proposed': - if (Array.isArray(data.steps)) { - const incoming = data.steps.map((s: any) => ({ + case 'plan.proposed': { + const d = data as unknown as PlanProposedData + if (Array.isArray(d.steps)) { + const incoming = d.steps.map((s) => ({ id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '', status: 'pending' as const, target_slug: s.target_slug || undefined })) - // The server appends rather than replaces once any step has started - // (see store.go proposePlan) — mirror that here so a model that calls - // propose_plan once per step still shows the FULL running history in - // the panel, not just its latest call's single step. - planSteps.update((existing) => (data.appended ? [...existing, ...incoming] : incoming)) + planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming)) } break + } case 'plan.step.started': case 'plan.step.finished': - applyPlanStepEvent(sid, ev.type, data) + applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData) break - case 'question.raised': + case 'question.raised': { + const d = data as unknown as QuestionRaisedData questions.update((qs) => [ { - id: data.question_id, prompt: data.prompt ?? '', - context: { why: data.why, options: data.options, entities: data.entities }, + id: d.question_id, prompt: d.prompt ?? '', + context: { why: d.why, options: d.options, entities: d.entities }, status: 'open', created_at: new Date().toISOString() }, - ...qs.filter((q) => q.id !== data.question_id) + ...qs.filter((q) => q.id !== d.question_id) ]) break - case 'question.answered': + } + case 'question.answered': { + const d = data as unknown as QuestionAnsweredData questions.update((qs) => - qs.map((q) => (q.id === data.question_id ? { ...q, status: 'answered', answer: data.answer } : q)) + qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q)) ) break - case 'entity.touched': - if (data.slug) { + } + case 'entity.touched': { + const d = data as unknown as EntityTouchedData + if (d.slug) { const now = Date.now() - touched.update((t) => [{ slug: data.slug, tool: data.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX)) + touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX)) } break + } case 'knowledge.recorded': - // No dedicated store — knowledge cards refetch on completion signal. break } } @@ -148,11 +159,11 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u // by correlation_id (health events don't carry one). function applyHealthChanged(ev: { type: string; data?: unknown }) { if (ev.type !== 'health.changed') return - const data = (ev.data ?? {}) as any + const data = (ev.data ?? {}) as HealthChangedData if (!data.slug) return const isRelevant = get(touched).some((t) => t.slug === data.slug) if (!isRelevant) return - healthDiffs.update((d) => [{ slug: data.slug, from: data.from, to: data.to, ts: Date.now() }, ...d].slice(0, 8)) + healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8)) } // startWorkspace opens the global event subscription and begins tracking the diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..3b4b0e7 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,115 @@ +// Discriminated unions for SSE event payloads — eliminates `any` in the +// chat and workspace stores by giving each event type a concrete data shape. + +// ---- Chat SSE events (streamChat / /agent/chat) ---- + +export interface ChatSessionEvent { + type: 'session' + data: string +} + +export interface ChatToolUseEvent { + type: 'tool_use' + data: { name: string; id: string; args: Record } +} + +export interface ChatToolResultEvent { + type: 'tool_result' + data: { id: string; result: unknown; error?: string } +} + +export interface ChatTextDeltaEvent { + type: 'text_delta' + data: string +} + +export interface ChatTextEvent { + type: 'text' + data: string +} + +export interface ChatDoneEvent { + type: 'done' + data?: { session_id?: string } + session_id?: string +} + +export interface ChatErrorEvent { + type: 'error' + data: string +} + +export type ChatEvent = + | ChatSessionEvent + | ChatToolUseEvent + | ChatToolResultEvent + | ChatTextDeltaEvent + | ChatTextEvent + | ChatDoneEvent + | ChatErrorEvent + +// ---- Tool call result (merged from tool_use + tool_result SSE pairs) ---- + +export interface ToolCallResult { + type: 'tool_use' | 'tool_result' + name: string + id?: string + args?: Record + result?: unknown + error?: string +} + +// ---- Message content (persisted messages from /agent/sessions/:id) ---- + +export interface MessageContent { + text?: string + tool_calls?: ToolCallResult[] +} + +// ---- Live event stream (SSE /api/v1/events/stream) ---- + +export interface PlanProposedData { + steps: Array<{ id: string; seq: number; title: string; detail?: string; target_slug?: string }> + appended?: boolean +} + +export interface PlanStepEventData { + step_id?: string + seq?: number + status?: string + execution_id?: string +} + +export interface QuestionRaisedData { + question_id: string + prompt?: string + why?: string + options?: string[] + entities?: string[] +} + +export interface QuestionAnsweredData { + question_id: string + answer?: string +} + +export interface EntityTouchedData { + slug?: string + tool?: string +} + +export interface HealthChangedData { + slug?: string + from?: string + to?: string +} + +// ---- Wails desktop bridge (injected into window) ---- + +export interface WailsCall { + ByName: (method: string, ...args: unknown[]) => unknown +} + +export interface WailsGlobal { + Call?: WailsCall +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 170c254..9419b3e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -41,9 +41,7 @@ export function debounce void>(fn: T, wait = 300 }) as T; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChild = T extends { child?: unknown } ? Omit : T; +export type WithoutChildren = T extends { children?: unknown } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = T & { ref?: U | null }; diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 1cbfb25..9050039 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -7,6 +7,7 @@ import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config' import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc' import ConfigBackground from '$lib/components/ConfigBackground.svelte' + import type { WailsGlobal } from '$lib/types' let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props() @@ -54,7 +55,7 @@ } function saveToDesktop() { - const wails = (window as any).wails + const wails = (window as unknown as { wails?: WailsGlobal }).wails if (!wails?.Call?.ByName) return try { wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim()) @@ -71,8 +72,8 @@ try { await startLogin() - } catch (e: any) { - error = e.message || 'OIDC login failed' + } catch (e: unknown) { + error = e instanceof Error ? e.message : 'OIDC login failed' oidcLoggingIn = false } } diff --git a/web/vite.config.ts b/web/vite.config.ts index 7916860..92500a8 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,5 @@ /// +import type { ProxyOptions } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' import tailwindcss from '@tailwindcss/vite' import { readFileSync, existsSync } from 'fs' @@ -17,12 +18,12 @@ if (existsSync('../VERSION')) { // Injects OIKOS_API_TOKEN into proxied /api requests in dev — the API no // longer has a dev-open bypass (plans/2026-07-12-wails-desktop-app.md 0.4), // so `OIKOS_API_TOKEN=dev-token npm run dev` needs this to reach it. -function authProxy(target: string, rewrite?: (path: string) => string) { +function authProxy(target: string, rewrite?: (path: string) => string): ProxyOptions { return { target, ...(rewrite ? { rewrite } : {}), - configure: (proxy: any) => { - proxy.on('proxyReq', (proxyReq: any) => { + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { const token = process.env.OIKOS_API_TOKEN if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) })