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<string, unknown>; 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.
This commit is contained in:
2026-07-17 23:08:35 +02:00
parent 7dc1c1ae39
commit 6806fac5fd
10 changed files with 184 additions and 67 deletions

View File

@@ -1 +1 @@
0.7.10
0.7.11

View File

@@ -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 `<svelte:component>` 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 |

View File

@@ -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,

View File

@@ -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'

View File

@@ -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)
}

View File

@@ -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<string, unknown>
// 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

115
web/src/lib/types.ts Normal file
View File

@@ -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<string, unknown> }
}
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<string, unknown>
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
}

View File

@@ -41,9 +41,7 @@ export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300
}) as T;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChild<T> = T extends { child?: unknown } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: unknown } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
/// <reference types="vitest/config" />
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}`)
})