Files
oikos/web/src/lib/stores/activity.ts
dtoro 873b00ac42
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
style(web): fix prettier config, format entire web/ tree
.prettierrc.json was missing "semi": false, so prettier wanted to add
semicolons to a codebase written without them (763 semicolon-free
statements vs. 150 with, in hand-written .ts; zero hand-written .svelte
files use them at all). That's why prettier --check failed on 249 files
— not because the code was unformatted, but because the config didn't
match the actual house style. Added "semi": false; left printWidth/etc
as configured (printWidth barely moves the failure count: 218/213/212
files at 100/120/140).

Ran `prettier --write .` with the corrected config. Verified
semantics-preserving before and after:
- eslint: 142 problems both before and after, byte-identical
- build passes, 38/38 tests pass
- token-stream diff (whitespace/semicolons/quotes normalized) on all
  218 changed files: only 52 had any remaining token change, all either
  trailing-comma removal (matching trailingComma: "none") or import/
  ternary reflow — no semantic changes
- live smoke test: Knowledge, Tasks, Fleet map, and a chat window
  (AgentTrace, markdown, Scope graph, activity rail) all render
  correctly, no console errors

Most of the diff is shadcn/ui vendor files (lib/components/ui/) moving
from the CLI's own style (double quotes, tabs, semicolons) to house
style; re-running `shadcn-svelte add` on a component will need a
follow-up format pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:56:07 +02:00

263 lines
8.9 KiB
TypeScript

import { derived, type Readable } from 'svelte/store'
import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import type { PlanStep, Session } from '$lib/api'
export { type ToolCallResult }
export interface ActivityEntry {
id: string
type:
| 'goal'
| 'plan'
| 'step_running'
| 'step_done'
| 'step_failed'
| 'tool_running'
| 'tool_done'
| 'tool_error'
| 'knowledge'
| 'complete'
| 'question'
| 'error'
description: string
detail?: string
args?: string
timestamp: number
toolName?: string
stepSeq?: number
indent?: boolean
status: 'running' | 'done' | 'failed'
}
// Detail text is kept full-length (not hard-truncated to a preview snippet)
// so the expanded view has something worth pretty-printing — capped only as
// a safety net against pathological payloads (a full fleet dump, etc).
const DETAIL_MAX = 8000
function summarizeArgs(args: unknown): string | undefined {
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
if (Object.keys(args).length === 0) return undefined
try {
return JSON.stringify(args)
} catch {
return undefined
}
}
function stringifyResult(result: unknown): string {
const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
}
// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
function computeActivityLog(
$msgs: ChatMessage[],
$steps: PlanStep[],
$task: Session | null
): ActivityEntry[] {
const entries: ActivityEntry[] = []
const now = Date.now()
// Goal
if ($task?.goal) {
entries.push({
id: 'goal',
type: 'goal',
description: $task.goal,
timestamp: 0,
status: 'done'
})
}
// Plan steps
for (const s of $steps) {
if (s.status === 'pending') continue
const stepLabel = s.title || `Step ${s.seq}`
entries.push({
id: s.id,
type:
s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed',
description: `Step ${s.seq}: ${stepLabel}`,
detail: s.detail || undefined,
timestamp: s.started_at ? new Date(s.started_at).getTime() : now,
status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed'
})
}
// Tool calls (from messages). Tag each tool with the plan step that's
// currently running when it fires.
let currentStepSeq = 0
let entryIdx = 0
for (let mi = 0; mi < $msgs.length; mi++) {
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 = 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
}
const label = toolActivityLabel(t)
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
if (t.type === 'tool_use') {
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
type: 'tool_running',
description: label,
args: summarizeArgs(t.args),
timestamp: now - ($msgs.length - mi) * 1000,
toolName: t.name,
stepSeq: stepTag,
indent: stepTag != null,
status: 'running'
})
} else if (t.type === 'tool_result') {
const running = entries.find(
(e) => e.type === 'tool_running' && e.id === t.id && e.status === 'running'
)
if (running && t.error) {
running.type = 'tool_error'
running.status = 'failed'
running.description = `${label}: ${t.error.slice(0, 80)}`
running.detail = t.error
} else if (running) {
running.type = 'tool_done'
running.status = 'done'
running.detail = stringifyResult(t.result)
} else {
// Historical/persisted tool calls arrive as one merged record (args
// + result on the same object, see mergeToolCalls in chat.ts) rather
// than a separate tool_use/tool_result pair — there's never a
// "running" entry to attach to, so this branch has to build the
// full entry itself. It used to fall back to the raw tool name
// (e.g. "get_entity") instead of the humanized label here.
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
type: t.error ? 'tool_error' : 'tool_done',
description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label,
detail: t.error ? t.error : stringifyResult(t.result),
args: summarizeArgs(t.args),
timestamp: now - ($msgs.length - mi) * 1000,
toolName: t.name,
stepSeq: stepTag,
indent: stepTag != null,
status: t.error ? 'failed' : 'done'
})
}
}
}
}
// Knowledge recorded — detect from upsert_knowledge tool results
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 = typeof t.args?.title === 'string' ? t.args.title : ''
entries.push({
id: `knowledge_${mi}`,
type: 'knowledge',
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
timestamp: now - ($msgs.length - mi) * 1000,
status: 'done'
})
}
}
}
// Task completion
if ($task?.outcome) {
entries.push({
id: 'complete',
type: 'complete',
description: $task.summary || `Task ${$task.outcome}`,
timestamp: now,
status: $task.outcome === 'failure' ? 'failed' : 'done'
})
}
// Note: approval entries were removed from activityLog (2026-07-15).
// They were always `status: 'running'` and never transitioned to 'done'
// (the derived store builds from tool-call text, not execution status),
// which caused the AgentIndicator to latch onto a stale "Approval: ..."
// entry and never clear — even after the session completed. Approvals
// are tracked via the REST /approvals endpoint (context.ts, Ops.svelte)
// and rendered as inline approval cards in the chat (or Ops page), not
// in the activity log.
// Sort oldest first
entries.sort((a, b) => a.timestamp - b.timestamp)
return entries
}
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
)
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
const chat = chatFor(sessionId)
const ws = workspaceFor(sessionId)
const task = taskFor(sessionId)
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
)
}
// Humanized, past/present-tense description of what a tool call is doing
// ("Check execution", "Research: …") rather than its raw wire name. Exported
// so the chat's agent trace can read as a thinking log instead of an API log.
export 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: ${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'
case 'list_entities':
return 'List entities'
case 'get_health_summary':
return 'Fleet health'
case 'get_state_snapshot':
return 'State snapshot'
case 'run': {
const purpose = str(args.purpose)
const target = str(args.target)
if (purpose) return purpose
if (target) return `Run on ${target}`
return 'Run command'
}
case 'get_execution_status':
return 'Check execution'
case 'update_plan_step':
return 'Update plan'
case 'upsert_knowledge':
return 'Record knowledge'
case 'complete_task':
return 'Complete task'
case 'ping_service':
return 'Check service'
case 'ask_operator':
return 'Ask operator'
// Unmapped tool (new/uncommon) — humanize the raw name rather than
// showing it verbatim, e.g. "revoke_execution" -> "Revoke execution".
default:
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
}
}