12 Commits

Author SHA1 Message Date
69964abe2e chore: reconcile on-client path + add golangci-lint config (R13+R14)
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
R13 — on-client path reconciliation:
- AGENTS.md: 6 occurrences of /opt/homelab-context/ → /opt/homelab/
  (sections 1, 2, 5, 7)
- .agents/NOMOS.md: 2 occurrences of /opt/homelab-context/ → /opt/homelab/
- CLIENTS.md already used /opt/homelab/ — now consistent across all docs.
  The repo is still named 'homelab-context' (git remote), it just clones
  to /opt/homelab/ on enrolled clients per CLIENTS.md.

R14 — golangci-lint/staticcheck/govulncheck tooling:
- .golangci.yml (new): config enabling govet, staticcheck, ineffassign,
  unused, errcheck, gosimple, typecheck, misspell, revive. Excludes
  generated code (internal/httpapi/gen/, internal/db/sqlcgen/) and
  relaxes errcheck in test files.
- Makefile: split 'lint' target into vet, golangci, govulncheck subtargets.
  Each checks if the tool is installed and prints install instructions
  if not. 'make lint' runs all three.
- CI already had golangci-lint-action + govulncheck (both advisory);
  the action auto-discovers .golangci.yml.
2026-07-17 23:09:47 +02:00
6806fac5fd 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.
2026-07-17 23:08:35 +02:00
7dc1c1ae39 docs: document the non-OpenAPI routes carve-out (R11)
10 routes are registered manually on the chi router in server.go rather
than generated from openapi.yaml. Added a 'Non-OpenAPI routes' comment
block at the top of NewHandler listing each route with its structural
reason for the carve-out:

  - Auth/infra: /healthz, /api/v1/auth/oidc-*, /oidc-callback — bypass
    auth middleware or aren't JSON API
  - SSE override: /api/v1/events/stream — re-registered for Flush()
  - Ad-hoc aggregations: /knowledge/recent, /knowledge/content/{id},
    /activity/recent, /activity/session/{id}, /learning/timeline,
    /learning/trend — derived shapes with no schema type yet

Updated .agents/dev/CONTRIBUTING.md §OpenAPI codegen with the carve-out
policy: if an ad-hoc route stabilizes, promote it to openapi.yaml with a
proper schema and migrate the serve* function to a strict handler.
2026-07-17 23:02:08 +02:00
8709e01dcb fix(web): eliminate all Svelte 5 runes-mode warnings (R10)
5 warnings → 0:

1. ActivityTimeline.svelte:103 — replaced deprecated <svelte:component
   this={icon}> with direct dynamic component rendering ({@const IconComp
   = icon}<IconComp />). In Svelte 5 runes mode, components are dynamic by
   default; <svelte:component> is unnecessary.

2. DetailSection.svelte:18 — 'let open = (defaultOpen)' captured only
   the initial value. Changed to (false) +  to sync with
   defaultOpen prop changes.

3. EntitySheet.svelte:10 — 'let currentSlug = (slug)' had the same
   issue. Changed to <string|null>(null) +  (the  was
   already there, now the initial value doesn't reference the prop).

4. theme.svelte.ts:23 — 'applyClass(current)' at module level referenced a
    variable, capturing only the initial value. Changed to apply the
   plain storedTheme() result for initialization; setTheme() already calls
   applyClass() on changes.

5. Chat.svelte:326 — unused CSS selector '.prose-chat
   :global(:first-child):is(h1,h2,h3)' replaced with explicit
   :global(> h1:first-child) etc. (the :first-child pseudo wasn't matching
   because the scoped wrapper div is the actual first child).

Build is now warning-free.
2026-07-17 22:58:08 +02:00
c96c795126 test: add unit tests for 6 previously-untested packages (R7)
Added pure unit tests for all packages that had 0% coverage. Where pure
logic was entangled with DB calls, extracted testable helpers first.

internal/domain (0% -> 100%):
- TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors,
  TestSignalTransitionsComplete

internal/learning (0% -> 26.2%):
- Refactored processGroup to extract 4 pure helpers: countOutcomes,
  computeConfidence, shouldValidate, shouldQuarantine
- TestWilsonLowerBound (monotonicity, edge cases, sample-size cap)
- TestCountOutcomes, TestComputeConfidence, TestShouldValidate,
  TestShouldQuarantine (table-driven)
- Remaining gap: extractPatterns/processGroup DB calls need make test-db

internal/policy (39% -> 50%):
- Extracted determineRoute from ClassifySignal (pure route logic)
- TestDetermineRoute (7 cases covering global/entity kill-switches, approval)
- Remaining gap: ClassifySignal/computeBlastRadius need DB mock

internal/knowledge (0% -> 14.2%):
- TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray
- Documented latent bug: toPGArray doesn't escape " or \\ in tags
- Remaining gap: ingest* functions need make test-db

internal/actuator (0% -> 14.7%):
- TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock)
- TestParseProcedure, TestSetDefaultSSHTimeout
- Circuit breaker full state-machine test (open/close/reset/per-target)
- Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures

internal/scheduler (0% -> 7.3%):
- TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript
- TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback)
- Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB

internal/notifier (0% -> 6.4%):
- TestHashToken, TestGenerateApprovalToken (HMAC re-derivation)
- Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need
  make test-db

All tests pass with -race. domain hits its 60% gate at 100%. The remaining
packages need integration tests (make test-db) and/or httptest-based tests
to reach their coverage gates — tracked as follow-up.
2026-07-17 22:54:44 +02:00
463bdacf5c feat(web): add eslint + prettier + vitest toolchain + web CI job (R8)
Added to web/package.json devDeps: eslint (9, flat config) +
eslint-plugin-svelte + typescript-eslint + globals; prettier +
prettier-plugin-svelte; vitest (jsdom env) + jsdom. New scripts: lint,
lint:fix, format, format:check, test, test:watch.

Configs:
- web/eslint.config.js — flat config, TS + Svelte, browser/node globals,
  no-explicit-any as warn, unused-vars as error (ignores _-prefixed).
- web/.prettierrc.json — single-quote, 100 width, svelte parser override.
- web/.prettierignore — dist/node_modules/build/lockfiles.
- web/vite.config.ts — vitest test block via reference directive, jsdom env,
  globals enabled.

Sample test: web/src/lib/utils.test.ts (6 tests covering relativeTime,
truncateMiddle, debounce — all passing).

CI: new web job in .gitea/workflows/ci.yml (npm ci, check [advisory],
lint [advisory], format:check [advisory], test [gate], build [gate]).
Advisory steps use continue-on-error until the baseline is clean —
matching the existing golangci-lint advisory pattern.

Known baseline surfaced by the new toolchain (pre-existing, not caused
by R8): svelte-check 154 errors (133-file config cascade), eslint 126
errors + 12 warnings (unused vars, @html XSS, unused CSS), prettier 175
unformatted files. Fixing these is a follow-up cleanup.

VERSION 0.7.9 -> 0.7.10. Plan R8 marked done; C.1 updated.
2026-07-17 22:49:27 +02:00
fb39a48bef refactor: split phase3.go + extract MCP tool registry (R4)
internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into
15 per-resource files:
- actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget,
  executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate)
- checks.go, classifications.go, executions.go, approvals.go, patterns.go,
  skills.go, approval_rules.go, autonomy.go, risk_classes.go,
  relationships.go, entity_types.go, metrics.go, agent_activity.go,
  helpers.go — one file per resource domain, each with its own imports.

internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations)
refactored to a registry pattern:
- internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33
  tool definitions. Handler logic moved verbatim — no changes to tool names,
  descriptions, schemas, or behavior.
- server.go: newServer is now 9 lines (iterate registry, AddTool each).
  -699 lines.

No function logic, names, or signatures changed. go vet, build, and all
tests pass (httpapi, mcp, db, policy).
2026-07-17 22:41:40 +02:00
a2410cf9c2 docs(R5): rewrite knowledge schema + llm-wiki for DB-native model; deprecate root inventory.yaml
Rewrote .agents/domains/knowledge/schema.md and .agents/shared/llm-wiki.md
which described the deleted Python substrate (bin/homelab, oikos/cards/,
oikos/ledger.py, root inventory.yaml, knowledge/sources/, get_page/
search_docs MCP tools). Now reflect ADR 0003: Postgres DB is the single
source of truth for structured data and narrative knowledge; seeds/*.yaml
are bootstrap+DR manifests (content-hashed via seed_versions); archive/
knowledge/ is the frozen legacy wiki; MCP search_knowledge/get_entity_
knowledge replace get_page/search_docs.

Swept substrate refs in .agents/shared/{writing-style,page-templates}.md
and .agents/domains/operations/schema.md: bare inventory.yaml ->
seeds/inventory.yaml; knowledge/sources/ -> archive/knowledge/sources/
(historical); get_changelog/oikos/ledger.py -> DB audit trail / structured
document changelog field; HERMES -> Nomos.

Root inventory.yaml (618-line Python-era file superseded 2026-07-07 by
seeds/inventory.yaml) replaced with a deprecation stub pointing to the seed
and DB. Kept as a stub rather than deleted because AGENTS.md §1/§2 still
point clients at /opt/homelab-context/inventory.yaml; full on-client path
reconciliation deferred to R13.

Flagged export gap: oikos export regenerates seeds/{ontology,inventory,
policy}.yaml but NOT seeds/knowledge.yaml — API-added knowledge lives only
in the DB until hand-edited into the seed.

VERSION 0.7.7 -> 0.7.8. Plan R5 marked done.
2026-07-17 22:36:41 +02:00
d2950dd09d refactor: sqlc vs raw SQL — hybrid approach (R3)
Deleted 8 genuinely unused sqlc queries (no inline equivalent):
- UpsertCurrentRelationship, ListEntitiesCapped, ListEntityStatus,
  UpdateSignalState, InsertClassification, InsertFeedback, InsertSkill,
  UpsertCurrentRelationship — all had zero call sites.

Migrated 9 inline raw SQL sites to use sqlc queries:
- GetOntology (impl.go): ListEntityTypes, ListRelationshipTypes,
  ListLifecycleDefs — replaces 3 raw pool.Query blocks with typed sqlcgen
  calls, eliminating manual row scanning.
- EndRelationship (phase3.go): EndCurrentRelationship — replaces tx.Exec
  with sqlcgen.New(tx).EndCurrentRelationship.
- checkPrecondition (impl.go): GetEntityStatus — replaces tx.QueryRow +
  manual Scan with sqlcgen.New(tx).GetEntityStatus.
- GetEntityRelations (impl.go): ListEntityRelations — replaces raw pool.Query
  + scanRelationships helper (now deleted).
- GetGraph (impl.go): ListGraphEdges — replaces raw pool.Query +
  scanRelationships.
- resolveEntityID (impl.go): GetEntityBySlug/GetEntityByID — replaces
  raw pool.QueryRow + Scan.
- createApproval (mcp/server.go): InsertApproval — replaces raw pool.Exec
  with sqlcgen.InsertApproval.

Deleted scanRelationships helper (was only used by the two migrated
graph queries above).

Regenerated sqlcgen — also picks up stale model updates (AgentSession,
SessionPlanStep, SessionQuestion, etc. from recent migrations).

Documented the carve-out in .agents/dev/CONTRIBUTING.md §SQL conventions:
sqlc is the default; raw pool.Query/Exec is reserved for LISTEN/NOTIFY,
dynamic WHERE builders, blast_radius(), and COPY.

go vet, build, httpapi/mcp/db tests all pass. -383/+170 lines.
2026-07-17 22:24:23 +02:00
0a3654b08f refactor(web): delete dead code (R2) — 1736 lines removed
Tool-renderer registry (21 files, ~1.5k lines):
- src/lib/tool-renderers.ts — registry + getToolRenderer (exported, never
  imported anywhere)
- src/lib/renderers/index.ts + 10 .ts registrars + 10 .svelte components
- main.ts: removed the requestAnimationFrame(() => import('./lib/renderers'))
  that was the only thing keeping the dead subsystem alive

Dead components (never imported):
- ToolCallGroup, PlanProgress, GoalHeader, InlineApproval, SessionDigest

Dead store exports (written, never read):
- context.ts: pendingApprovals writable (+ Approval type import)
- events.ts: connectionState writable (+ its .set() calls)

Dead API surface:
- api.ts: SessionDigest interface + fetchSessionDigest (only caller was the
  dead SessionDigest.svelte)

Dead npm deps:
- mode-watcher (0 imports; superseded by stores/theme.svelte.ts)
- @internationalized/date (0 imports)

Also: fix stale comments referencing deleted symbols, update plan R1/R2
status. Build clean (4683 modules, down from 4706; one Svelte 5 warning
gone — the dead HealthSummary.svelte was emitting state_referenced_locally).
2026-07-17 22:10:27 +02:00
c3973e7ac9 refactor: delete dead Go code (R1)
- internal/httpapi/stubs.go: delete — 5-line comment-only orphan file with
  no declarations; its own comment said the stubs live in phase3.go.
- internal/notifier/notifier.go: delete VerifyApprovalToken — zero call
  sites; phase3.go:DecideApproval reimplements the check inline (noted as
  dead in docs/mbse). hashToken stays (used by generateApprovalToken).
- internal/checkdefaults/defaults.go: unexport ResolveHost, ForEntityType,
  ShortSlug, DefaultInterval — only called within the package. Ensure stays
  exported (called by internal/db/seed.go).

go vet, go build, and affected tests pass.
2026-07-17 22:06:46 +02:00
e3a0326c78 docs: codebase review + documentation maintenance pass
Full review (plans/2026-07-17-codebase-review-and-cleanup.md) covering Go,
web SPA, and docs. Applied low-risk doc/tooling fixes; code refactors and
dead-code deletions are listed as actionable recommendations pending approval.

Doc fixes:
- AGENTS.md: remove ghost of retired request_execution (contradicted the
  retire notice above it); fix knowledge/wiki/ -> archive/knowledge/;
  replace brittle counts (33 tools, 36 docs, 20 checks) with pointers to
  source; drop point-in-time dates.
- OIKOS.md: fix broken plan link (now in done/); 001-011 -> 001-020;
  15 MCP tools -> pointer; replace hardcoded knowledge counts.
- README.md: 15 tools -> pointer; fix wails plan link (now in done/);
  complete internal/ package list (add checkdefaults, observability, safego);
  add cmd/desktop/ to repo layout.
- commands.md, page-templates.md: fix broken links; HERMES.md -> NOMOS.md.

Plans housekeeping:
- Move 4 done 2026-07-14 plans from plans/ to plans/done/.
- Reconcile plans/index.md: add the 2 missing 2026-07-14 entries and the
  2 missing 2026-07-15 done entries; add this review.
- Fix stale plan path in migrations/020 comment.

New docs:
- docs/index.md and docs/operations/README.md (folder READMEs per
  writing-style.md).

Tooling:
- web/package.json: add check/typecheck/lint scripts + svelte-check devDep.
- Makefile: desktop-package version now reads from VERSION file instead of
  hardcoded 0.1.0.

VERSION 0.7.6 -> 0.7.7 (patch: docs + tooling only).
2026-07-17 22:04:54 +02:00
116 changed files with 10049 additions and 6376 deletions

View File

@@ -13,13 +13,13 @@ service itself.
## Source of truth ## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of The homelab-context repo at `/opt/homelab/` is the single source of
truth for: truth for:
- Fleet topology (`inventory.yaml`) - Fleet topology (`inventory.yaml`)
- Agent behaviour and conventions - Agent behaviour and conventions
- Everything in this file - Everything in this file
When in doubt, check `/opt/homelab-context/` first, or query the Oikos API/MCP When in doubt, check `/opt/homelab/` first, or query the Oikos API/MCP
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
authoritative at runtime. authoritative at runtime.

View File

@@ -1,8 +1,8 @@
# Oikos — the operating model # Oikos — the operating model
Oikos (Greek: *household*) is the agent operating system layered on this Oikos (Greek: *household*) is the agent operating system layered on this
repo. It is not new infrastructure: `inventory.yaml` is the kernel data repo. It is not new infrastructure: `seeds/inventory.yaml` is the kernel data
structure, the `homelab` CLI and MCP server are the syscall surface, and structure, the Oikos REST API and MCP server are the syscall surface, and
this page defines the rules everything above them follows. this page defines the rules everything above them follows.
Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions: Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions:
@@ -100,16 +100,16 @@ via the API's `/api/v1/graph` endpoint, and the Mermaid export at
The Oikos runtime was rewritten from Python to Go over 6 phases and is deployed The Oikos runtime was rewritten from Python to Go over 6 phases and is deployed
in Docker on mac-mini. See in Docker on mac-mini. See
[plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](../plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) [plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](../plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md)
for the full plan. The Python codebase has been removed; all functionality runs for the full plan. The Python codebase has been removed; all functionality runs
in the Go binary. in the Go binary.
**Phase 1 — Ontology + DB (DONE):** **Phase 1 — Ontology + DB (DONE):**
- `migrations/` (001011): TimescaleDB hypertables, entity_status, CAGGs, - `migrations/` (001020, forward-only): TimescaleDB hypertables, entity_status, CAGGs,
retention policies, knowledge entities with FTS. Forward-only, idempotent. retention policies, knowledge entities with FTS. Idempotent.
- `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap + - `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap +
DR export. Knowledge seed contains 36 documents, 6 investigations, and 12 DR export. Knowledge seed contents are not hardcoded here — count them
runbooks. from the seed or query the DB.
- `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship - `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship
validation. validation.
- Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`, - Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`,
@@ -139,8 +139,8 @@ in the Go binary.
**Phase 4 — Agent / Nomos (DONE):** **Phase 4 — Agent / Nomos (DONE):**
- Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode - Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode
(:8092). Structured queries + natural-language routing to 15 MCP tools. (:8092). Structured queries + natural-language routing to the MCP tool
Agent activity logging on every tool call. No SSH keys. list (see AGENTS.md §3). Agent activity logging on every tool call. No SSH keys.
- `nomos/` directory with config, SOUL.md, homelab-ops skill. - `nomos/` directory with config, SOUL.md, homelab-ops skill.
- Nomos Docker service in `docker-compose.yml` (profile: full). - Nomos Docker service in `docker-compose.yml` (profile: full).
- Go packages: `cmd/nomos/`, `compose/nomos/`. - Go packages: `cmd/nomos/`, `compose/nomos/`.

View File

@@ -94,7 +94,12 @@ current phase status). To add a new capability:
## SQL conventions ## SQL conventions
- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec` - Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec`
annotations for sqlc annotations for sqlc. Generated code in `internal/db/sqlcgen/` — never
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
- **sqlc is the default** for all DB access. Raw `pool.Query/Exec` with inline
SQL is a documented carve-out for cases sqlc can't express: `LISTEN`/`NOTIFY`,
dynamic WHERE-clause builders, `blast_radius()` (opaque return type), and
`COPY`. All other DB access should go through sqlc queries.
- Use `pgx/v5` driver. UUIDs use `pgtype.UUID`, timestamps use `time.Time` - Use `pgx/v5` driver. UUIDs use `pgtype.UUID`, timestamps use `time.Time`
- CTEs for graph traversals (blast radius, dependency chains) - CTEs for graph traversals (blast radius, dependency chains)
- CAGGs and retention policies for TimescaleDB hypertables - CAGGs and retention policies for TimescaleDB hypertables
@@ -108,6 +113,23 @@ current phase status). To add a new capability:
implement it in `internal/httpapi/impl.go` implement it in `internal/httpapi/impl.go`
- Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format - Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format
- Cursor pagination, If-Match/ETag, idempotency keys, SSE streaming - Cursor pagination, If-Match/ETag, idempotency keys, SSE streaming
- **Non-OpenAPI routes carve-out:** ~10 routes are registered manually on
the chi router in `internal/httpapi/server.go` rather than generated from
`openapi.yaml`. These fall into three categories:
1. **Auth/infra** (`/healthz`, `/api/v1/auth/oidc-*`, `/oidc-callback`) —
must bypass the auth middleware or aren't JSON API endpoints.
2. **SSE override** (`/api/v1/events/stream`) — in the spec but
re-registered manually because the strict handler can't `Flush()` per
event.
3. **Ad-hoc aggregations** (`/api/v1/knowledge/recent`,
`/api/v1/knowledge/content/{id}`, `/api/v1/activity/recent`,
`/api/v1/activity/session/{id}`, `/api/v1/learning/timeline`,
`/api/v1/learning/trend`) — return derived/aggregate shapes that don't
map cleanly to a schema type. If one of these stabilizes, promote it
to `openapi.yaml` with a proper schema and migrate the `serve*`
function to a strict handler.
The full list with reasons is in the "Non-OpenAPI routes" comment block
at the top of `NewHandler` in `server.go`.
## Testing philosophy ## Testing philosophy

View File

@@ -1,48 +1,93 @@
# Knowledge domain — schema # Knowledge domain — schema
The knowledge domain is the durable, authoritative current-state documentation of the homelab: one The knowledge domain is the durable, authoritative current-state documentation of the homelab:
page per node and per cross-cutting system, synthesized from live state and evidence. It answers narrative for every node and cross-cutting system, synthesized from live state and evidence. It
"what exists and how does it work right now." answers "what exists and how does it work right now."
It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the
[writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md) [writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md)
rules. rules.
## The narrative / substrate split ## Source of truth — the database
The knowledge wiki is **narrative**. It sits alongside a **machine-readable substrate** that it Per ADR 0003, the Postgres database is the single source of truth for all structured data **and**
describes but never contains. The split is load-bearing: several programs read the substrate at narrative knowledge. The narrative/substrate split of the Python era is gone: the DB holds both the
fixed paths, so the wiki reorganization never moves it. structured graph (entities, relationships, status, metrics) and the narrative layer (documents,
investigations, runbooks) in the `knowledge_entities` table.
| Layer | Location | Consumed by | | Concern | Where it lives | How it gets there |
|-------|----------|-------------| |---------|----------------|-------------------|
| Substrate — source of truth | `inventory.yaml` (root) | MCP server, `homelab` CLI, `oikos/` scheduler/drift/relations/gen-topology | | Knowledge content — documents, investigations, runbooks | `knowledge_entities` table (rows linked to `entities` via `documents` / `about` edges) | Seeded from `seeds/knowledge.yaml` at deploy; mutated at runtime via the API |
| Substrate — generated host records | `inventory.yaml` (root) | Go `internal/mcp/` server, `bin/homelab`; the single source of truth | | Seed manifest (bootstrap + DR) | `seeds/knowledge.yaml` | Hand-edited or regenerated; ingested idempotently (content-hashed via `seed_versions`) |
| Substrate — kernel + context cards | `oikos/` (code, `oikos/cards/`, `oikos/state.json`) | MCP `explain`, scheduler | | Structured graph — hosts, services, entity types, relationships | `entities`, `relationships`, `entity_types` tables | Seeded from `seeds/{ontology,inventory}.yaml`; mutated via API/MCP |
| Narrative — synthesized wiki | `archive/knowledge/{hosts,containers,vms,infrastructure}/` | humans, agents via MCP `get_page` / `search_docs` | | Archived narrative wiki (read-only history) | `archive/knowledge/` | Frozen 2026-07-07 when the DB became source of truth |
| Evidence — immutable sources | `knowledge/sources/` (references + investigations) | synthesis into wiki pages |
## Wiki pages ### Seed ingest
- **Node pages** (`archive/knowledge/containers/<id>-<name>.md`, `.../vms/<id>-<name>.md`, `seeds/knowledge.yaml` has three top-level lists — `documents`, `investigations`, `runbooks` — each
`.../hosts/<name>.md`) follow the container/host template in entry carrying `slug`, `title`, `content` (markdown), and tags. `internal/knowledge/seed.go`
[page-templates.md](../../shared/page-templates.md): opening definition, `## At a glance`, ingests each entry by:
`## Role`, service/port map, storage, auto-deploy, `## Related`, `## Changelog`.
- **Cross-cutting pages** (`archive/knowledge/infrastructure/<topic>.md`) follow the cross-cutting 1. `getOrCreateEntity` — ensures the slug exists in `entities` (type `document` / `investigation` /
template: `## Why`, `## Components`, `## How to apply`, `## Gotchas`, `## Related`, `## Changelog`. `runbook`).
- Each `inventory.yaml` host entry carries a `doc_page:` field pointing at its narrative page. 2. `upsertKnowledgeEntity` — writes the markdown body into `knowledge_entities`, keyed by
Changing where a page lives means updating that field (read by `bin/homelab`). `content_hash` so re-ingest is a no-op when nothing changed.
3. `createEdge` — links the knowledge entity to its subject(s) via `documents` (for `document`) or
`about` (for `investigation`) edges. Runbooks bind to an `entity_type` via `applies_to_type`
rather than to a single entity.
### Runtime mutation
Agents register or update knowledge through the API, not by editing the seed:
- `POST /api/v1/knowledge/{entity_slug}` — upsert a document/investigation on an entity
(`upsert_knowledge` MCP tool).
- `update_entity_attributes` — merge a discovered fact (IP, version, port) into an entity.
- `create_relationship` — record a discovered edge (`depends-on`, `hosts`, `routes-to`).
> **Export gap.** `oikos export` regenerates `seeds/{ontology,inventory,policy}.yaml` from the DB
> for version control, but **not** `seeds/knowledge.yaml`. Knowledge added via the API today lives
> only in the DB until someone hand-edits the seed. Tracked as a follow-up.
## Knowledge kinds
- **Documents** (`document` entities, linked via `documents` edges) — node and cross-cutting
narrative pages. Carry `at_glance` (structured attributes) and a parsed `changelog`. Follow the
container / cross-cutting templates in [page-templates.md](../../shared/page-templates.md).
- **Investigations** (`investigation` entities, linked via `about` edges) — incident evidence,
written once at incident time. Sections: `## Summary`, `## Timeline`, `## Root cause`,
`## Mitigations applied`, `## Open questions`.
- **Runbooks** (`runbook` entities, bound by `applies_to_type`) — repeatable procedures. Carry
`risk_class` and a JSON-schema-validated `procedure`. **Runbooks also live as `SKILL.md` files
under `.agents/skills/<name>/`** — the DB row is the policy/lifecycle framing, the SKILL.md is
the executable procedure the agent loads. See
[the operations schema](../operations/schema.md).
## The two logs ## The two logs
- The per-page **`## Changelog`** records infrastructure changes and is machine-parsed - The per-document **`## Changelog`** records infrastructure changes to that node. Keep the
(`get_changelog`, the Oikos ledger). Keep the `### YYYY-MM-DD — title` shape. `### YYYY-MM-DD — title` shape so the parsed `changelog` field stays structured.
- **`knowledge/log.md`** is append-only and records *documentation-maintenance* operations only - **`archive/knowledge/log.md`** is the append-only record of *documentation-maintenance*
(restructures, source ingests, lint sweeps): `## [YYYY-MM-DD] <op> | <summary>`. It never operations on the legacy wiki (restructures, source ingests, lint sweeps):
duplicates the Oikos change ledger (`oikos/ledger.py`). `## [YYYY-MM-DD] <op> | <summary>`. It is frozen with the rest of `archive/knowledge/`; new
doc-maintenance operations are recorded in the DB audit trail instead.
## Querying knowledge
Use MCP, not grep:
- `search_knowledge(query)` — ILIKE search over documents, investigations, and runbooks in
`knowledge_entities`.
- `get_entity_knowledge(entity_slug)` — every document, investigation, and runbook linked to one
entity, in one call.
- `get_entity(slug)` / `get_relations(entity)` — the structured graph around an entity.
Grep the clone only when MCP is unreachable, and prefer `archive/knowledge/` for historical
narrative (it is not updated when the DB changes).
## Same-session update rule ## Same-session update rule
A change to a node updates every page that references it in the same session — the node page, the A change to a node updates the DB in the same session — the entity's attributes, the relationships
section `README.md` table, the root `README.md`, the Caddy/DNS/ingress pages, the host page, and that reference it, and any document whose `at_glance` or changelog should reflect the new state. See
`inventory.yaml`. See [page-templates.md](../../shared/page-templates.md#same-session-update-rule). [page-templates.md](../../shared/page-templates.md#same-session-update-rule) for the legacy wiki
equivalent (now scoped to `archive/knowledge/` history).

View File

@@ -6,9 +6,10 @@ follows [writing-style](../../shared/writing-style.md); runbooks and plans use t
exception. exception.
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
reference (command cheatsheet, enrollment, Hermes agent) lives in reference (command cheatsheet, enrollment, Nomos agent) lives in
[`.agents/operations/`](../../operations/); investigations are sources under [`.agents/operations/`](../../operations/); investigations are `investigation` entities in the DB
`knowledge/sources/investigations/`; plans stay in the repo-root `plans/` folder (below). (historically `archive/knowledge/sources/investigations/`); plans stay in the repo-root `plans/`
folder (below).
## Plans always live in `plans/` ## Plans always live in `plans/`
@@ -21,7 +22,7 @@ message.** An agent drafting a plan:
3. On completion, moves it to `plans/done/` and updates the index status. 3. On completion, moves it to `plans/done/` and updates the index status.
This is the single source for homelab design intent; keeping it in-repo means the plan is This is the single source for homelab design intent; keeping it in-repo means the plan is
versioned, reviewable, and reachable by MCP `get_page`/`search_docs` like any other doc. versioned, reviewable, and reachable by MCP `search_knowledge` like any other doc.
## Runbooks ## Runbooks
@@ -44,12 +45,14 @@ transition: "<from> -> <to>" # only for lifecycle runbooks
## Investigations ## Investigations
Incident records live in `knowledge/sources/investigations/YYYY-MM-DD-slug.md` and are **evidence sources** — written Incident records are `investigation` entities in the DB, linked to the entities they implicate via
once at incident time, then linked from the changelogs of the nodes they implicate. Sections: `about` edges. They are **evidence sources** — written once at incident time, then back-linked from
`## Summary`, `## Timeline`, `## Root cause`, `## Mitigations applied`, `## Open questions`. Resolved the changelogs of the nodes they implicate. Sections: `## Summary`, `## Timeline`, `## Root cause`,
incidents move to `knowledge/sources/investigations/archive/`. `## Mitigations applied`, `## Open questions`. The legacy file-based investigations live at
`archive/knowledge/sources/investigations/` (frozen 2026-07-07); new investigations go in the DB.
## The operations log ## The operations log
`plans/log.md` and `knowledge/log.md` are append-only records of documentation operations on `plans/log.md` is the append-only record of documentation operations on plans
those areas (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the Oikos change ledger. (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the DB audit trail. The legacy
`archive/knowledge/log.md` is frozen with the rest of the archived wiki.

View File

@@ -49,7 +49,7 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
- `ras-mc-ctl --errors` — full event log - `ras-mc-ctl --errors` — full event log
- `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power` - `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power`
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave` - `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../archive/knowledge/investigations/archive/2026-04-21-hubris-crash-loop.md)) - `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../archive/knowledge/investigations/2026-04-21-hubris-crash-loop.md))
## Fleet apt operations ## Fleet apt operations
@@ -67,7 +67,7 @@ the dpkg-interrupted recovery procedure specifically.
See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
section used to document is retired; the actual current interface is the section used to document is retired; the actual current interface is the
33 MCP tools cataloged in [AGENTS.md](../../AGENTS.md#3-the-mcp-server) plus MCP tool catalog in [AGENTS.md §3](../../AGENTS.md#3-the-mcp-server) plus
the REST API. Closest current equivalents for what used to live here: the REST API. Closest current equivalents for what used to live here:
| Old `homelab` command | Current equivalent | | Old `homelab` command | Current equivalent |
@@ -82,7 +82,7 @@ the REST API. Closest current equivalents for what used to live here:
There is no separately-deployed "Oikos Console" anymore — the control-room There is no separately-deployed "Oikos Console" anymore — the control-room
SPA (`web/`) is the operator dashboard, served standalone (see SPA (`web/`) is the operator dashboard, served standalone (see
[plans/2026-07-12-wails-desktop-app.md](../../plans/2026-07-12-wails-desktop-app.md)). [plans/done/2026-07-12-wails-desktop-app.md](../../plans/done/2026-07-12-wails-desktop-app.md)).
## Related ## Related
- [Hubris host](../../archive/knowledge/hosts/hubris.md) - [Hubris host](../../archive/knowledge/hosts/hubris.md)

View File

@@ -1,40 +1,49 @@
# LLM Wiki — the documentation contract # LLM Wiki — the documentation contract
How the narrative documentation in this repo is organized. The pattern is borrowed from the How documentation in this repo is organized. The pattern is the `sources / wiki / index / log`
`sources / wiki / index / log` model: a durable synthesized layer (`archive/knowledge/`) built on top model: a durable synthesized layer built on top of immutable evidence, with pure-listing indexes and
of immutable evidence (`knowledge/sources/`, incident records), with pure-listing indexes and an an append-only operations log.
append-only operations log.
This contract governs the **narrative layer only**. The machine-readable substrate — `inventory.yaml`, This contract governs the **narrative layer only**. The machine-readable source of truth — the
`secrets/`, `scripts/`, `bin/` — is not part of the wiki and never Postgres database, bootstrapped from `seeds/` — is not part of the wiki and never moves under it.
moves under it. See [the knowledge schema](../domains/knowledge/schema.md) for the split. See [the knowledge schema](../domains/knowledge/schema.md) for the split, and ADR 0003 for the
DB-native model.
## Layers ## Layers
- **Sources** are immutable raw material: incident records (`knowledge/sources/investigations/`), external reference - **Source of truth** is the Postgres database. Structured data (entities, relationships, status,
docs (`knowledge/sources/references/`), and the live system itself (`pct config`, `docker inspect`). metrics) and narrative knowledge (documents, investigations, runbooks) both live there, in the
Read them; do not rewrite them into other sources. `entities` / `relationships` / `knowledge_entities` tables. It is bootstrapped at deploy time from
- **Wiki** (`archive/knowledge/`) is the synthesized, authoritative current-state layer: one page per `seeds/{ontology,inventory,policy,knowledge}.yaml` (idempotent, content-hashed via
node (`containers/`, `vms/`, host narratives) and per cross-cutting system (`infrastructure/`). A `seed_versions`) and mutated at runtime via the API/MCP. `oikos export` regenerates
reader understands the topic from the wiki page without reading the sources. `seeds/{ontology,inventory,policy}.yaml` for version control.
- **Sources** are immutable raw material: incident records (now `investigation` entities in the DB,
historically `archive/knowledge/sources/investigations/`), external reference docs, and the live
system itself (`pct config`, `docker inspect`). Read them; do not rewrite them into other sources.
- **Wiki** — the synthesized, authoritative current-state layer. Today this is the set of
`document` entities in the DB (one per node and per cross-cutting system), queried via MCP
`search_knowledge` / `get_entity_knowledge`. The legacy file-based wiki is frozen at
`archive/knowledge/{hosts,containers,vms,infrastructure}/` for historical reference only.
- **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a - **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a
one-line summary, and nothing else. Anything the section wants to say up front goes into a page one-line summary, and nothing else. Anything the section wants to say up front goes into a page
the index lists, not into the index. the index lists, not into the index.
- **Log** (`log.md`) is append-only, recording *doc-maintenance operations* (restructures, source - **Log** is append-only, recording *doc-maintenance operations* (restructures, source ingests,
ingests, lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`. lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`. The active log is the DB
audit trail; `archive/knowledge/log.md` is the frozen legacy equivalent.
## Two logs, kept distinct ## Two logs, kept distinct
- **`## Changelog`** on each node/topic page records *infrastructure* changes to that node. It is - **`## Changelog`** on each node/topic document records *infrastructure* changes to that node. It
machine-parsed (`get_changelog`, the Oikos ledger) — keep the `### YYYY-MM-DD — title` shape. is stored as a structured field on the `document` entity — keep the `### YYYY-MM-DD — title`
- **`log.md`** per area records *documentation* operations only. It never duplicates the Oikos shape so it parses cleanly.
change ledger (`oikos/ledger.py`), which stays authoritative for infra changes with - **Doc-maintenance logs** record *documentation* operations only. They never duplicate the
who/what/risk/approval/verification. infrastructure changelog, which stays authoritative for infra changes with
who/what/risk/approval/verification (now the DB audit trail, formerly `oikos/ledger.py`).
## Rules ## Rules
- Wiki pages stay short and focused. A page past ~300 lines splits. - Wiki pages stay short and focused. A page past ~300 lines splits.
- Pages stay flat under `wiki/<section>/` until there are enough to warrant a sub-group. - Pages stay flat under their section until there are enough to warrant a sub-group.
- Every page follows [writing-style.md](writing-style.md). - Every page follows [writing-style.md](writing-style.md).
- Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`), - Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`),
listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat

View File

@@ -9,12 +9,12 @@ in [writing-style.md](writing-style.md); the layer model (sources / wiki / index
**Foundational / entry-point files:** ALL-CAPS **Foundational / entry-point files:** ALL-CAPS
- **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans. - **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans.
- **Agent instruction** (under `.agents/`): `OIKOS.md`, `HERMES.md` — foundational docs agents read before acting. - **Agent instruction** (under `.agents/`): `OIKOS.md`, `NOMOS.md` — foundational docs agents read before acting.
- **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY). - **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY).
**Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed **Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed
- **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from `inventory.yaml`. - **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from the entity's attributes in the DB (seeded via `seeds/inventory.yaml`).
- **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node. - **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node.
- **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase. - **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase.
- **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist. - **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist.
@@ -118,7 +118,7 @@ What it looks like after.
Changelog entries to write, index status to update. Changelog entries to write, index status to update.
``` ```
### Investigation (`knowledge/sources/investigations/YYYY-MM-DD-slug.md`) ### Investigation (`investigation` entity in the DB; historically `archive/knowledge/sources/investigations/YYYY-MM-DD-slug.md`)
```markdown ```markdown
# YYYY-MM-DD — <title> # YYYY-MM-DD — <title>
@@ -152,16 +152,18 @@ Changelog entries to write, index status to update.
## Same-session update rule ## Same-session update rule
When you make a change to a node — migrate an LXC, update an IP, change a When you make a change to a node — migrate an LXC, update an IP, change a
mount, deploy a new service — **update every relevant doc page in the same mount, deploy a new service — **update the DB and every relevant doc page in
session.** A change that touches a container page must also update: the same session.** A change that touches a container must also update:
- The `containers/index.md` table (IPs, host, mounts, status) - The `entities` / `relationships` rows for the node (via the API/MCP) —
this is the source of truth
- The `document` entity's `at_glance` and `## Changelog` for the container
- The `containers/index.md` table in the archived wiki (IPs, host, mounts,
status) — historical reference, update for consistency where still consulted
- The `README.md` table (if the change affects listed columns) - The `README.md` table (if the change affects listed columns)
- The Caddy page site list (if the change affects `*.hubris.network` routing) - The Caddy page site list (if the change affects `*.hubris.network` routing)
- The DNS / ingress infrastructure pages (if the change affects routing) - The DNS / ingress infrastructure pages (if the change affects routing)
- The `hosts/{hubris,strong}.md` host page (if container count changes) - The `hosts/{hubris,strong}.md` host page (if container count changes)
- The `inventory.yaml` host entry (single source of truth)
- The `infrastructure/topology.md` (generated from inventory, but regen if needed)
The pattern of updating only one page and leaving stale references on others The pattern of updating only one page and leaving stale references on others
is a bug. If you're doing a multi-step migration, document the intermediate is a bug. If you're doing a multi-step migration, document the intermediate

View File

@@ -36,7 +36,7 @@ Every doc-level page follows the same shape so a reader scans it in one pass.
1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`. 1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`.
2. **Opening definition.** First paragraph, 13 sentences, says what the thing is. No motivation, no marketing, no setup. 2. **Opening definition.** First paragraph, 13 sentences, says what the thing is. No motivation, no marketing, no setup.
3. **Body sections** in the natural order for the topic. Reuse the section templates in [page-templates.md](page-templates.md). 3. **Body sections** in the natural order for the topic. Reuse the section templates in [page-templates.md](page-templates.md).
4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is machine-parsed (Go MCP `get_changelog` in `internal/mcp/server.go`); keep the `### YYYY-MM-DD — title` shape. 4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is stored as a structured field on the `document` entity in the DB; keep the `### YYYY-MM-DD — title` shape so it parses cleanly.
5. **Related links** only at the bottom, only when a reference cannot be woven inline. 5. **Related links** only at the bottom, only when a reference cannot be woven inline.
## Section indexes (folder READMEs) ## Section indexes (folder READMEs)
@@ -53,12 +53,12 @@ duplicated prose, no narrative between the intro and the table.
- Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists. - Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists.
- Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation. - Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation.
- When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph. - When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph.
- Use backticks for code, paths, hostnames, and file names (`inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology. - Use backticks for code, paths, hostnames, and file names (`seeds/inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology.
- Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote. - Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote.
## Diagrams ## Diagrams
- Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` is generated by `oikos/gen-topology.py` — do not hand-edit it. (Go DB-native topology generation planned.) - Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` in the archived wiki was generated by the retired `oikos/gen-topology.py`; the DB-native equivalent is a future task — do not hand-edit the archived file expecting it to regenerate.
- ASCII box diagrams are fine for small shape diagrams; keep them to one screen. - ASCII box diagrams are fine for small shape diagrams; keep them to one screen.
## Sourcing and cross-references ## Sourcing and cross-references

View File

@@ -70,3 +70,30 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: docker build (verify image builds; no push) - name: docker build (verify image builds; no push)
run: docker build -f compose/oikos/Dockerfile -t oikos:ci . run: docker build -f compose/oikos/Dockerfile -t oikos:ci .
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- name: svelte-check (advisory — baseline not yet clean)
run: npm run check
continue-on-error: true
- name: eslint (advisory — baseline not yet clean)
run: npm run lint
continue-on-error: true
- name: prettier format check (advisory — baseline not yet clean)
run: npm run format:check
continue-on-error: true
- name: test
run: npm run test
- name: build
run: npm run build

41
.golangci.yml Normal file
View File

@@ -0,0 +1,41 @@
# golangci-lint configuration for Oikos
# Docs: https://golangci-lint.run/usage/configuration/
run:
timeout: 5m
tests: true
linters:
enable:
- govet # go vet
- staticcheck # advanced static analysis
- ineffassign # detect ineffectual assignments
- unused # find unused identifiers
- errcheck # check for unchecked errors
- gosimple # simplifications
- typecheck # standard type checking
- misspell # find commonly misspelled English words in comments
- revive # fast, configurable linter (replaces golint)
linters-settings:
errcheck:
# Allow unchecked errors on common Close/Flush patterns (deferred cleanup)
exclude-functions:
- (io.Closer).Close
- (*os.File).Close
issues:
# Exclude generated code
exclude-rules:
- path: _test\.go
linters:
- errcheck
- path: internal/httpapi/gen/
linters:
- all
- path: internal/db/sqlcgen/
linters:
- all
# Don't auto-exclude common patterns
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,7 +1,7 @@
# AGENTS.md — orientation for any agent on a homelab client # AGENTS.md — orientation for any agent on a homelab client
You are running on a machine that is part of the **hubris** homelab. The full You are running on a machine that is part of the **hubris** homelab. The full
context is in this checkout at `/opt/homelab-context/`. This file is the entry context is in this checkout at `/opt/homelab/`. This file is the entry
point. Read it once at start, then keep working. point. Read it once at start, then keep working.
- **New client?** Read [CLIENTS.md](CLIENTS.md) first. - **New client?** Read [CLIENTS.md](CLIENTS.md) first.
@@ -30,7 +30,7 @@ is archived at `archive/knowledge/` for historical reference.
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read: Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
/opt/homelab-context/inventory.yaml /opt/homelab/inventory.yaml
That file tells you your role, your peers, what's mounted, and what services That file tells you your role, your peers, what's mounted, and what services
you host. If it does not exist, this client was not enrolled — stop and tell you host. If it does not exist, this client was not enrolled — stop and tell
@@ -39,12 +39,13 @@ the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
## 2. The topology ## 2. The topology
- `/opt/homelab-context/inventory.yaml` — every host, LXC, VM, and workstation - `/opt/homelab/inventory.yaml` — every host, LXC, VM, and workstation
with their mesh addresses, roles, and service mappings. This is the seed file; with their mesh addresses, roles, and service mappings. This is the seed file;
at runtime the DB is authoritative (query via MCP `get_entity` or the REST API). at runtime the DB is authoritative (query via MCP `get_entity` or the REST API).
- `/opt/homelab-context/seeds/knowledge.yaml` — full narrative knowledge: 36 - `/opt/homelab/seeds/knowledge.yaml` — full narrative knowledge
documents, 6 investigations, 12 runbooks. Ingested into the DB on deploy. (documents, investigations, runbooks). Counts are not hardcoded here; count
- `/opt/homelab-context/.agents/operations/commands.md` — the operator's cheatsheet them from the seed or query the DB. Ingested into the DB on deploy.
- `/opt/homelab/.agents/operations/commands.md` — the operator's cheatsheet
for pct, caddy, DNS, and the Oikos command surface. for pct, caddy, DNS, and the Oikos command surface.
## 3. The MCP server ## 3. The MCP server
@@ -55,7 +56,8 @@ Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
enrollment and `/healthz` (see "Authentication" below for where the token enrollment and `/healthz` (see "Authentication" below for where the token
comes from). comes from).
Available tools (33 total): Available tools (the authoritative list — count them below if a number is
needed; do not hardcode the count elsewhere):
Context — observe + orient: Context — observe + orient:
get_entity(slug), list_entities(type, limit, cursor), get_entity(slug), list_entities(type, limit, cursor),
@@ -111,10 +113,9 @@ Available tools (33 total):
operator approval, and destructive patterns (rm -rf, dd, mkfs, operator approval, and destructive patterns (rm -rf, dd, mkfs,
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
need approval regardless of what you declare. This is the ONLY need approval regardless of what you declare. This is the ONLY
mutation tool — `request_execution` was retired 2026-07-14. mutation tool — `request_execution` was retired 2026-07-14; the
`run` — the general execution primitive. Run any shell former enum actions (restart, systemctl, pct_exec, apt_upgrade,
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the pct_create) are all expressed as `run(target, command)` now.
route for those specific actions; policy-gated the same way `run` is.
get_execution_status(execution_id) — poll progress get_execution_status(execution_id) — poll progress
**When to prefer MCP over grepping the clone:** always for knowledge queries. **When to prefer MCP over grepping the clone:** always for knowledge queries.
@@ -145,8 +146,8 @@ POST /api/v1/knowledge/{entity_slug}
{"title": "...", "content": "...", "tags": ["..."]} {"title": "...", "content": "...", "tags": ["..."]}
``` ```
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive The DB is the truth. The old wiki files are archived at `archive/knowledge/`
per the DB-as-source-of-truth plan. (historical reference only — use MCP `search_knowledge` for live queries).
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md - **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure` files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`
@@ -162,8 +163,8 @@ per the DB-as-source-of-truth plan.
## 6. Acting on the homelab ## 6. Acting on the homelab
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary - **Read state**: use MCP tools. Nomos (the AI agent) is the primary
operator interface — it has 33 MCP tools for observe/orient/decide/act operator interface — it routes to the MCP tool list in §3 for
(§3). observe/orient/decide/act.
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls - **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
`run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute `run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
immediately; `config_mutation` and `destructive` actions are queued for immediately; `config_mutation` and `destructive` actions are queued for
@@ -178,7 +179,7 @@ per the DB-as-source-of-truth plan.
## 7. Communication mode ## 7. Communication mode
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's Read and apply `/opt/homelab/.agents/shared/caveman.md` (if present). It defines the lab's
terse-communication standard — drop filler, keep substance, use fragments. terse-communication standard — drop filler, keep substance, use fragments.
## 8. Auto-setup mechanism ## 8. Auto-setup mechanism
@@ -191,16 +192,15 @@ on every client after `git pull`. This is handled by `tools/post-pull.sh`
Currently auto-setup: Currently auto-setup:
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s - **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
health-check scripts to `/opt/oikos/checks` on each host. The scheduler's health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
`ssh-script` check kind depends on these actually being there — 20 are `ssh-script` check kind depends on these actually being there (count is
live in the DB as of 2026-07-12. whatever is currently seeded in the DB — do not hardcode it here).
To add a new auto-setup, create `tools/setup-<name>.sh` in the repo, To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
commit and push. All enrolled clients pick it up within 5 minutes. commit and push. All enrolled clients pick it up within 5 minutes.
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
wait for the 5-min timer. (This mechanism — and the server-side wait for the 5-min timer. (The server-side `tools_changed` detection only
`tools_changed` detection behind it — only correctly recognized correctly recognizes `setup-*.sh` scripts — earlier it silently matched
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
nothing, so nothing auto-ran on any client via this path.) nothing, so nothing auto-ran on any client via this path.)
## 9. Versioning ## 9. Versioning

View File

@@ -51,7 +51,7 @@ The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hu
- Checks Gitea releases every 6 hours - Checks Gitea releases every 6 hours
- System tray → **Check for Updates** triggers an immediate check - System tray → **Check for Updates** triggers an immediate check
- Download, extract, replace the app in `/Applications`, and relaunch - Download, extract, replace the app in `/Applications`, and relaunch
- Versions are compared against the `version` const in `main.go` - Versions are compared against the `version` var in `main.go`, injected from the repo `VERSION` file at link time (`make desktop` passes `-ldflags "-X main.version=$(cat VERSION)"`)
## Project structure ## Project structure

View File

@@ -1,9 +1,10 @@
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install .PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install
BINARY := oikos BINARY := bin/oikos
GO ?= go GO ?= go
build: build:
mkdir -p bin
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos $(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
webhook: webhook:
@@ -19,9 +20,18 @@ test-db:
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \ OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/ $(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/
lint: lint: vet golangci govulncheck
vet:
$(GO) vet ./... $(GO) vet ./...
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
golangci:
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run --config .golangci.yml || echo "golangci-lint not installed — see https://golangci-lint.run/usage/install/"
govulncheck:
@command -v govulncheck >/dev/null 2>&1 && govulncheck ./... || echo "govulncheck not installed — run: go install golang.org/x/vuln/cmd/govulncheck@latest"
.PHONY: lint vet golangci govulncheck
generate: generate:
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \ $(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
@@ -56,7 +66,7 @@ desktop: ui ## Build the Wails desktop app for the current platform
rm -rf cmd/desktop/frontend/dist rm -rf cmd/desktop/frontend/dist
mkdir -p cmd/desktop/frontend/dist mkdir -p cmd/desktop/frontend/dist
cp -r web/dist/* cmd/desktop/frontend/dist/ cp -r web/dist/* cmd/desktop/frontend/dist/
cd cmd/desktop && CGO_ENABLED=1 go build -o build/bin/Oikos . cd cmd/desktop && CGO_ENABLED=1 go build -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos .
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux) desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
@case $$(uname -s) in \ @case $$(uname -s) in \
@@ -67,7 +77,7 @@ desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar
mkdir -p "$$APP/Contents/Resources"; \ mkdir -p "$$APP/Contents/Resources"; \
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \ cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \ cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
sed 's/$$(VERSION)/0.1.0/' cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \ sed "s/\$$(VERSION)/$$(cat VERSION)/" cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \ cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
Linux) \ Linux) \
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \ cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
@@ -81,6 +91,7 @@ install: desktop-package ## Install to /Applications
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
rm -rf bin
rm -rf cmd/desktop/build rm -rf cmd/desktop/build
rm -rf cmd/desktop/frontend/dist rm -rf cmd/desktop/frontend/dist
$(GO) clean -testcache $(GO) clean -testcache

View File

@@ -49,7 +49,7 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
| Component | Port | Role | | Component | Port | Role |
|-----------|------|------| |-----------|------|------|
| `oikos api` | 8090 | REST API + MCP server (15 tools) | | `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) |
| `oikos scheduler` | — | Probe runner, signal lifecycle, metrics | | `oikos scheduler` | — | Probe runner, signal lifecycle, metrics |
| `oikos notifier` | — | Approval tokens, Matrix alerts | | `oikos notifier` | — | Approval tokens, Matrix alerts |
| `nomos serve` | 8092 | MCP client gateway, query routing | | `nomos serve` | 8092 | MCP client gateway, query routing |
@@ -112,8 +112,8 @@ oikos secret migrate # SOPS → Infisical
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
bearer token entered on first launch (see `web/src/pages/Config.svelte`). bearer token entered on first launch (see `web/src/pages/Config.svelte`).
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
output). A native desktop wrapper is planned — see output). A native desktop wrapper exists at `cmd/desktop/` — see
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md). [plans/done/2026-07-12-wails-desktop-app.md](plans/done/2026-07-12-wails-desktop-app.md).
## Repo layout ## Repo layout
@@ -121,9 +121,10 @@ output). A native desktop wrapper is planned — see
cmd/oikos/ Go entry point — single binary cmd/oikos/ Go entry point — single binary
cmd/nomos/ Nomos MCP client gateway cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning, cmd/desktop/ Wails desktop wrapper around the SPA
notifier, policy, secrets, db, config, ontology, domain, internal/ Go packages (actuator, checkdefaults, config, db, domain,
knowledge) httpapi, knowledge, learning, mcp, notifier, observability,
ontology, policy, safego, scheduler, secrets)
web/ Control-room SPA (Svelte 5) — standalone, not embedded web/ Control-room SPA (Svelte 5) — standalone, not embedded
api/openapi.yaml API contract (OpenAPI 3.1) api/openapi.yaml API contract (OpenAPI 3.1)
migrations/ Forward-only SQL migrations (TimescaleDB) migrations/ Forward-only SQL migrations (TimescaleDB)

View File

@@ -1 +1 @@
0.7.6 0.7.11

View File

@@ -36,13 +36,17 @@ var iconPNG []byte
const ( const (
keyringService = "com.hubris.oikos-desktop" keyringService = "com.hubris.oikos-desktop"
keyringUser = "oikos" keyringUser = "oikos"
version = "0.1.0"
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases" updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour updateInterval = 6 * time.Hour
oidcCallbackPort = 18901 oidcCallbackPort = 18901
) )
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
// (Makefile desktop target). The default keeps a non-empty fallback for
// `go build ./cmd/desktop` without ldflags.
var version = "0.1.0-dev"
type OikosConfig struct { type OikosConfig struct {
ApiUrl string `json:"apiUrl"` ApiUrl string `json:"apiUrl"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`

16
docs/index.md Normal file
View File

@@ -0,0 +1,16 @@
# Docs
Long-form reference material for the Oikos platform. Operational state and
topology live in the DB (seeded from `seeds/`); these docs cover decisions,
procedures, and the system model.
| Path | Contents |
| ---- | -------- |
| [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) |
| [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform |
| [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) |
For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model
see [.agents/OIKOS.md](../.agents/OIKOS.md); for development see
[CONTRIBUTING.md](../CONTRIBUTING.md). Design plans live in
[plans/](../plans/), not here.

18
docs/operations/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Operations runbooks
Step-by-step procedures for operating the homelab. These complement the
agent-facing skill files in [`.agents/skills/`](../../.agents/skills/) (which
are machine-actionable) and the deploy scripts in
[`scripts/`](../../scripts/) (which are executable).
| Runbook | Scope |
| ------- | ----- |
| [rollback.md](rollback.md) | Rollback a deploy: checkout SHA + pg_restore |
For the deploy pipeline itself see
[`scripts/deploy.sh`](../../scripts/deploy.sh), the watchdog at
[`scripts/watchdog.sh`](../../scripts/watchdog.sh), and the cutover checklist
at [`scripts/cutover-checklist.md`](../../scripts/cutover-checklist.md). The
risk classification for any mutation is defined in
[`seeds/policy.yaml`](../../seeds/policy.yaml) — run `oikos` MCP `preflight`
to check the class before acting.

View File

@@ -0,0 +1,109 @@
package actuator
import (
"testing"
"time"
)
func TestNewCircuitBreakerDefaults(t *testing.T) {
cb := newCircuitBreaker(0, 0)
if cb.threshold != 3 {
t.Errorf("threshold = %d, want 3", cb.threshold)
}
if cb.cooldownS != 300 {
t.Errorf("cooldownS = %d, want 300", cb.cooldownS)
}
cb = newCircuitBreaker(5, 60)
if cb.threshold != 5 {
t.Errorf("threshold = %d, want 5", cb.threshold)
}
if cb.cooldownS != 60 {
t.Errorf("cooldownS = %d, want 60", cb.cooldownS)
}
}
func TestCircuitBreakerIsOpenFresh(t *testing.T) {
cb := newCircuitBreaker(3, 60)
if cb.isOpen("host:A") {
t.Errorf("fresh circuit should be closed, got open")
}
}
func TestCircuitBreakerOpensAtThreshold(t *testing.T) {
cb := newCircuitBreaker(3, 60)
// threshold-1 failures → still closed
cb.recordFailure("host:A")
cb.recordFailure("host:A")
if cb.isOpen("host:A") {
t.Fatalf("circuit should be closed after threshold-1 failures")
}
// one more → open
cb.recordFailure("host:A")
if !cb.isOpen("host:A") {
t.Fatalf("circuit should be open after threshold failures")
}
}
func TestCircuitBreakerClosesAfterCooldown(t *testing.T) {
cb := newCircuitBreaker(1, 60)
// Force open
cb.recordFailure("host:A")
if !cb.isOpen("host:A") {
t.Fatalf("circuit should be open")
}
// Manipulate the cooldown timestamp to the past to simulate expiry.
cb.mu.Lock()
cb.cooldowns["host:A"] = time.Now().Add(-1 * time.Second)
cb.mu.Unlock()
if cb.isOpen("host:A") {
t.Fatalf("circuit should be closed after cooldown expired")
}
// Failure count should have been reset by isOpen.
cb.mu.Lock()
got := cb.failures["host:A"]
cb.mu.Unlock()
if got != 0 {
t.Errorf("failure count after cooldown reset = %d, want 0", got)
}
}
func TestCircuitBreakerRecordSuccessResets(t *testing.T) {
cb := newCircuitBreaker(3, 60)
cb.recordFailure("host:A")
cb.recordFailure("host:A")
cb.recordSuccess("host:A")
cb.mu.Lock()
got := cb.failures["host:A"]
cb.mu.Unlock()
if got != 0 {
t.Errorf("failure count after success = %d, want 0", got)
}
if cb.isOpen("host:A") {
t.Errorf("circuit should be closed after success reset")
}
}
func TestCircuitBreakerPerTargetIsolation(t *testing.T) {
cb := newCircuitBreaker(2, 60)
cb.recordFailure("host:A")
cb.recordFailure("host:A") // host:A now at threshold → open
if !cb.isOpen("host:A") {
t.Fatalf("host:A should be open")
}
if cb.isOpen("host:B") {
t.Errorf("host:B should be closed (isolated from host:A)")
}
// host:B has no failures recorded
cb.mu.Lock()
gotB := cb.failures["host:B"]
cb.mu.Unlock()
if gotB != 0 {
t.Errorf("host:B failure count = %d, want 0", gotB)
}
}

View File

@@ -0,0 +1,151 @@
package actuator
import (
"context"
"errors"
"net"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
func TestSSHErrorClassString(t *testing.T) {
cases := []struct {
class SSHErrorClass
want string
}{
{SSHErrorNetwork, "network"},
{SSHErrorAuth, "auth"},
{SSHErrorTimeout, "timed_out"},
{SSHErrorRemote, "remote"},
{SSHErrorOther, "other"},
{SSHErrorClass(999), "unknown"},
}
for _, c := range cases {
if got := c.class.String(); got != c.want {
t.Errorf("SSHErrorClass(%d).String() = %q, want %q", c.class, got, c.want)
}
}
}
// timeoutNetErr is a custom net.Error implementation for testing.
type timeoutNetErr struct {
timeout bool
msg string
}
func (e *timeoutNetErr) Error() string { return e.msg }
func (e *timeoutNetErr) Timeout() bool { return e.timeout }
func (e *timeoutNetErr) Temporary() bool { return false }
func TestClassifySSHError(t *testing.T) {
// ssh.ExitError fields are unexported, but classifySSHError only checks
// for the type via errors.As, so the zero value is sufficient.
exitErr := &ssh.ExitError{}
cases := []struct {
name string
err error
want SSHErrorClass
}{
{"nil", nil, SSHErrorOther},
{"deadline exceeded", context.DeadlineExceeded, SSHErrorTimeout},
{"net error timeout true", &timeoutNetErr{timeout: true, msg: "i/o timeout"}, SSHErrorNetwork},
{"net error timeout false", &timeoutNetErr{timeout: false, msg: "connection refused"}, SSHErrorNetwork},
{"unable to authenticate", errors.New("unable to authenticate, no supported methods remain"), SSHErrorAuth},
{"no supported methods remain", errors.New("no supported methods remain (server sent publickey)"), SSHErrorAuth},
{"ssh handshake failed", errors.New("ssh: handshake failed: read tcp -> eof"), SSHErrorAuth},
{"publickey", errors.New("publickey denied"), SSHErrorAuth},
{"permission denied", errors.New("permission denied (publickey)"), SSHErrorAuth},
{"exit error", exitErr, SSHErrorRemote},
{"generic error", errors.New("something went wrong"), SSHErrorOther},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := classifySSHError(c.err); got != c.want {
t.Errorf("classifySSHError(%v) = %v, want %v", c.err, got, c.want)
}
})
}
}
func TestParseProcedure(t *testing.T) {
cases := []struct {
name string
data []byte
wantErr bool
wantLen int
}{
{
name: "valid with steps",
data: []byte(`{"steps":[{"runner":"shell","command":"echo hi"}]}`),
wantErr: false,
wantLen: 1,
},
{
name: "invalid json",
data: []byte(`{not json`),
wantErr: true,
},
{
name: "empty bytes",
data: []byte{},
wantErr: true,
},
{
name: "valid no steps key",
data: []byte(`{"foo":"bar"}`),
wantErr: false,
wantLen: 0,
},
{
name: "valid with extra fields",
data: []byte(`{"extra":"ignored","steps":[{"runner":"verify","command":"true"}],"more":123}`),
wantErr: false,
wantLen: 1,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
proc, err := ParseProcedure(c.data)
if c.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (proc=%+v)", proc)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(proc.Steps) != c.wantLen {
t.Errorf("got %d steps, want %d", len(proc.Steps), c.wantLen)
}
})
}
}
func TestSetDefaultSSHTimeout(t *testing.T) {
mu.Lock()
orig := defaultSSHTimeout
mu.Unlock()
defer func() {
mu.Lock()
defaultSSHTimeout = orig
mu.Unlock()
}()
newTimeout := 42 * time.Second
SetDefaultSSHTimeout(newTimeout)
mu.Lock()
got := defaultSSHTimeout
mu.Unlock()
if got != newTimeout {
t.Errorf("defaultSSHTimeout = %v, want %v", got, newTimeout)
}
}
// Ensure timeoutNetErr satisfies net.Error at compile time.
var _ net.Error = (*timeoutNetErr)(nil)

View File

@@ -19,7 +19,7 @@ type CheckDef struct {
Extra map[string]any Extra map[string]any
} }
func ResolveHost(attrs map[string]any) string { func resolveHost(attrs map[string]any) string {
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" { if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
return ip return ip
} }
@@ -57,8 +57,8 @@ func resolveSSHPort(attrs map[string]any) int {
return 22 return 22
} }
func ForEntityType(entityType string, attrs map[string]any) []CheckDef { func forEntityType(entityType string, attrs map[string]any) []CheckDef {
host := ResolveHost(attrs) host := resolveHost(attrs)
user := resolveSSHUser(attrs) user := resolveSSHUser(attrs)
port := resolveSSHPort(attrs) port := resolveSSHPort(attrs)
@@ -122,7 +122,7 @@ func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
return nil return nil
} }
func ShortSlug(slug string) string { func shortSlug(slug string) string {
const n = 8 const n = 8
if len(slug) > n { if len(slug) > n {
return slug[len(slug)-n:] return slug[len(slug)-n:]
@@ -130,7 +130,7 @@ func ShortSlug(slug string) string {
return slug return slug
} }
func DefaultInterval(kind string) int32 { func defaultInterval(kind string) int32 {
switch kind { switch kind {
case "ping": case "ping":
return 30 return 30
@@ -156,7 +156,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
attrs = map[string]any{} attrs = map[string]any{}
} }
defs := ForEntityType(entityType, attrs) defs := forEntityType(entityType, attrs)
if len(defs) == 0 { if len(defs) == 0 {
return return
} }
@@ -166,7 +166,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
if err != nil { if err != nil {
checkID = uuid.New() checkID = uuid.New()
} }
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, ShortSlug(slug), i) checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, shortSlug(slug), i)
_, _ = tx.Exec(ctx, _, _ = tx.Exec(ctx,
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at) `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
@@ -199,6 +199,6 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled) `INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
VALUES ($1, $2, $3, $4, $5, 30, true) VALUES ($1, $2, $3, $4, $5, 30, true)
ON CONFLICT (entity_id) DO NOTHING`, ON CONFLICT (entity_id) DO NOTHING`,
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind)) checkID, entityID, def.Kind, configJSON, defaultInterval(def.Kind))
} }
} }

View File

@@ -27,9 +27,6 @@ WHERE e.type IN (SELECT name FROM tt)
ORDER BY e.slug ORDER BY e.slug
LIMIT sqlc.arg('lim'); LIMIT sqlc.arg('lim');
-- name: ListEntitiesCapped :many
SELECT e.* FROM entities e ORDER BY e.slug LIMIT $1;
-- name: InsertEntity :one -- name: InsertEntity :one
INSERT INTO entities (id, slug, type, name, state, attributes) INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)

View File

@@ -14,11 +14,6 @@ WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
ORDER BY se.slug ORDER BY se.slug
LIMIT sqlc.arg('lim'); LIMIT sqlc.arg('lim');
-- name: ListEntityStatus :many
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
ORDER BY e.slug;
-- name: GetIdempotentResponse :one -- name: GetIdempotentResponse :one
SELECT response_code, response_body, request_hash FROM idempotency_keys SELECT response_code, response_body, request_hash FROM idempotency_keys
WHERE actor = $1 AND key = $2; WHERE actor = $1 AND key = $2;
@@ -89,9 +84,6 @@ DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
updated_at = now() updated_at = now()
RETURNING *; RETURNING *;
-- name: UpdateSignalState :exec
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1;
-- name: GetOpenSignalsForAutoAct :many -- name: GetOpenSignalsForAutoAct :many
-- Signals with auto-act classifications that haven't been executed yet -- Signals with auto-act classifications that haven't been executed yet
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route, SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
@@ -106,12 +98,6 @@ WHERE c.route = 'auto-act'
ORDER BY s.last_seen_at ASC ORDER BY s.last_seen_at ASC
LIMIT $1; LIMIT $1;
-- name: InsertClassification :exec
INSERT INTO classifications (entity_id, signal_entity_id, target_entity_id, action,
recommended_action, risk_class, route, blast_radius, pattern_confidence,
skill_id, autonomy_check, reasoning, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);
-- name: ListClassifications :many -- name: ListClassifications :many
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action, SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
c.recommended_action, c.risk_class, c.route, c.blast_radius, c.recommended_action, c.risk_class, c.route, c.blast_radius,
@@ -153,11 +139,6 @@ WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
ORDER BY te.slug ORDER BY te.slug
LIMIT sqlc.arg('lim'); LIMIT sqlc.arg('lim');
-- name: InsertFeedback :exec
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson,
unexpected_side_effects, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7);
-- name: GetFeedbackAfterWatermark :many -- name: GetFeedbackAfterWatermark :many
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson, SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
f.unexpected_side_effects, f.tags, f.created_at, f.unexpected_side_effects, f.tags, f.created_at,
@@ -201,11 +182,6 @@ SELECT * FROM skills
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
ORDER BY name, version DESC; ORDER BY name, version DESC;
-- name: InsertSkill :exec
INSERT INTO skills (entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, changed_by, change_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
-- name: UpdateSkillStatus :exec -- name: UpdateSkillStatus :exec
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2; UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;

View File

@@ -22,12 +22,6 @@ WHERE r.valid_to IS NULL
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[])) AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
ORDER BY r.type, se.slug, te.slug; ORDER BY r.type, se.slug, te.slug;
-- name: UpsertCurrentRelationship :exec
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes;
-- name: EndCurrentRelationship :execrows -- name: EndCurrentRelationship :execrows
UPDATE relationships SET valid_to = now() UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL; WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;

View File

@@ -177,43 +177,6 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
return items, nil return items, nil
} }
const listEntitiesCapped = `-- name: ListEntitiesCapped :many
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e ORDER BY e.slug LIMIT $1
`
func (q *Queries) ListEntitiesCapped(ctx context.Context, limit int32) ([]Entity, error) {
rows, err := q.db.Query(ctx, listEntitiesCapped, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Entity
for rows.Next() {
var i Entity
if err := rows.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateEntity = `-- name: UpdateEntity :one const updateEntity = `-- name: UpdateEntity :one
UPDATE entities SET UPDATE entities SET
name = COALESCE($1, name), name = COALESCE($1, name),

View File

@@ -40,6 +40,12 @@ type AgentSession struct {
Actor string Actor string
CreatedAt time.Time CreatedAt time.Time
LastActiveAt time.Time LastActiveAt time.Time
Goal string
Status string
Outcome *string
Summary string
EntityID *uuid.UUID
CompletionNudges int32
} }
type Approval struct { type Approval struct {
@@ -83,6 +89,7 @@ type AuditLog struct {
Detail []byte Detail []byte
SourceIp *string SourceIp *string
CorrelationID *string CorrelationID *string
SessionID *uuid.UUID
} }
type AutonomySetting struct { type AutonomySetting struct {
@@ -289,6 +296,13 @@ type MetricSample struct {
Tags []byte Tags []byte
} }
type NomosPlanExecution struct {
ExecutionID uuid.UUID
SessionID uuid.UUID
ContinuedAt *time.Time
CreatedAt time.Time
}
type Pattern struct { type Pattern struct {
EntityID uuid.UUID EntityID uuid.UUID
AppliesType string AppliesType string
@@ -351,6 +365,32 @@ type SeedVersion struct {
AppliedAt time.Time AppliedAt time.Time
} }
type SessionPlanStep struct {
ID uuid.UUID
SessionID uuid.UUID
Seq int32
Title string
Detail string
Status string
ExecutionID *uuid.UUID
TargetSlug *string
StartedAt *time.Time
FinishedAt *time.Time
CreatedAt time.Time
Generation int32
}
type SessionQuestion struct {
ID uuid.UUID
SessionID uuid.UUID
Prompt string
Context []byte
Status string
Answer *string
CreatedAt time.Time
AnsweredAt *time.Time
}
type Signal struct { type Signal struct {
EntityID uuid.UUID EntityID uuid.UUID
Kind string Kind string

View File

@@ -416,48 +416,6 @@ func (q *Queries) InsertCheckDef(ctx context.Context, arg InsertCheckDefParams)
return err return err
} }
const insertClassification = `-- name: InsertClassification :exec
INSERT INTO classifications (entity_id, signal_entity_id, target_entity_id, action,
recommended_action, risk_class, route, blast_radius, pattern_confidence,
skill_id, autonomy_check, reasoning, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`
type InsertClassificationParams struct {
EntityID uuid.UUID
SignalEntityID *uuid.UUID
TargetEntityID *uuid.UUID
Action string
RecommendedAction []byte
RiskClass string
Route string
BlastRadius []uuid.UUID
PatternConfidence *float32
SkillID *uuid.UUID
AutonomyCheck *string
Reasoning []byte
CorrelationID string
}
func (q *Queries) InsertClassification(ctx context.Context, arg InsertClassificationParams) error {
_, err := q.db.Exec(ctx, insertClassification,
arg.EntityID,
arg.SignalEntityID,
arg.TargetEntityID,
arg.Action,
arg.RecommendedAction,
arg.RiskClass,
arg.Route,
arg.BlastRadius,
arg.PatternConfidence,
arg.SkillID,
arg.AutonomyCheck,
arg.Reasoning,
arg.CorrelationID,
)
return err
}
const insertEvent = `-- name: InsertEvent :one const insertEvent = `-- name: InsertEvent :one
INSERT INTO events (type, entity_id, severity, source, data, correlation_id) INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
@@ -530,35 +488,6 @@ func (q *Queries) InsertExecution(ctx context.Context, arg InsertExecutionParams
return err return err
} }
const insertFeedback = `-- name: InsertFeedback :exec
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson,
unexpected_side_effects, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
type InsertFeedbackParams struct {
EntityID uuid.UUID
ExecutionID uuid.UUID
Outcome string
Observation *string
Lesson *string
UnexpectedSideEffects []string
Tags []string
}
func (q *Queries) InsertFeedback(ctx context.Context, arg InsertFeedbackParams) error {
_, err := q.db.Exec(ctx, insertFeedback,
arg.EntityID,
arg.ExecutionID,
arg.Outcome,
arg.Observation,
arg.Lesson,
arg.UnexpectedSideEffects,
arg.Tags,
)
return err
}
const insertMetricSample = `-- name: InsertMetricSample :exec const insertMetricSample = `-- name: InsertMetricSample :exec
INSERT INTO metric_samples (entity_id, metric, value, tags, ts) INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
VALUES ($1, $2, $3, $4, now()) VALUES ($1, $2, $3, $4, now())
@@ -581,41 +510,6 @@ func (q *Queries) InsertMetricSample(ctx context.Context, arg InsertMetricSample
return err return err
} }
const insertSkill = `-- name: InsertSkill :exec
INSERT INTO skills (entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, changed_by, change_reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
type InsertSkillParams struct {
EntityID uuid.UUID
Version int32
Name string
Procedure []byte
AppliesType *string
Action string
PatternIds []uuid.UUID
Status string
ChangedBy *uuid.UUID
ChangeReason *string
}
func (q *Queries) InsertSkill(ctx context.Context, arg InsertSkillParams) error {
_, err := q.db.Exec(ctx, insertSkill,
arg.EntityID,
arg.Version,
arg.Name,
arg.Procedure,
arg.AppliesType,
arg.Action,
arg.PatternIds,
arg.Status,
arg.ChangedBy,
arg.ChangeReason,
)
return err
}
const listApprovalRules = `-- name: ListApprovalRules :many const listApprovalRules = `-- name: ListApprovalRules :many
SELECT id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at FROM approval_rules ORDER BY entity_type, action SELECT id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at FROM approval_rules ORDER BY entity_type, action
` `
@@ -852,44 +746,6 @@ func (q *Queries) ListEnabledCheckDefs(ctx context.Context) ([]ListEnabledCheckD
return items, nil return items, nil
} }
const listEntityStatus = `-- name: ListEntityStatus :many
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
ORDER BY e.slug
`
type ListEntityStatusRow struct {
Slug string
Type string
Health string
LastCheckAt *time.Time
}
func (q *Queries) ListEntityStatus(ctx context.Context) ([]ListEntityStatusRow, error) {
rows, err := q.db.Query(ctx, listEntityStatus)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListEntityStatusRow
for rows.Next() {
var i ListEntityStatusRow
if err := rows.Scan(
&i.Slug,
&i.Type,
&i.Health,
&i.LastCheckAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEvents = `-- name: ListEvents :many const listEvents = `-- name: ListEvents :many
SELECT id, ts, type, entity_id, severity, source, data, correlation_id SELECT id, ts, type, entity_id, severity, source, data, correlation_id
FROM events FROM events
@@ -1462,20 +1318,6 @@ func (q *Queries) UpdatePatternStatus(ctx context.Context, arg UpdatePatternStat
return err return err
} }
const updateSignalState = `-- name: UpdateSignalState :exec
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1
`
type UpdateSignalStateParams struct {
EntityID uuid.UUID
State string
}
func (q *Queries) UpdateSignalState(ctx context.Context, arg UpdateSignalStateParams) error {
_, err := q.db.Exec(ctx, updateSignalState, arg.EntityID, arg.State)
return err
}
const updateSkillStatus = `-- name: UpdateSkillStatus :exec const updateSkillStatus = `-- name: UpdateSkillStatus :exec
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2 UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2
` `

View File

@@ -139,27 +139,3 @@ func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams)
} }
return items, nil return items, nil
} }
const upsertCurrentRelationship = `-- name: UpsertCurrentRelationship :exec
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes
`
type UpsertCurrentRelationshipParams struct {
SourceID uuid.UUID
TargetID uuid.UUID
Type string
Attributes []byte
}
func (q *Queries) UpsertCurrentRelationship(ctx context.Context, arg UpsertCurrentRelationshipParams) error {
_, err := q.db.Exec(ctx, upsertCurrentRelationship,
arg.SourceID,
arg.TargetID,
arg.Type,
arg.Attributes,
)
return err
}

View File

@@ -0,0 +1,139 @@
package domain
import (
"errors"
"testing"
)
func TestIsNil(t *testing.T) {
cases := []struct {
name string
u UUID
want bool
}{
{"empty string", UUID(""), true},
{"single char", UUID("x"), false},
{"uuid string", UUID("550e8400-e29b-41d4-a716-446655440000"), false},
{"nil literal", UUID(""), true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.u.IsNil()
if got != c.want {
t.Errorf("UUID(%q).IsNil() = %v, want %v", c.u, got, c.want)
}
})
}
}
func TestCanTransition(t *testing.T) {
type tc struct {
name string
from string
to string
want bool
}
var cases []tc
for from, targets := range ValidSignalTransitions {
for _, to := range targets {
cases = append(cases, tc{from + "->" + to, from, to, true})
}
}
disallowed := []tc{
{"raised->raised", SignalRaised, SignalRaised, false},
{"resolved->raised", SignalResolved, SignalRaised, false},
{"failed->raised", SignalFailed, SignalRaised, false},
{"acknowledged->raised", SignalAcknowledged, SignalRaised, false},
{"muted->resolved", SignalMuted, SignalResolved, false},
{"acting->acknowledged", SignalActing, SignalAcknowledged, false},
}
cases = append(cases, disallowed...)
cases = append(cases,
tc{"unknown source", "nonexistent", SignalRaised, false},
tc{"unknown target", SignalRaised, "nonexistent", false},
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := &Signal{State: c.from}
got := s.CanTransition(c.to)
if got != c.want {
t.Errorf("CanTransition(%q -> %q) = %v, want %v", c.from, c.to, got, c.want)
}
})
}
}
func TestSentinelErrors(t *testing.T) {
cases := []struct {
name string
err error
msg string
}{
{"ErrNotFound", ErrNotFound, "entity not found"},
{"ErrInvalidTransition", ErrInvalidTransition, "invalid lifecycle transition"},
{"ErrApprovalRequired", ErrApprovalRequired, "operator approval required"},
{"ErrAutonomyBlocked", ErrAutonomyBlocked, "autonomy policy blocks this action"},
{"ErrConflict", ErrConflict, "concurrent modification conflict"},
{"ErrCircuitOpen", ErrCircuitOpen, "circuit breaker open for target"},
{"ErrAbstractType", ErrAbstractType, "cannot instantiate abstract entity type"},
{"ErrInvalidEdge", ErrInvalidEdge, "relationship endpoint type mismatch"},
{"ErrCardinality", ErrCardinality, "relationship cardinality violation"},
{"ErrSeedHashMismatch", ErrSeedHashMismatch, "seed content hash mismatch"},
{"ErrAlreadyExists", ErrAlreadyExists, "entity already exists"},
{"ErrQuarantined", ErrQuarantined, "pattern is quarantined"},
{"ErrSkillDeprecated", ErrSkillDeprecated, "skill is deprecated"},
{"ErrInvalidInput", ErrInvalidInput, "invalid input"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.err == nil {
t.Fatal("sentinel error is nil")
}
if !errors.Is(c.err, c.err) {
t.Errorf("errors.Is failed for %s", c.name)
}
if c.err.Error() != c.msg {
t.Errorf("Error() = %q, want %q", c.err.Error(), c.msg)
}
})
}
}
func TestSignalTransitionsComplete(t *testing.T) {
// Non-terminal states must be keys in ValidSignalTransitions.
// SignalResolved is a terminal state (no outgoing transitions) and is
// intentionally absent from the map.
nonTerminal := []string{
SignalRaised,
SignalAcknowledged,
SignalActing,
SignalMuted,
SignalFailed,
}
for _, state := range nonTerminal {
targets, ok := ValidSignalTransitions[state]
if !ok {
t.Errorf("non-terminal state %q missing from ValidSignalTransitions", state)
continue
}
if len(targets) == 0 {
t.Errorf("state %q maps to empty transition list", state)
}
}
// Resolved is terminal: it should not appear as a source key.
if _, ok := ValidSignalTransitions[SignalResolved]; ok {
t.Errorf("terminal state %q should not have outgoing transitions", SignalResolved)
}
// No state anywhere in the map may map to nil/empty.
for state, targets := range ValidSignalTransitions {
if len(targets) == 0 {
t.Errorf("state %q maps to empty/nil transition list", state)
}
}
}

View File

@@ -0,0 +1,676 @@
package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
)
var (
_sshUser string
_sshKey []byte
)
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
// field made the approved pct_create execution fail to parse *after* the
// operator had already approved it — the container was never created and the
// operator saw "queued" with no result. This type tolerates the common shapes.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(strings.Trim(string(data), `"`))
switch strings.ToLower(s) {
case "true", "1", "yes", "on":
*b = true
case "false", "0", "no", "off", "", "null":
*b = false
default:
return fmt.Errorf("cannot parse %q as bool", s)
}
return nil
}
func initSSH() {
if _sshUser == "" {
_sshUser = os.Getenv("OIKOS_SSH_USER")
if _sshUser == "" {
_sshUser = "root"
}
}
if len(_sshKey) == 0 {
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
if keyPath == "" {
keyPath = "/etc/oikos/ssh_key"
}
var err error
_sshKey, err = os.ReadFile(keyPath)
if err != nil {
slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
}
}
}
// sshExecTimeout bounds how long a single remote command may run. Without
// this, a hung remote command (e.g. a piped install script stuck retrying
// DNS against a misconfigured gateway) blocks the executing goroutine
// forever: the execution never leaves 'approved'/'running', the operator
// sees an unkillable spinner, and get_execution_status has nothing new to
// report. Generous enough for a real apt/docker install; not infinite.
const sshExecTimeout = 10 * time.Minute
func sshExec(ctx context.Context, host, user, command string) (string, error) {
initSSH()
if len(_sshKey) == 0 {
return "", fmt.Errorf("no SSH key available")
}
if user == "" {
user = _sshUser
}
addr := host + ":22"
signer, err := ssh.ParsePrivateKey(_sshKey)
if err != nil {
return "", fmt.Errorf("parse key: %w", err)
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return "", fmt.Errorf("dial %s: %w", host, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
type result struct {
out []byte
err error
}
done := make(chan result, 1)
go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command)
done <- result{out, err}
}()
select {
case r := <-done:
text := strings.TrimSpace(string(r.out))
// A non-zero exit MUST surface as an error. The previous guard only
// errored when there was no output, so a `pct create` that printed
// "CT 132 already exists" and exited non-zero was reported as
// success — the execution was marked completed though nothing was
// provisioned.
if r.err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", r.err, text)
}
return text, fmt.Errorf("exec: %w", r.err)
}
return text, nil
case <-time.After(sshExecTimeout):
// Close the session/client to hang up the remote side; the
// goroutine above will eventually exit once that unblocks
// CombinedOutput, but we don't wait for it — the caller needs an
// answer now, not an indefinite hang.
session.Close()
client.Close()
return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
case <-ctx.Done():
session.Close()
client.Close()
return "", ctx.Err()
}
}
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
var attrs string
err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
if err != nil {
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
return "", "", fmt.Errorf("parse attributes: %w", err)
}
sshUser := _sshUser
if sshUser == "" {
sshUser = "root"
}
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
for _, proto := range []string{"netbird", "tailscale"} {
if p, ok := mesh[proto].(map[string]interface{}); ok {
if ip, ok := p["ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
}
}
}
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}
// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved-
// execution side: any target slug (host: or lxc:) resolves to the SSH
// endpoint that runs the command plus a wrap function that turns a plain
// shell command into what actually needs to be sent — identity for a host,
// `pct exec <pve_id>` for an LXC. Kept as a small duplicate rather than a
// cross-package import to avoid coupling httpapi to mcp for one helper.
func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) {
if strings.HasPrefix(targetSlug, "host:") {
host, user, err = resolveHostSSH(ctx, pool, targetSlug)
return host, user, func(cmd string) string { return cmd }, err
}
if strings.HasPrefix(targetSlug, "lxc:") {
var pveID, hostAttr string
// COALESCE the host column: many older LXC entities (seeded from
// inventory, not provisioned by pct_create) have pve_id but no host
// attribute at all. Scanning a SQL NULL into a plain string errors
// the whole row, wrongly reporting "missing pve_id" even when it was
// present — COALESCE avoids the NULL, "" is handled below.
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
}
hostSlug := hostAttr
if hostSlug == "" {
hostSlug = "hubris"
}
if !strings.HasPrefix(hostSlug, "host:") {
hostSlug = "host:" + hostSlug
}
host, user, err = resolveHostSSH(ctx, pool, hostSlug)
id := pveID
return host, user, func(cmd string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
}, err
}
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
}
// executeApprovedAction runs a gated action after operator approval.
// Runs in a background goroutine to not block the HTTP response.
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
// the control room can watch approved actions run to completion live.
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
severity := "info"
if status == "failed" {
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
if status == "completed" || status == "failed" || status == "cancelled" {
closePlanStepForExecution(ctx, pool, execID, status)
}
}
// closePlanStepForExecution auto-closes a task plan step whose linked execution
// just reached a terminal state, so the task board advances even if the agent
// doesn't call update_plan_step itself (belt and suspenders — the agent links
// the step to the execution when it starts it; the api finishes it here). Emits
// plan.step.finished correlated to the step's session. No-op for the vast
// majority of executions, which aren't plan steps.
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
stepStatus := "done"
if execStatus == "failed" || execStatus == "cancelled" {
stepStatus = "failed"
}
var stepID, sessionID string
var seq int
if err := pool.QueryRow(ctx, `
UPDATE session_plan_steps SET status = $2, finished_at = now()
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
return // no matching open step
}
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
}
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug)
if err != nil {
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", err.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
idx := strings.Index(actionStr, ":")
if idx < 0 {
slog.Error("httpapi: malformed action string (no colon)", "action", actionStr)
return
}
action, params := actionStr[:idx], actionStr[idx+1:]
startedAt := time.Now()
var output, cmd string
switch action {
case "systemctl":
svc := strings.TrimPrefix(targetSlug, "lxc:")
switch {
case strings.HasPrefix(params, "enable:"):
svc = strings.TrimPrefix(params, "enable:")
cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
case strings.HasPrefix(params, "disable:"):
svc = strings.TrimPrefix(params, "disable:")
cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
default:
cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
}
output, err = sshExec(ctx, host, user, cmd)
case "apt_upgrade":
svc := strings.TrimPrefix(targetSlug, "lxc:")
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
output, err = sshExec(ctx, host, user, cmd)
case "pct_create":
var cfg struct {
VMID int `json:"vmid"`
Hostname string `json:"hostname"`
Cores int `json:"cores"`
Memory int `json:"memory"`
DiskGB int `json:"disk_gb"`
IP string `json:"ip"`
GW string `json:"gw"`
Bridge string `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0
Storage string `json:"storage"`
Template string `json:"template"`
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
Mounts []string `json:"mounts"`
Nameserver string `json:"nameserver"`
Searchdomain string `json:"searchdomain"`
// No services/post_install here anymore — pct_create is atomic
// (create + start + register only). Installing packages and
// running setup scripts is the agent's job via follow-up `run`
// calls against lxc:<hostname>, so each step is individually
// observable and recoverable instead of one opaque multi-minute
// black box. See the comment above the removed post-create block.
}
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("invalid pct_create params: %v", err))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
// Only hostname is required. vmid is optional — when 0 (or later found
// to collide) the VMID guard below assigns a free cluster id.
if cfg.Hostname == "" {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, `{"error":"pct_create: hostname is required"}`)
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"})
return
}
if cfg.Cores == 0 {
cfg.Cores = 1
}
if cfg.Memory == 0 {
cfg.Memory = 512
}
if cfg.DiskGB == 0 {
cfg.DiskGB = 8
}
if cfg.Storage == "" {
cfg.Storage = "local-lvm"
}
if cfg.GW == "" {
cfg.GW = "192.168.8.2"
}
if cfg.Nameserver == "" {
cfg.Nameserver = "192.168.8.2"
}
if cfg.Searchdomain == "" {
cfg.Searchdomain = "hubris.network"
}
// Template pre-flight: resolve against what the host actually has
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
// `pct` error when that exact file isn't present. List the cache, then
// either validate the requested template or auto-pick the newest
// debian one; on miss, fail early with the available list so the
// operator/agent can retry with a real name.
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
available := []string{}
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
if l = strings.TrimSpace(l); l != "" {
available = append(available, l)
}
}
if tplErr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
return
}
cfg.Template = resolveTemplate(cfg.Template, available)
if cfg.Template == "" {
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
// guess (e.g. 132) can collide with a container on another node — pct
// create then fails with "CT N already exists on node X". Fetch the set
// of in-use VMIDs across the cluster; if the requested id is taken (or
// absent), fall back to the cluster's next free id so provisioning
// still succeeds instead of dead-ending on the operator's approval.
usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`)
used := map[int]bool{}
for _, l := range strings.Fields(usedRaw) {
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[n] = true
}
}
if cfg.VMID == 0 || used[cfg.VMID] {
nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`)
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw))
if nerr != nil || cerr != nil || nextID == 0 {
msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID)
cfg.VMID = nextID
}
privFlag := "--unprivileged 1"
if cfg.Privileged {
privFlag = "--unprivileged 0"
}
nestingFlag := ""
features := []string{}
if cfg.Nesting {
features = append(features, "nesting=1")
}
if cfg.Privileged {
features = append(features, "keyctl=1")
}
if len(features) > 0 {
nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
}
if cfg.Bridge == "" {
cfg.Bridge = "vmbr0"
}
// net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox
// rejects a gateway alongside ip=dhcp, so only add gw for a static IP.
net0 := "name=eth0,bridge=" + cfg.Bridge + ","
isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp")
if !isStatic {
net0 += "ip=dhcp"
} else {
net0 += "ip=" + cfg.IP
if cfg.GW != "" {
net0 += ",gw=" + cfg.GW
}
}
// Pre-flight: for a static config, ping the gateway from the target
// HOST, on the SPECIFIC BRIDGE being requested, before spending 5+
// minutes creating the container. This is the check that would have
// caught the real TypeType failure immediately instead of after a
// full provision attempt.
//
// Binding to the bridge (`ping -I <bridge>`) matters and was found
// live: a plain unqualified `ping <gw>` from the host can succeed via
// the host's own routing table (multiple routes, possibly through an
// upstream router) even when the *container* — which only gets a
// naive on-link default route via its bridge's veth — can never ARP
// that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2`
// succeeded (via the host's default route), but a container actually
// attached to vmbr0 showed 100% packet loss trying to reach the same
// address, because vmbr0 doesn't carry that subnet's L2 segment.
// Binding to the bridge interface reproduces what the container will
// actually experience, not what the host's broader routing table can
// reach.
if isStatic && cfg.GW != "" {
pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW))
if pingErr != nil || !gatewayPreflightPassed(pingOut) {
msg := fmt.Sprintf(
"gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+
"Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.",
cfg.GW, targetSlug, cfg.Bridge)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
}
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template)
createCmd := fmt.Sprintf(
"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag)
if cfg.Nameserver != "" {
createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver)
}
if cfg.Searchdomain != "" {
createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain)
}
// Add mount points
for i, mp := range cfg.Mounts {
if i < 10 { // pct supports up to mp9
createCmd += fmt.Sprintf(" --mp%d %s", i, mp)
}
}
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
output, err = sshExec(ctx, host, user, createCmd)
// pct_create is now DELIBERATELY ATOMIC: create + start + register,
// nothing else. It used to also run apt installs and a post_install
// script inline as one black-box multi-minute SSH call — the agent
// got back a single opaque success/fail for the whole thing with no
// way to see (or fix) which step actually broke. That's the opposite
// of what makes an agent able to recover from errors.
//
// Installing packages, running post_install, and verifying the
// service now happen as the agent's OWN follow-up `run` calls against
// the new lxc:<hostname> target — each one is synchronous (in an
// active assent window) or individually gated, so the agent observes
// every step's real output and can diagnose + retry the exact thing
// that failed instead of re-doing the whole container. See SOUL.md
// "After pct_create: you drive the install" and provisionScript's
// surviving role (DNS self-heal) is now something the agent invokes
// itself via `run`, not something baked into this handler.
//
// cfg.Services/cfg.PostInstall are intentionally no longer read here.
// On success, register the entity in the DB with proper relationships
if err == nil {
slug := "lxc:" + cfg.Hostname
var lxcID uuid.UUID
lxcID, _ = uuid.NewV7()
attrs := map[string]any{
"pve_id": fmt.Sprintf("%d", cfg.VMID),
"host": strings.TrimPrefix(targetSlug, "host:"),
"ip": cfg.IP,
}
attrsJSON, _ := json.Marshal(attrs)
_, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON)
if insErr != nil {
slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug)
}
// Create hosts relationship: Proxmox host → LXC
var hostID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil {
_, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID)
if relErr != nil {
slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug)
}
}
// Create entity_status row for health tracking
pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at)
VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID)
emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{
"lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug,
})
slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug)
}
case "run":
// The general gated primitive: arbitrary shell against any host or
// LXC, approved and classified by internal/policy.ClassifyCommand at
// request time (see mcp/server.go's "run" tool). No fixed action
// enum — new capability doesn't require new Go code here.
var cfg struct {
Command string `json:"command"`
Purpose string `json:"purpose"`
}
if perr := json.Unmarshal([]byte(params), &cfg); perr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("invalid run params: %v", perr))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()})
return
}
cmd = wrap(cfg.Command)
output, err = sshExec(ctx, host, user, cmd)
default:
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("unknown action: %s", action))
return
}
durationMs := int(time.Since(startedAt).Milliseconds())
status := "completed"
verified := true
// Build result via json.Marshal, not string interpolation. Command output
// (apt/pct) contains quotes, backslashes and control chars; the old
// fmt.Sprintf only escaped "\n", producing invalid JSON that failed the
// ::jsonb cast — so this UPDATE was silently discarded and the execution
// was stuck at "approved" forever even though provisioning succeeded.
resMap := map[string]any{"output": output}
if err != nil {
resMap["error"] = err.Error()
status = "failed"
verified = false
}
resultJSON, _ := json.Marshal(resMap)
if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil {
slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status)
}
emitExecutionEvent(ctx, pool, execID, status, map[string]any{
"action": action, "target": targetSlug, "duration_ms": durationMs,
})
slog.Info("httpapi: approved action executed",
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
}
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
// error text and command output routinely contain quotes/backslashes that
// break a hand-built string and fail the ::jsonb cast.
func jsonErr(format string, args ...any) []byte {
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
return b
}
// resolveTemplate maps a requested template name to one actually present in
// the host's template cache. Exact match wins; a bare distro hint (e.g.
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
// (falling back to any) template available. Returns "" when nothing fits.
// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers
// from the pct_create gateway pre-flight check. Pulled out as its own
// function (rather than an inline strings.Contains at the call site) so it's
// unit-testable: a prior version checked for "REACHABLE", which is a
// substring of "UNREACHABLE" — the check could never actually fail, and it
// took a live deployment to notice. Exact-match markers plus a test make
// that specific bug class structurally unable to recur silently.
func gatewayPreflightPassed(out string) bool {
return strings.TrimSpace(out) == "PREFLIGHT_OK"
}
func resolveTemplate(requested string, available []string) string {
if len(available) == 0 {
return ""
}
if requested != "" {
for _, a := range available {
if a == requested {
return a
}
}
for _, a := range available {
if strings.HasPrefix(a, requested) {
return a
}
}
}
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
best := ""
for _, a := range available {
if strings.Contains(a, "debian") && a > best {
best = a
}
}
if best != "" {
return best
}
for _, a := range available {
if a > best {
best = a
}
}
return best
}

View File

@@ -0,0 +1,90 @@
package httpapi
import (
"context"
"fmt"
"time"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
// ─── Agent Activity (stub) ─────────────────────────────────────────────
func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) {
limit := clampLimit(request.Params.Limit)
from := time.Now().Add(-24 * time.Hour)
if request.Params.From != nil {
from = *request.Params.From
}
to := time.Now()
if request.Params.To != nil {
to = *request.Params.To
}
var agentID *string
if request.Params.AgentId != nil {
a := *request.Params.AgentId
agentID = &a
}
var activityType *string
if request.Params.ActivityType != nil {
a := string(*request.Params.ActivityType)
activityType = &a
}
var entityID *string
if request.Params.EntityId != nil {
a := *request.Params.EntityId
entityID = &a
}
var cursorID *int
if request.Params.Cursor != nil && *request.Params.Cursor != "" {
if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 {
cursorID = &id
}
}
rows, err := s.pool.Query(ctx, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, input_summary, output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
WHERE ts >= $1 AND ts <= $2
AND ($3::text IS NULL OR agent_id::text = $3)
AND ($4::text IS NULL OR activity_type = $4)
AND ($5::text IS NULL OR entity_id::text = $5)
AND ($6::bigint IS NULL OR id < $6::bigint)
ORDER BY id DESC
LIMIT $7`,
from, to, agentID, activityType, entityID, cursorID, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AgentActivity{}
for rows.Next() {
var a gen.AgentActivity
if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId,
&a.ActivityType, &a.ToolName, &a.EntityId,
&a.InputSummary, &a.OutputSummary,
&a.DurationMs, &a.TokenCount, &a.Success,
&a.CorrelationId); err != nil {
return nil, err
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
lastID := fmt.Sprintf("%d", items[len(items)-1].Id)
next = &lastID
}
if items == nil {
items = []gen.AgentActivity{}
}
return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil
}

View File

@@ -0,0 +1,160 @@
package httpapi
import (
"context"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
)
// ─── Approval Rules (Policy) ───────────────────────────────────────────
func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, entity_type, action, risk_class, autonomy_level,
COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
version, updated_at
FROM approval_rules ORDER BY entity_type, action`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.ApprovalRule{}
for rows.Next() {
var rule gen.ApprovalRule
var scopeSlug string
if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
&rule.Version); err != nil {
return nil, err
}
if scopeSlug != "" {
rule.ScopeEntity = &scopeSlug
}
items = append(items, rule)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.ApprovalRule{}
}
return gen.ListApprovalRules200JSONResponse{Items: items}, nil
}
func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
}
return nil, err
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
&id, "POST", "/api/v1/policy/approval-rules", "",
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Return 202 pending approval (dual-control).
return gen.CreateApprovalRule202JSONResponse{}, nil
}
func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
var scopeEntity *uuid.UUID
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
if rerr != nil {
return nil, rerr
}
scopeEntity = &se
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := tx.Exec(ctx, `
UPDATE approval_rules
SET entity_type = COALESCE($2, entity_type),
action = COALESCE($3, action),
risk_class = COALESCE($4, risk_class),
autonomy_level = COALESCE($5, autonomy_level),
scope_entity = COALESCE($6, scope_entity),
version = version + 1,
updated_at = now()
WHERE id = $1`,
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
string(req.Body.AutonomyLevel), scopeEntity)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
map[string]any{"action": req.Body.Action}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchApprovalRule202JSONResponse{}, nil
}

View File

@@ -0,0 +1,259 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Approvals ─────────────────────────────────────────────────────────
func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
var status *string
if req.Params.Status != nil {
s := string(*req.Params.Status)
status = &s
}
var kind *string
if req.Params.Kind != nil {
k := string(*req.Params.Kind)
kind = &k
}
rows, err := s.pool.Query(ctx, `
SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
a.status, a.expires_at, a.decided_at, a.decided_by::text,
a.created_at, e.slug
FROM approvals a
JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
WHERE ($1::text IS NULL OR a.status = $1)
AND ($2::text IS NULL OR a.kind = $2)
AND ($3::text IS NULL OR e.slug > $3)
ORDER BY e.slug
LIMIT $4`,
status, kind, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Approval{}
for rows.Next() {
var a gen.Approval
var payloadBytes []byte
var decidedBy *string
if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
&a.CreatedAt, &a.Slug); err != nil {
return nil, err
}
a.DecidedBy = decidedBy
var payload map[string]any
if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
a.Payload = &payload
}
items = append(items, a)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Approval{}
}
return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
actorType, actor := actorInfo(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if req.Body.Token != nil && *req.Body.Token != "" {
var tokenHash *string
var apprStatus string
var expiresAt time.Time
err := tx.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
id).Scan(&tokenHash, &apprStatus, &expiresAt)
if err != nil || tokenHash == nil {
return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
}
if apprStatus != "pending" {
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
}
if expiresAt.Before(time.Now()) {
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
}
if *tokenHash != hashToken(*req.Body.Token) {
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
}
}
// Map decision to status.
var status string
switch req.Body.Decision {
case gen.Approve:
status = "approved"
case gen.Deny:
status = "denied"
case gen.Revoke:
status = "revoked"
default:
return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
}
if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: id,
Status: status,
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read approval.
app, err := q.GetApprovalByID(ctx, id)
if err != nil {
return nil, err
}
approval := approvalToGen(app)
if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
map[string]any{"decision": status}); auditErr != nil {
return nil, auditErr
}
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
map[string]any{"decision": status, "actor": actor}); evErr != nil {
return nil, evErr
}
// On approve: execute the linked gated command.
if status == "approved" {
var execID, targetID uuid.UUID
var actionStr, targetSlug, riskClass string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
if err == nil {
// Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
safego.Go("httpapi:executeApprovedAction", func() {
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
})
// Status only — risk_class was set correctly at request time
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
// a hardcoded 'config_mutation' here corrupted the audit ledger
// for every other risk class, including destructive.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)
// Approving a plan step — by ANY route (this endpoint backs both
// the chat Approve button and chat-assent) — opens/extends the
// agent's assent window. This is the scope gate the Nomos
// auto-continuation worker checks: with the window open, the
// finished execution's result is fed back to the agent so it runs
// the plan to completion. Without opening it here, approving via
// the button (instead of typing "go ahead") would silently not
// auto-continue.
var agentID *uuid.UUID
if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires)
// Approving a DESTRUCTIVE step via the button is exactly as
// explicit as a typed "I confirm" — the operator affirmatively
// clicked Approve on a card that said DESTRUCTIVE. Open the
// same short, target-scoped destructive window chat-assent's
// typed-confirm path opens, for parity: a multi-step
// destructive recovery (stop, then destroy) shouldn't need a
// fresh confirmation per click any more than it needs one per
// typed phrase.
if riskClass == "destructive" && targetSlug != "" {
dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`,
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires)
}
}
slog.Info("httpapi: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
} else {
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
}
} else {
// Denied/revoked: reflect it on the linked execution too. Previously
// only the approvals row changed, so the execution stayed
// 'pending_approval' forever — any UI/poller reading execution
// status (not approval status) never saw the decision.
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.DecideApproval200JSONResponse(approval), nil
}
func approvalToGen(a sqlcgen.Approval) gen.Approval {
app := gen.Approval{
Id: a.EntityID,
Action: a.Action,
RiskClass: a.RiskClass,
Kind: gen.ApprovalKind(a.Kind),
Status: gen.ApprovalStatus(a.Status),
ExpiresAt: a.ExpiresAt,
DecidedAt: a.DecidedAt,
CreatedAt: a.CreatedAt,
}
if a.DecidedBy != nil {
s := a.DecidedBy.String()
app.DecidedBy = &s
}
var payload map[string]any
if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
app.Payload = &payload
}
return app
}

View File

@@ -0,0 +1,102 @@
package httpapi
import (
"context"
"fmt"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
)
// ─── Autonomy Settings ─────────────────────────────────────────────────
func (s *Server) GetAutonomySettings(ctx context.Context, req gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) {
rows, err := s.pool.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AutonomySetting{}
for rows.Next() {
var as gen.AutonomySetting
if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
return nil, err
}
items = append(items, as)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.AutonomySetting{}
}
return gen.GetAutonomySettings200JSONResponse{Items: items}, nil
}
func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
for key, value := range *req.Body {
_, err := tx.Exec(ctx, `
INSERT INTO autonomy_settings (key, value, version, updated_at)
VALUES ($1, $2, 1, now())
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, version = autonomy_settings.version + 1, updated_at = now()`,
key, value)
if err != nil {
return nil, err
}
}
// Re-read all settings.
rows, err := tx.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.AutonomySetting{}
for rows.Next() {
var as gen.AutonomySetting
if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
return nil, err
}
items = append(items, as)
}
if rows.Err() != nil {
return nil, rows.Err()
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/policy/autonomy", "",
map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchAutonomySettings200JSONResponse{Items: items}, nil
}
// keysOfMap returns the keys of a map[string]string.
func keysOfMap(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

304
internal/httpapi/checks.go Normal file
View File

@@ -0,0 +1,304 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT cd.entity_id, e.slug, cd.kind,
COALESCE(te.slug, '') AS target_slug, cd.target_type,
cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled,
e.version
FROM check_defs cd
JOIN entities e ON e.id = cd.entity_id
LEFT JOIN entities te ON te.id = cd.target_id
WHERE ($1::text IS NULL OR cd.kind = $1)
AND ($2::text IS NULL OR te.slug = $2)
AND ($3::bool IS NULL OR cd.enabled = $3)
AND ($4::text IS NULL OR e.slug > $4)
ORDER BY e.slug
LIMIT $5`,
req.Params.Kind, req.Params.Target, req.Params.Enabled, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Check{}
for rows.Next() {
var c gen.Check
var targetSlug string
var configBytes []byte
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil {
return nil, err
}
if targetSlug != "" {
c.Target = &targetSlug
}
var config map[string]any
if len(configBytes) > 0 && json.Unmarshal(configBytes, &config) == nil && len(config) > 0 {
c.Config = &config
}
items = append(items, c)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Check{}
}
return gen.ListChecks200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
slug := req.Body.Slug
if slug == "" {
slug = "check:" + string(req.Body.Kind) + ":" + uuid.New().String()[:8]
}
// Resolve target if provided.
var targetID *uuid.UUID
if req.Body.Target != nil && *req.Body.Target != "" {
tid, rerr := s.resolveEntityID(ctx, *req.Body.Target)
if rerr != nil {
return nil, rerr
}
targetID = &tid
}
intervalS := int32(300)
if req.Body.IntervalS != nil {
intervalS = int32(*req.Body.IntervalS)
}
timeoutS := int32(30)
if req.Body.TimeoutS != nil {
timeoutS = int32(*req.Body.TimeoutS)
}
enabled := true
if req.Body.Enabled != nil {
enabled = *req.Body.Enabled
}
configJSON := []byte("{}")
if req.Body.Config != nil {
configJSON, _ = json.Marshal(req.Body.Config)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Create the entity row (checks are entities).
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: slug,
Type: "check",
Name: slug,
Attributes: []byte("{}"),
})
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: check %q already exists", domain.ErrAlreadyExists, slug)
}
return nil, err
}
if err := q.InsertCheckDef(ctx, sqlcgen.InsertCheckDefParams{
EntityID: id,
TargetID: targetID,
TargetType: req.Body.TargetType,
Kind: string(req.Body.Kind),
Config: configJSON,
IntervalS: intervalS,
TimeoutS: timeoutS,
Zone: req.Body.Zone,
Enabled: enabled,
}); err != nil {
return nil, err
}
// Build response Check.
check := gen.Check{
Id: id,
Slug: entity.Slug,
Kind: gen.CheckKind(req.Body.Kind),
IntervalS: int(intervalS),
TimeoutS: int(timeoutS),
Enabled: enabled,
TargetType: req.Body.TargetType,
Zone: req.Body.Zone,
Version: int(entity.Version),
}
if req.Body.Config != nil {
check.Config = req.Body.Config
}
if targetID != nil && req.Body.Target != nil {
check.Target = req.Body.Target
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/checks", "",
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateCheck201JSONResponse(check), nil
}
func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
// Parse If-Match
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
expectedVersion, err := parseIntIfMatch(ifMatch)
if err != nil {
return nil, err
}
_ = expectedVersion // check_defs don't track version via If-Match today, but we validate the header is present
if ifMatch == "" {
return nil, fmt.Errorf("%w: invalid If-Match header", domain.ErrInvalidInput)
}
// Get current check def
current, err := sqlcgen.New(s.pool).GetCheckDef(ctx, id)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: check %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Apply patch.
if req.Body.Config != nil {
current.Config, _ = json.Marshal(req.Body.Config)
}
if req.Body.IntervalS != nil {
current.IntervalS = int32(*req.Body.IntervalS)
}
if req.Body.TimeoutS != nil {
current.TimeoutS = int32(*req.Body.TimeoutS)
}
if req.Body.Enabled != nil {
current.Enabled = *req.Body.Enabled
}
if err := sqlcgen.New(tx).UpdateCheckDef(ctx, sqlcgen.UpdateCheckDefParams{
EntityID: id,
Kind: current.Kind,
Config: current.Config,
IntervalS: current.IntervalS,
TimeoutS: current.TimeoutS,
TargetID: current.TargetID,
TargetType: current.TargetType,
Zone: current.Zone,
Enabled: current.Enabled,
}); err != nil {
return nil, err
}
// Re-read to get updated timestamp.
updated, err := sqlcgen.New(tx).GetCheckDef(ctx, id)
if err != nil {
return nil, err
}
check := checkDefToGen(updated)
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchCheck200JSONResponse(check), nil
}
func checkDefToGen(cd sqlcgen.CheckDef) gen.Check {
c := gen.Check{
Id: cd.EntityID,
Kind: gen.CheckKind(cd.Kind),
IntervalS: int(cd.IntervalS),
TimeoutS: int(cd.TimeoutS),
Enabled: cd.Enabled,
TargetType: cd.TargetType,
Zone: cd.Zone,
}
var config map[string]any
if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 {
c.Config = &config
}
return c
}
// parseIntIfMatch parses an integer from a raw If-Match header value (with quotes stripped).
func parseIntIfMatch(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty version")
}
var v int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid version: %q", s)
}
v = v*10 + int(c-'0')
}
return v, nil
}

View File

@@ -0,0 +1,85 @@
package httpapi
import (
"context"
"encoding/json"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/google/uuid"
)
// ─── Classifications ───────────────────────────────────────────────────
func (s *Server) ListClassifications(ctx context.Context, req gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
var route *string
if req.Params.Route != nil {
r := string(*req.Params.Route)
route = &r
}
rows, err := s.pool.Query(ctx, `
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
c.recommended_action, c.risk_class, c.route, c.blast_radius,
c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
c.correlation_id, c.created_at,
e.slug, COALESCE(se.slug, '') AS signal_slug, COALESCE(te.slug, '') AS target_slug
FROM classifications c
LEFT JOIN entities e ON e.id = c.entity_id
LEFT JOIN entities se ON se.id = c.signal_entity_id
LEFT JOIN entities te ON te.id = c.target_entity_id
WHERE ($1::text IS NULL OR c.route = $1)
AND ($2::text IS NULL OR e.slug > $2)
ORDER BY e.slug
LIMIT $3`,
route, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Classification{}
for rows.Next() {
var cls gen.Classification
var recActionJSON []byte
var reasoningJSON []byte
var blastRadius []uuid.UUID
var signalSlug, targetSlug string
if err := rows.Scan(&cls.Id, &cls.SignalId, &targetSlug, &cls.Action,
&recActionJSON, &cls.RiskClass, &cls.Route, &blastRadius,
&cls.PatternConfidence, &cls.SkillId, &cls.AutonomyCheck, &reasoningJSON,
&cls.CorrelationId, &cls.CreatedAt,
&cls.Target, &signalSlug, &targetSlug); err != nil {
return nil, err
}
if targetSlug != "" {
cls.Target = &targetSlug
}
var reasoning map[string]any
if json.Unmarshal(reasoningJSON, &reasoning) == nil {
cls.Reasoning = reasoning
}
if len(blastRadius) > 0 {
br := make([]string, len(blastRadius))
for i, id := range blastRadius {
br[i] = id.String()
}
cls.BlastRadius = &br
}
items = append(items, cls)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
if items[len(items)-1].Target != nil {
next = items[len(items)-1].Target
}
}
if items == nil {
items = []gen.Classification{}
}
return gen.ListClassifications200JSONResponse{Items: items, NextCursor: next}, nil
}

View File

@@ -0,0 +1,160 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
)
// ─── Entity Types (Ontology) ───────────────────────────────────────────
func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
isAbstract := false
if req.Body.IsAbstract != nil {
isAbstract = *req.Body.IsAbstract
}
attrsSchemaJSON := []byte("null")
if req.Body.AttributeSchema != nil {
attrsSchemaJSON, _ = json.Marshal(req.Body.AttributeSchema)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`,
req.Body.Name, req.Body.ParentType, isAbstract, req.Body.Domain,
string(req.Body.Layer), req.Body.Description, req.Body.LifecycleId, attrsSchemaJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: entity type %q already exists", domain.ErrAlreadyExists, req.Body.Name)
}
return nil, err
}
// Re-read.
var et gen.EntityType
var schemaBytes []byte
err = tx.QueryRow(ctx, `
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status
FROM entity_types WHERE name = $1`, req.Body.Name).
Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
if err != nil {
return nil, err
}
var schema map[string]any
if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/ontology/entity-types", "",
map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateEntityType201JSONResponse(et), nil
}
func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Build dynamic update.
sets := []string{}
args := []any{}
argIdx := 2
if req.Body.Description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *req.Body.Description)
argIdx++
}
if req.Body.Status != nil {
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
args = append(args, string(*req.Body.Status))
argIdx++
}
if req.Body.AttributeSchema != nil {
schemaJSON, _ := json.Marshal(req.Body.AttributeSchema)
sets = append(sets, fmt.Sprintf("attribute_schema = $%d", argIdx))
args = append(args, schemaJSON)
argIdx++
}
if len(sets) == 0 {
return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput)
}
sets = append(sets, "schema_version = schema_version + 1, updated_at = now()")
query := fmt.Sprintf(`UPDATE entity_types SET %s WHERE name = $1`, strings.Join(sets, ", "))
finalArgs := append([]any{req.Name}, args...)
result, err := tx.Exec(ctx, query, finalArgs...)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Name)
}
// Re-read.
var et gen.EntityType
var schemaBytes []byte
err = tx.QueryRow(ctx, `
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status
FROM entity_types WHERE name = $1`, req.Name).
Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
if err != nil {
return nil, err
}
var schema map[string]any
if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "",
map[string]any{"status": req.Body.Status}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchEntityType200JSONResponse(et), nil
}

View File

@@ -0,0 +1,267 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Executions ────────────────────────────────────────────────────────
func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE ($1::text IS NULL OR e.status = $1)
AND ($2::text IS NULL OR te.slug > $2)
ORDER BY te.slug
LIMIT $3`,
req.Params.Status, req.Params.Cursor, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Execution{}
for rows.Next() {
var exec gen.Execution
var resultBytes []byte
var targetSlug string
if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug); err != nil {
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
// Target is stored as UUID, but we surface the slug
exec.Slug = targetSlug
items = append(items, exec)
}
if rows.Err() != nil {
return nil, rows.Err()
}
var next *string
if len(items) > limit {
items = items[:limit]
next = &items[len(items)-1].Slug
}
if items == nil {
items = []gen.Execution{}
}
return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil
}
func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = s.pool.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
var result map[string]any
if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
exec.Result = &result
}
exec.Slug = targetSlug
return gen.GetExecution200JSONResponse(exec), nil
}
func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
if err != nil {
return nil, err
}
correlationID := uuid.New().String()
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7
// collides for real under back-to-back requests since the leading bytes
// encode a millisecond timestamp (observed live via the MCP run tool).
execSlug := "exec:" + id.String()
if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: execSlug,
Type: "execution",
Name: req.Body.Action + " on " + req.Body.Target,
Attributes: []byte("{}"),
}); err != nil {
return nil, err
}
if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
EntityID: id,
TargetEntityID: &targetID,
Action: req.Body.Action,
RiskClass: "unclassified", // will be classified by classifier
CorrelationID: correlationID,
}); err != nil {
return nil, err
}
// Re-read to get the full record.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&id, "POST", "/api/v1/executions", "",
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
return nil, auditErr
}
if eventErr := observability.Event(ctx, q, "execution.requested", &id,
"info", "oikos-api", "",
map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil {
return nil, eventErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.RequestExecution201JSONResponse(exec), nil
}
func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: id,
Status: "cancelled",
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
// Re-read.
var exec gen.Execution
var resultBytes []byte
var targetSlug string
err = tx.QueryRow(ctx, `
SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
e.target_entity_id, e.action, e.risk_class,
e.approval_id::text, e.agent_id::text, e.skill_id::text,
e.skill_version, e.status, e.result, e.duration_ms,
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
te.slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE e.entity_id = $1`, id).
Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
&exec.Target, &exec.Action, &exec.RiskClass,
&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
&exec.CreatedAt, &targetSlug)
if err != nil {
return nil, err
}
exec.Slug = targetSlug
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
map[string]any{"status": "cancelled"}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CancelExecution200JSONResponse(exec), nil
}

View File

@@ -0,0 +1,32 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// ─── Helpers ───────────────────────────────────────────────────────────
func coalesceStr(s *string, def string) string {
if s == nil || *s == "" {
return def
}
return *s
}
func parseIntOrZero(s string) (int, error) {
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("invalid integer: %q", s)
}
n = n*10 + int(c-'0')
}
return n, nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}

View File

@@ -60,19 +60,17 @@ func clampLimit(l *int) int {
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID. // resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) { func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
if id, err := uuid.Parse(idOrSlug); err == nil { if id, err := uuid.Parse(idOrSlug); err == nil {
var found uuid.UUID entity, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE id = $1", id).Scan(&found) if err != nil {
if err == pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug) return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
} }
return found, err return entity.ID, nil
} }
var id uuid.UUID entity, err := sqlcgen.New(s.pool).GetEntityBySlug(ctx, idOrSlug)
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id) if err != nil {
if err == pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug) return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
} }
return id, err return entity.ID, nil
} }
// entityCols requires the entities table to be aliased as `e`, with // entityCols requires the entities table to be aliased as `e`, with
@@ -188,46 +186,37 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati
if req.Params.Direction != nil { if req.Params.Direction != nil {
dir = string(*req.Params.Direction) dir = string(*req.Params.Direction)
} }
rows, err := s.pool.Query(ctx, ` relType := req.Params.RelType
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
FROM relationships r Direction: dir,
JOIN entities se ON se.id = r.source_id ID: id,
JOIN entities te ON te.id = r.target_id RelType: relType,
WHERE r.valid_to IS NULL })
AND (($3 IN ('out','both') AND r.source_id = $1)
OR ($3 IN ('in','both') AND r.target_id = $1))
AND ($2::text IS NULL OR r.type = $2)
ORDER BY r.type, se.slug, te.slug`,
id, req.Params.RelType, dir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items, err := scanRelationships(rows) items := []gen.Relationship{}
if err != nil { for _, r := range rows {
return nil, err var attrs *map[string]any
if len(r.Attributes) > 0 {
var m map[string]any
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
attrs = &m
}
}
validTo := r.ValidTo
items = append(items, gen.Relationship{
Source: r.SourceSlug,
Target: r.TargetSlug,
Type: r.Type,
Attributes: attrs,
ValidFrom: r.ValidFrom,
ValidTo: validTo,
})
} }
return gen.GetEntityRelations200JSONResponse{Items: items}, nil return gen.GetEntityRelations200JSONResponse{Items: items}, nil
} }
func scanRelationships(rows pgx.Rows) ([]gen.Relationship, error) {
defer rows.Close()
items := []gen.Relationship{}
for rows.Next() {
var rel gen.Relationship
var attrsJSON []byte
if err := rows.Scan(&rel.Source, &rel.Target, &rel.Type,
&attrsJSON, &rel.ValidFrom, &rel.ValidTo); err != nil {
return nil, err
}
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
rel.Attributes = &attrs
}
items = append(items, rel)
}
return items, rows.Err()
}
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) { func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id) id, err := s.resolveEntityID(ctx, req.Id)
if err != nil { if err != nil {
@@ -336,21 +325,31 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
for i, n := range nodes { for i, n := range nodes {
ids[i] = uuid.UUID(n.Id) ids[i] = uuid.UUID(n.Id)
} }
rows, err := s.pool.Query(ctx, ` edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to Ids: ids,
FROM relationships r RelTypes: *req.Params.RelType,
JOIN entities se ON se.id = r.source_id })
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND r.source_id = ANY($1) AND r.target_id = ANY($1)
AND ($2::text[] IS NULL OR r.type = ANY($2))
ORDER BY r.type, se.slug, te.slug`, ids, req.Params.RelType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
edges, err := scanRelationships(rows) edges := []gen.Relationship{}
if err != nil { for _, r := range edgeRows {
return nil, err var attrs *map[string]any
if len(r.Attributes) > 0 {
var m map[string]any
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
attrs = &m
}
}
validTo := r.ValidTo
edges = append(edges, gen.Relationship{
Source: r.SourceSlug,
Target: r.TargetSlug,
Type: r.Type,
Attributes: attrs,
ValidFrom: r.ValidFrom,
ValidTo: validTo,
})
} }
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges} resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
@@ -421,78 +420,70 @@ func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObje
Lifecycles: []gen.LifecycleDef{}, Lifecycles: []gen.LifecycleDef{},
} }
rows, err := s.pool.Query(ctx, ` q := sqlcgen.New(s.pool)
SELECT name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, schema_version, status etRows, err := q.ListEntityTypes(ctx)
FROM entity_types ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for _, et := range etRows {
var et gen.EntityType schemaVersion := int(et.SchemaVersion)
var schemaVersion int var schema *map[string]any
var schemaJSON []byte if len(et.AttributeSchema) > 0 {
if err := rows.Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, var s map[string]any
&et.Layer, &et.Description, &et.LifecycleId, &schemaJSON, if json.Unmarshal(et.AttributeSchema, &s) == nil && s != nil {
&schemaVersion, &et.Status); err != nil { schema = &s
rows.Close()
return nil, err
} }
et.SchemaVersion = &schemaVersion
var schema map[string]any
if len(schemaJSON) > 0 && json.Unmarshal(schemaJSON, &schema) == nil && schema != nil {
et.AttributeSchema = &schema
} }
resp.EntityTypes = append(resp.EntityTypes, et) resp.EntityTypes = append(resp.EntityTypes, gen.EntityType{
} Name: et.Name,
rows.Close() ParentType: et.ParentType,
if rows.Err() != nil { IsAbstract: et.IsAbstract,
return nil, rows.Err() Domain: et.Domain,
Layer: gen.EntityTypeLayer(et.Layer),
Description: et.Description,
LifecycleId: et.LifecycleID,
SchemaVersion: &schemaVersion,
AttributeSchema: schema,
Status: gen.EntityTypeStatus(et.Status),
})
} }
rows, err = s.pool.Query(ctx, ` rtRows, err := q.ListRelationshipTypes(ctx)
SELECT name, inverse, source_type, target_type, cardinality, description
FROM relationship_types ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for _, rt := range rtRows {
var rt gen.RelationshipType resp.RelationshipTypes = append(resp.RelationshipTypes, gen.RelationshipType{
if err := rows.Scan(&rt.Name, &rt.Inverse, &rt.SourceType, &rt.TargetType, Name: rt.Name,
&rt.Cardinality, &rt.Description); err != nil { Inverse: rt.Inverse,
rows.Close() SourceType: rt.SourceType,
return nil, err TargetType: rt.TargetType,
} Cardinality: gen.RelationshipTypeCardinality(rt.Cardinality),
resp.RelationshipTypes = append(resp.RelationshipTypes, rt) Description: rt.Description,
} })
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
} }
rows, err = s.pool.Query(ctx, ` lcRows, err := q.ListLifecycleDefs(ctx)
SELECT id, states, default_state, terminal_states, transitions
FROM lifecycle_defs ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for _, lc := range lcRows {
var lc gen.LifecycleDef terminal := lc.TerminalStates
var terminal []string var transitions map[string]any
var transJSON []byte if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
if err := rows.Scan(&lc.Id, &lc.States, &lc.DefaultState, &terminal, &transJSON); err != nil { return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.ID, err)
rows.Close()
return nil, err
} }
lc.TerminalStates = &terminal resp.Lifecycles = append(resp.Lifecycles, gen.LifecycleDef{
if err := json.Unmarshal(transJSON, &lc.Transitions); err != nil { Id: lc.ID,
rows.Close() States: lc.States,
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.Id, err) DefaultState: lc.DefaultState,
TerminalStates: &terminal,
Transitions: transitions,
})
} }
resp.Lifecycles = append(resp.Lifecycles, lc)
} return resp, nil
rows.Close()
return resp, rows.Err()
} }
// ─── Signals ────────────────────────────────────────────────────────── // ─── Signals ──────────────────────────────────────────────────────────
@@ -1605,10 +1596,9 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
} }
} }
case "health-check-answering": case "health-check-answering":
var health string st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
err := tx.QueryRow(ctx, "SELECT health FROM entity_status WHERE entity_id = $1", entityID).Scan(&health) if err != nil || st.Health == "unknown" || st.Health == "down" {
if err != nil || health == "unknown" || health == "down" { return fmt.Errorf("health check not answering (status: %s)", st.Health)
return fmt.Errorf("health check not answering (status: %s)", health)
} }
case "doc-page-complete": case "doc-page-complete":
var count int var count int

180
internal/httpapi/metrics.go Normal file
View File

@@ -0,0 +1,180 @@
package httpapi
import (
"context"
"fmt"
"time"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/jackc/pgx/v5/pgtype"
)
// ─── Metrics ───────────────────────────────────────────────────────────
func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
}
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
to := time.Now()
if req.Params.To != nil {
to = *req.Params.To
}
var metricNames []string
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
metricNames = *req.Params.Metric
} else {
// metric omitted: report every metric recorded for this entity in range.
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT metric FROM metric_samples
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY metric`, entityID, from, to)
if err != nil {
return nil, err
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return nil, err
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
items := []gen.MetricSeries{}
for _, metricName := range metricNames {
series := gen.MetricSeries{
EntityId: entityID.String(),
Metric: metricName,
Rollup: gen.MetricSeriesRollupRaw,
}
rows, err := s.pool.Query(ctx, `
SELECT ts, value
FROM metric_samples
WHERE entity_id = $1 AND metric = $2
AND ts >= $3 AND ts <= $4
ORDER BY ts ASC`,
entityID, metricName, from, to)
if err != nil {
return nil, err
}
samples := []struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{}
for rows.Next() {
var ts time.Time
var val float64
if err := rows.Scan(&ts, &val); err != nil {
rows.Close()
return nil, err
}
f := float32(val)
samples = append(samples, struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{Value: &f, Ts: ts})
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
series.Samples = samples
items = append(items, series)
}
if items == nil {
items = []gen.MetricSeries{}
}
return gen.QueryMetrics200JSONResponse{Items: items}, nil
}
func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
entityID, err := s.resolveEntityID(ctx, req.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-7 * 24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
rows, err := s.pool.Query(ctx, `
SELECT metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(stddev(value)::numeric, 2) AS std_val,
count(*) AS sample_count,
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
FROM metric_samples
WHERE entity_id = $1 AND ts >= $2
GROUP BY metric
ORDER BY metric`, entityID, from)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Trend{}
for rows.Next() {
var t gen.Trend
var avgVal, stdVal, slopeNum pgtype.Numeric
var sampleCount int
if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil {
return nil, err
}
// Determine direction.
if slopeNum.Valid {
f, _ := slopeNum.Float64Value()
t.Slope = float32Ptr(float32(f.Float64))
if f.Float64 > 0.01 {
t.Direction = gen.Improving
} else if f.Float64 < -0.01 {
t.Direction = gen.Degrading
} else {
t.Direction = gen.Stable
}
} else {
t.Direction = gen.Unknown
}
items = append(items, t)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Trend{}
}
return gen.GetTrends200JSONResponse{Items: items}, nil
}
func float32Ptr(f float32) *float32 {
return &f
}

View File

@@ -0,0 +1,123 @@
package httpapi
import (
"context"
"fmt"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/jackc/pgx/v5"
)
// ─── Patterns ──────────────────────────────────────────────────────────
func (s *Server) ListPatterns(ctx context.Context, req gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT p.entity_id, e.slug, p.applies_type, p.action, p.pattern, p.confidence,
p.evidence_count, p.success_count, p.failure_count, p.status,
p.quarantined, p.version, p.last_validated_at
FROM patterns p
JOIN entities e ON e.id = p.entity_id
WHERE ($1::text IS NULL OR p.status = $1)
AND ($2::text IS NULL OR p.applies_type = $2)
AND ($3::text IS NULL OR p.action = $3)
ORDER BY p.applies_type, p.action
LIMIT $4`,
req.Params.Status, req.Params.EntityType, req.Params.Action, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Pattern{}
for rows.Next() {
var p gen.Pattern
if err := rows.Scan(&p.Id, &p.Slug, &p.AppliesType, &p.Action, &p.Pattern,
&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt); err != nil {
return nil, err
}
items = append(items, p)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Pattern{}
}
return gen.ListPatterns200JSONResponse{Items: items}, nil
}
func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if req.Body.Status != nil {
status := string(*req.Body.Status)
if err := q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: id,
Status: status,
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
}
if req.Body.Quarantined != nil {
if err := q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: id,
Quarantined: *req.Body.Quarantined,
}); err != nil {
return nil, err
}
}
// Re-read.
var p gen.Pattern
err = tx.QueryRow(ctx, `
SELECT entity_id, applies_type, action, pattern, confidence,
evidence_count, success_count, failure_count, status,
quarantined, version, last_validated_at
FROM patterns WHERE entity_id = $1`, id).
Scan(&p.Id, &p.AppliesType, &p.Action, &p.Pattern,
&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/patterns/"+req.Id, "",
map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchPattern200JSONResponse(p), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
)
// ─── Relationships ─────────────────────────────────────────────────────
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
sourceID, err := s.resolveEntityID(ctx, req.Body.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
if err != nil {
return nil, err
}
attrsJSON := []byte("{}")
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
sourceID, targetID, req.Body.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
}
return nil, err
}
rel := gen.Relationship{
Source: req.Body.Source,
Target: req.Body.Target,
Type: req.Body.Type,
ValidFrom: time.Now(),
}
if req.Body.Attributes != nil {
rel.Attributes = req.Body.Attributes
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/relationships", "",
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateRelationship201JSONResponse(rel), nil
}
func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
sourceID, err := s.resolveEntityID(ctx, req.Params.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Params.Target)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
SourceID: sourceID,
TargetID: targetID,
Type: req.Params.RelType,
})
if err != nil {
return nil, err
}
if result == 0 {
return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
nil, "DELETE", "/api/v1/relationships", "",
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.EndRelationship204Response{}, nil
}

View File

@@ -0,0 +1,33 @@
package httpapi
import (
"context"
"github.com/dtoro/oikos/internal/httpapi/gen"
)
// ─── Risk Classes ──────────────────────────────────────────────────────
func (s *Server) ListRiskClasses(ctx context.Context, req gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) {
rows, err := s.pool.Query(ctx, `SELECT name, description, approval_required, autonomy_allowed FROM risk_classes ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.RiskClass{}
for rows.Next() {
var rc gen.RiskClass
if err := rows.Scan(&rc.Name, &rc.Description, &rc.ApprovalRequired, &rc.AutonomyAllowed); err != nil {
return nil, err
}
items = append(items, rc)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.RiskClass{}
}
return gen.ListRiskClasses200JSONResponse{Items: items}, nil
}

View File

@@ -97,6 +97,25 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
MaxAge: 86400, MaxAge: 86400,
})) }))
// ─── Non-OpenAPI routes (carve-out) ────────────────────────────────
//
// These routes are registered manually on the chi router rather than
// generated from api/openapi.yaml. Each has a structural reason it
// can't go through the strict-server codegen:
//
// /healthz — infra liveness probe, no auth, no /api/v1 prefix
// /api/v1/auth/oidc-* — auth flow, must run before auth middleware
// /oidc-callback — standalone HTML page, not a JSON API
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
// /api/v1/activity/recent — recency-ordered, not paginated
// /api/v1/activity/session/{id} — session-scoped aggregation
// /api/v1/learning/timeline — derived view, no backing schema type
// /api/v1/learning/trend — derived view, no backing schema type
//
// See .agents/dev/CONTRIBUTING.md §OpenAPI codegen for the policy.
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy. // Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second) ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
@@ -173,23 +192,27 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the // Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
// Knowledge page's "what the system has learned" view. Registered after // Knowledge page's "what the system has learned" view. Registered after
// HandlerWithOptions so it wins over any generated catch-all. // HandlerWithOptions so it wins over any generated catch-all.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
// Custom (non-OpenAPI) route: full markdown content for a knowledge // Custom (non-OpenAPI) route: full markdown content for a knowledge
// entity (document/investigation/runbook) by its own id or slug — the // entity (document/investigation/runbook) by its own id or slug — the
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a // generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
// different question (knowledge referencing this entity), not this one. // different question (knowledge referencing this entity), not this one.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent) r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered, // Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the // unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest. // per-session "what did this session do" digest.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity) r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
// Learning view: capability timeline + success trend, both derived from // Learning view: capability timeline + success trend, both derived from
// executions (real, growing data) rather than the patterns/skills tables, // executions (real, growing data) rather than the patterns/skills tables,
// which are correctly modeled but have no writers anywhere yet. // which are correctly modeled but have no writers anywhere yet.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline) r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend) r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)

196
internal/httpapi/skills.go Normal file
View File

@@ -0,0 +1,196 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ─── Skills ────────────────────────────────────────────────────────────
func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) {
limit := clampLimit(req.Params.Limit)
rows, err := s.pool.Query(ctx, `
SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type,
s.action, s.pattern_ids, s.status, s.success_rate,
s.changed_by::text, s.change_reason, s.last_used_at
FROM skills s
WHERE ($1::text IS NULL OR s.status = $1)
AND ($2::text IS NULL OR s.applies_type = $2)
AND ($3::text IS NULL OR s.action = $3)
ORDER BY s.name, s.version DESC`,
req.Params.Status, req.Params.AppliesTo, req.Params.Action)
if err != nil {
return nil, err
}
defer rows.Close()
// Deduplicate to latest version per skill (the ORDER BY name, version DESC
// means the first row per name is the latest).
seen := map[string]bool{}
items := []gen.Skill{}
for rows.Next() {
var s gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType,
&s.Action, &patternIDs, &s.Status, &s.SuccessRate,
&s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil {
return nil, err
}
if seen[s.Id.String()] {
continue
}
seen[s.Id.String()] = true
if err := json.Unmarshal(procBytes, &s.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
s.PatternIds = &pids
}
items = append(items, s)
if len(items) > limit {
break
}
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Skill{}
}
return gen.ListSkills200JSONResponse{Items: items}, nil
}
func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
if req.Body.Status != nil {
if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{
EntityID: id,
Status: string(*req.Body.Status),
}); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
}
// Re-read skill.
var skill gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
err = tx.QueryRow(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at
FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id).
Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill proc", "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
skill.PatternIds = &pids
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
&id, "PATCH", "/api/v1/skills/"+req.Id, "",
map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.PatchSkill200JSONResponse(skill), nil
}
func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) {
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT entity_id, version, name, procedure, applies_type, action,
pattern_ids, status, success_rate, changed_by::text,
change_reason, last_used_at
FROM skills WHERE entity_id = $1
ORDER BY version DESC`, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Skill{}
for rows.Next() {
var skill gen.Skill
var procBytes []byte
var patternIDs []uuid.UUID
if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
slog.Warn("phase3: unmarshal skill proc", "error", err)
}
if len(patternIDs) > 0 {
pids := make([]string, len(patternIDs))
for i, pid := range patternIDs {
pids[i] = pid.String()
}
skill.PatternIds = &pids
}
items = append(items, skill)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Skill{}
}
return gen.ListSkillVersions200JSONResponse{Items: items}, nil
}

View File

@@ -1,6 +0,0 @@
package httpapi
// Remaining stubs for endpoints that depend on tables not yet created
// (knowledge_entities, agent_activity). These are kept here because the
// phase3.go file already defines them; this file is deliberately empty.
// The stubs live in phase3.go as simple errNotImplemented returns.

View File

@@ -0,0 +1,155 @@
package knowledge
import (
"crypto/sha256"
"encoding/hex"
"reflect"
"testing"
)
func TestContentHash(t *testing.T) {
t.Run("determinism", func(t *testing.T) {
a := contentHash("hello")
b := contentHash("hello")
if a != b {
t.Errorf("contentHash not deterministic: %q != %q", a, b)
}
})
t.Run("empty string known sha256", func(t *testing.T) {
got := contentHash("")
h := sha256.Sum256([]byte(""))
want := hex.EncodeToString(h[:])
if got != want {
t.Errorf("contentHash(\"\") = %q, want %q", got, want)
}
})
t.Run("different inputs different outputs", func(t *testing.T) {
if contentHash("a") == contentHash("b") {
t.Error("different inputs produced same hash")
}
})
t.Run("output is 64-char hex", func(t *testing.T) {
got := contentHash("anything")
if len(got) != 64 {
t.Errorf("len = %d, want 64", len(got))
}
for _, r := range got {
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
if !isHex {
t.Errorf("non-hex char %q in hash %q", r, got)
break
}
}
})
}
func TestStr(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want string
}{
{"missing key", map[string]any{}, "nope", ""},
{"string value", map[string]any{"k": "v"}, "k", "v"},
{"int value", map[string]any{"k": 42}, "k", ""},
{"nil value", map[string]any{"k": nil}, "k", ""},
{"empty string", map[string]any{"k": ""}, "k", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := str(c.m, c.key)
if got != c.want {
t.Errorf("str() = %q, want %q", got, c.want)
}
})
}
}
func TestStrSlice(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want []string
}{
{"missing key", map[string]any{}, "tags", nil},
{"all strings", map[string]any{"tags": []any{"a", "b", "c"}}, "tags", []string{"a", "b", "c"}},
{"mixed types", map[string]any{"tags": []any{1, "a", true, "b"}}, "tags", []string{"a", "b"}},
{"empty array", map[string]any{"tags": []any{}}, "tags", []string{}},
{"nil elements filtered", map[string]any{"tags": []any{nil, "a", nil, "b"}}, "tags", []string{"a", "b"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := strSlice(c.m, c.key)
if len(got) != len(c.want) {
t.Errorf("len = %d, want %d (got %v)", len(got), len(c.want), got)
return
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], c.want[i])
}
}
})
}
}
func TestMapVal(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want map[string]any
}{
{"missing key", map[string]any{}, "nope", nil},
{"present map", map[string]any{"k": map[string]any{"x": 1}}, "k", map[string]any{"x": 1}},
{"wrong type string", map[string]any{"k": "v"}, "k", nil},
{"nested map", map[string]any{"k": map[string]any{"a": map[string]any{"b": 2}}}, "k", map[string]any{"a": map[string]any{"b": 2}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := mapVal(c.m, c.key)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("mapVal() = %v, want %v", got, c.want)
}
})
}
}
func TestToPGArray(t *testing.T) {
cases := []struct {
name string
tags []string
want string
}{
{"empty", []string{}, "{}"},
{"single", []string{"a"}, `{"a"}`},
{"multiple", []string{"a", "b"}, `{"a","b"}`},
{"nil", nil, "{}"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := toPGArray(c.tags)
if got != c.want {
t.Errorf("toPGArray() = %q, want %q", got, c.want)
}
})
}
// Special chars: tags containing " or \ are NOT escaped by toPGArray.
// This is a latent bug — Postgres array literals require these to be
// backslash-escaped. Test documents current behavior so a fix is
// detectable. Should be fixed.
t.Run("special chars unescaped (current buggy behavior)", func(t *testing.T) {
got := toPGArray([]string{`a"b`, `c\d`})
// Current output: {"a"b","c\d"} — invalid Postgres array literal.
want := `{"a"b","c\d"}`
if got != want {
t.Errorf("toPGArray(special) = %q, want %q (if this changed, the escaping bug was fixed — update this test)", got, want)
}
})
}

View File

@@ -83,27 +83,13 @@ func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) ti
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) { appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
successCount := 0 successCount, failureCount := countOutcomes(items)
failureCount := 0
for _, f := range items {
switch f.Outcome {
case "success":
successCount++
case "failure", "unexpected":
failureCount++
case "partial":
successCount++ // partial counts as half-success
}
}
total := successCount + failureCount total := successCount + failureCount
if total == 0 { if total == 0 {
return return
} }
// Compute Wilson score lower bound confidence := computeConfidence(successCount, failureCount)
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
// Cap by sample size: nothing looks confident before 5 samples
confidence = math.Min(confidence, float64(total)/5.0)
// Get or create pattern — first look up existing entity, then upsert. // Get or create pattern — first look up existing entity, then upsert.
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{ existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
return return
} }
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined { if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) {
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{ _ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: pat.EntityID, EntityID: pat.EntityID,
Status: "validated", Status: "validated",
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
"confidence", confidence, "samples", total) "confidence", confidence, "samples", total)
} }
// Anomaly check: >10 identical outcomes within 1h if shouldQuarantine(total) {
if total > 10 {
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{ _ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: pat.EntityID, EntityID: pat.EntityID,
Quarantined: true, Quarantined: true,
@@ -169,6 +154,45 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
} }
} }
// countOutcomes tallies feedback items into success and failure counts.
// "partial" counts as a half-success (increments success).
func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) {
for _, f := range items {
switch f.Outcome {
case "success":
success++
case "failure", "unexpected":
failure++
case "partial":
success++
}
}
return success, failure
}
// computeConfidence calculates the Wilson score lower bound, capped by
// sample size (nothing looks confident before 5 samples).
func computeConfidence(success, failure int) float64 {
total := success + failure
if total == 0 {
return 0
}
confidence := wilsonLowerBound(float64(success), float64(total), 0.95)
return math.Min(confidence, float64(total)/5.0)
}
// shouldValidate returns true when a pattern has enough evidence and
// confidence to be promoted from "hypothesized" to "validated".
func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool {
return evidenceCount >= 5 && confidence >= 0.7 && !quarantined
}
// shouldQuarantine returns true when an anomaly burst is detected
// (>10 identical outcomes, indicating a runaway loop rather than organic feedback).
func shouldQuarantine(total int) bool {
return total > 10
}
// wilsonLowerBound computes the Wilson score interval lower bound. // wilsonLowerBound computes the Wilson score interval lower bound.
// Conservative estimate of success rate for small sample sizes. // Conservative estimate of success rate for small sample sizes.
func wilsonLowerBound(success, total, z float64) float64 { func wilsonLowerBound(success, total, z float64) float64 {

View File

@@ -0,0 +1,211 @@
package learning
import (
"math"
"testing"
"github.com/dtoro/oikos/internal/db/sqlcgen"
)
func TestWilsonLowerBound(t *testing.T) {
tests := []struct {
name string
success float64
total float64
z float64
want float64
// For approximate checks
approx bool
epsilon float64
}{
{"zero total", 5, 0, 1.96, 0, false, 0},
{"zero success", 0, 10, 1.96, 0, true, 1e-10},
{"all success small n", 3, 3, 1.96, 0, true, 0.5},
{"all success large n", 100, 100, 1.96, 0, true, 0.05},
{"half success large n", 50, 100, 1.96, 0.39, true, 0.02},
{"higher z gives lower bound", 8, 10, 3.0, 0, true, 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := wilsonLowerBound(tt.success, tt.total, tt.z)
if tt.approx {
if tt.want > 0 && math.Abs(got-tt.want) > tt.epsilon {
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want ~%v (±%v)", tt.success, tt.total, tt.z, got, tt.want, tt.epsilon)
}
if got < 0 {
t.Errorf("wilsonLowerBound returned negative: %v", got)
}
if got > 1 {
t.Errorf("wilsonLowerBound returned >1: %v", got)
}
} else {
if got != tt.want {
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want %v", tt.success, tt.total, tt.z, got, tt.want)
}
}
})
}
}
func TestWilsonLowerBoundMonotonic(t *testing.T) {
prev := 0.0
for s := 0.0; s <= 20; s++ {
got := wilsonLowerBound(s, 20, 1.96)
if got < prev-1e-9 {
t.Errorf("not monotonically increasing: s=%v got=%v prev=%v", s, got, prev)
}
prev = got
}
}
func TestCountOutcomes(t *testing.T) {
tests := []struct {
name string
items []sqlcgen.GetFeedbackAfterWatermarkRow
wantSuccess int
wantFailure int
}{
{
"empty",
nil,
0, 0,
},
{
"all success",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "success"},
},
2, 0,
},
{
"all failure",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "failure"},
{Outcome: "unexpected"},
},
0, 2,
},
{
"mixed including partial",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "failure"},
{Outcome: "partial"},
{Outcome: "unexpected"},
},
2, 2,
},
{
"unknown outcome ignored",
[]sqlcgen.GetFeedbackAfterWatermarkRow{
{Outcome: "success"},
{Outcome: "bogus"},
},
1, 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, f := countOutcomes(tt.items)
if s != tt.wantSuccess || f != tt.wantFailure {
t.Errorf("countOutcomes() = (%d, %d), want (%d, %d)", s, f, tt.wantSuccess, tt.wantFailure)
}
})
}
}
func TestComputeConfidence(t *testing.T) {
tests := []struct {
name string
success int
failure int
wantMax float64
wantMin float64
}{
{"zero total", 0, 0, 0, 0},
{"one success no failures", 1, 0, 0.2, 0},
{"five success no failures", 5, 0, 1.0, 0.3},
{"ten success no failures", 10, 0, 1.0, 0.5},
{"half success", 5, 5, 0.5, 0.2},
{"all failures", 0, 10, 0.01, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeConfidence(tt.success, tt.failure)
if got < 0 || got > 1 {
t.Errorf("confidence out of [0,1]: %v", got)
}
if got > tt.wantMax+0.01 {
t.Errorf("confidence too high: got %v, max ~%v", got, tt.wantMax)
}
if tt.wantMin > 0 && got < tt.wantMin-0.1 {
t.Errorf("confidence too low: got %v, min ~%v", got, tt.wantMin)
}
})
}
}
func TestComputeConfidenceSampleSizeCap(t *testing.T) {
got := computeConfidence(1, 0)
if got > 0.2+1e-9 {
t.Errorf("sample size cap not applied: 1 sample should cap at 1/5=0.2, got %v", got)
}
got = computeConfidence(4, 0)
if got > 0.8+1e-9 {
t.Errorf("sample size cap not applied: 4 samples should cap at 4/5=0.8, got %v", got)
}
}
func TestShouldValidate(t *testing.T) {
tests := []struct {
name string
evidenceCount int
confidence float64
quarantined bool
want bool
}{
{"enough evidence and confidence", 5, 0.7, false, true},
{"high evidence high confidence", 10, 0.9, false, true},
{"not enough evidence", 4, 0.9, false, false},
{"not enough confidence", 5, 0.69, false, false},
{"quarantined blocks validation", 10, 0.9, true, false},
{"exactly at threshold", 5, 0.7, false, true},
{"zero evidence", 0, 0.9, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldValidate(tt.evidenceCount, tt.confidence, tt.quarantined)
if got != tt.want {
t.Errorf("shouldValidate(%d, %v, %v) = %v, want %v", tt.evidenceCount, tt.confidence, tt.quarantined, got, tt.want)
}
})
}
}
func TestShouldQuarantine(t *testing.T) {
tests := []struct {
name string
total int
want bool
}{
{"zero", 0, false},
{"small", 5, false},
{"at threshold", 10, false},
{"over threshold", 11, true},
{"large burst", 50, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldQuarantine(tt.total)
if got != tt.want {
t.Errorf("shouldQuarantine(%d) = %v, want %v", tt.total, got, tt.want)
}
})
}
}

View File

@@ -68,708 +68,9 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
Logger: slog.Default(), Logger: slog.Default(),
}) })
for _, t := range allTools(pool, agentID) {
register := func(tool *mcp.Tool, handler toolHandler) { s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler))
} }
register(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
idOrSlug, _ := args["slug_or_id"].(string)
return queryEntity(ctx, pool, idOrSlug), nil
})
register(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
InputSchema: objSchema(
prop{"type", "string", "Filter by entity type"},
prop{"state", "string", "Filter by lifecycle state"},
prop{"q", "string", "Substring match on slug or name"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
FROM entities e
WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
ORDER BY e.slug LIMIT $4`,
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
})
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
return queryRows(ctx, pool, `
SELECT r.type, src.slug AS source, tgt.slug AS target
FROM relationships r
JOIN entities src ON src.id = r.source_id
JOIN entities tgt ON tgt.id = r.target_id
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
ORDER BY r.type`, slug), nil
})
register(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
prop{"depth", "integer", "Traversal depth (default 3)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
depth := int(getFloat(args, "depth", 3))
return queryRows(ctx, pool,
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
slug, depth), nil
})
register(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check'
ORDER BY e.slug`), nil
})
register(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
FROM audit_log
WHERE ($1::text IS NULL OR entity_id::text = $1)
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
})
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
q := nStr(args["query"])
return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1),
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
FragmentDelimiter=" ... "') AS snippet,
ke.source, ke.tags
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.search @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20`, q), "knowledge_results"), nil
})
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN relationships r ON r.source_id = ke.entity_id
JOIN entities target ON target.id = r.target_id
WHERE target.slug = $1
AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
UNION
SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN relationships r ON r.source_id = ke.entity_id
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL
AND r.type = 'procedure-for'
ORDER BY 1`, slug), "knowledge_results"), nil
})
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
return queryRows(ctx, pool, `
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1`, slug), nil
})
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
InputSchema: objSchema(
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return upsertKnowledge(ctx, pool, args)
})
register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
attrsStr, _ := args["attributes"].(string)
if slug == "" || attrsStr == "" {
return textResult("error: slug and attributes are required"), nil
}
var attrs map[string]any
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON))
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
})
register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err := pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
})
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hours := int(getFloat(args, "hours", 24))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg,
ROUND(min(value)::numeric, 2) AS min,
ROUND(max(value)::numeric, 2) AS max
FROM metric_samples
WHERE ts > now() - make_interval(hours => $1)
GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
})
// ─── Phase 4: new tools ──────────────────────────────────────────
register(&mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
InputSchema: objSchema(
prop{"entity_slug", "string", "Filter by target entity slug"},
prop{"state", "string", "Filter by signal state (raised, resolved)"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, `
SELECT s.entity_id::text, s.kind, s.severity, s.state,
s.occurrence_count, e.slug AS target_slug,
s.first_seen_at, s.last_seen_at
FROM signals s
LEFT JOIN entities e ON e.id = s.target_entity_id
WHERE ($1::text IS NULL OR e.slug = $1)
AND ($2::text IS NULL OR s.state = $2)
ORDER BY s.last_seen_at DESC LIMIT $3`,
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
})
register(&mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
InputSchema: objSchema(
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
prop{"entity_type", "string", "Filter by applies_type"},
prop{"action", "string", "Filter by action"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
p.confidence, p.evidence_count, p.success_count, p.failure_count,
p.status, p.quarantined, p.version, p.last_validated_at
FROM patterns p
WHERE ($1::text IS NULL OR p.status = $1)
AND ($2::text IS NULL OR p.applies_type = $2)
AND ($3::text IS NULL OR p.action = $3)
ORDER BY p.applies_type, p.action`,
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
})
register(&mcp.Tool{Name: "get_skills", Description: "List available automation skills",
InputSchema: objSchema(
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
s.applies_type, s.action, s.status, s.success_rate,
s.changed_by::text, s.change_reason, s.last_used_at
FROM skills s
WHERE ($1::text IS NULL OR s.status = $1)
ORDER BY s.name, s.version DESC`,
nStr(args["status"])), nil
})
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
// All mutations now route through `run`. The handler functions
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
// for future runbook extraction — especially pct_create DNS/VMID logic.
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong) or lxc:<slug> (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."},
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug, _ := args["target"].(string)
command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
if targetSlug == "" || command == "" {
return textResult("error: target and command are required"), nil
}
var targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
})
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
InputSchema: objSchema(
prop{"url", "string", "Absolute http(s) URL to fetch"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
rawURL, _ := args["url"].(string)
return httpGet(ctx, rawURL), nil
})
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
execID, _ := args["execution_id"].(string)
if execID == "" {
return textResult("execution_id required"), nil
}
eid, err := uuid.Parse(execID)
if err != nil {
// Try finding by exec slug prefix
var found uuid.UUID
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
if err2 != nil {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
eid = found
}
return queryRows(ctx, pool, `
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
e.result::text, e.duration_ms, e.started_at::text,
e.completed_at::text, e.correlation_id
FROM executions e
WHERE e.entity_id = $1`, eid), nil
})
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
prop{"days", "integer", "Look-back window in days (default 7)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
days := int(getFloat(args, "days", 7))
return queryRows(ctx, pool, `
SELECT metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(stddev(value)::numeric, 2) AS std_val,
count(*) AS sample_count,
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
FROM metric_samples ms
JOIN entities e ON e.id = ms.entity_id
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
GROUP BY metric
ORDER BY metric`, slug, days), nil
})
register(&mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
InputSchema: objSchema(
prop{"severity", "string", "Filter by severity (info, warn, error)"},
prop{"entity_slug", "string", "Filter by entity slug"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, `
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
ev.data::text AS message, ev.correlation_id
FROM events ev
LEFT JOIN entities e ON e.id = ev.entity_id
WHERE ($1::text IS NULL OR ev.severity = $1)
AND ($2::text IS NULL OR e.slug = $2)
ORDER BY ev.ts DESC LIMIT $3`,
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
})
register(&mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
InputSchema: objSchema(
prop{"limit", "integer", "Max rows (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
WHERE agent_id = $1
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
})
// ─── Phase 5: operational MCP tools ──────────────────────────────
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
InputSchema: objSchema(
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
state, _ := argsMap(req)["state"].(string)
var statePtr *string
if state != "" {
statePtr = &state
}
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
e.attributes->>'lan_ip' AS lan_ip,
e.state,
st.health, st.last_check_at,
(SELECT MAX(k.created_at)
FROM relationships r
JOIN knowledge_entities k ON k.entity_id = r.source_id
WHERE r.target_id = e.id
AND r.type = 'about'
AND r.valid_to IS NULL
AND (k.tags @> ARRAY['audit']::text[]
OR k.tags @> ARRAY['update']::text[]
OR k.title ILIKE '%audit%'
OR k.title ILIKE '%update%')
) AS last_audited_at
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type = 'lxc'
AND ($1::text IS NULL OR e.state = $1)
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
})
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}
rows, err := pool.Query(ctx, `
SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
WHERE e.slug = $1`, slug)
if err != nil {
return textResult(fmt.Sprintf("query error: %v", err)), nil
}
defer rows.Close()
if !rows.Next() {
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
}
var health, lastCheck, url string
rows.Scan(&health, &lastCheck, &url)
if url == "" {
url = "(no URL in entity attributes)"
}
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
})
register(&mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
prop{"lines", "integer", "Number of lines (default 50)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
n := int(getFloat(args, "lines", 50))
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
})
register(&mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user,
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
})
register(&mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
InputSchema: objSchema(
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["lxc_slug"].(string)
if slug == "" {
return textResult("lxc_slug is required"), nil
}
var pveID string
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
if err != nil || pveID == "" {
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
}
// Resolve the Proxmox host — find the host that runs this LXC
var hostID uuid.UUID
err = pool.QueryRow(ctx, `
SELECT t.id FROM entities t
JOIN relationships r ON r.source_id = t.id
JOIN entities s ON s.id = r.target_id
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
LIMIT 1`, slug).Scan(&hostID)
if err != nil {
// Fallback: use the inventory host attribute if no relationship
var hostSlug string
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
if err != nil || hostSlug == "" {
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
}
var host, user string
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err2 != nil {
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
}
return textResult(out), nil
}
var hostSlug string
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
host, user, err := resolveHost(ctx, pool, hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
})
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
register(&mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hostname, _ := args["hostname"].(string)
if hostname == "" {
return textResult("error: hostname required"), nil
}
slug := "ws:" + hostname
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check,
e.attributes->>'mesh_ip' AS mesh_ip,
e.attributes->>'age_pubkey' AS age_pubkey,
e.enrolled_at
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1
ORDER BY e.slug`, slug), "entity_card"), nil
})
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("error: service_slug required"), nil
}
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check,
e.version, e.updated_at,
COALESCE(e.attributes::text, '{}') AS attrs
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1`, slug), "entity_card"), nil
})
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
InputSchema: objSchema(
prop{"service_slug", "string", "Entity slug"},
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
action, _ := args["action"].(string)
if slug == "" || action == "" {
return textResult("error: service_slug and action required"), nil
}
return queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state,
CASE
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
ELSE 'read_only'
END AS risk_class,
CASE
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
WHEN $2 = 'config_mutation' THEN 'operator-approval'
ELSE 'operator-approval+confirmation'
END AS approval
FROM entities e WHERE e.slug = $1`, slug, action), nil
})
register(&mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
InputSchema: objSchema(
prop{"entity_slug", "string", "Entity slug"},
prop{"limit", "integer", "Max entries (default 20)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
limit := int(getFloat(args, "limit", 20))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
al.action, al.method, al.path,
al.detail::text AS details
FROM audit_log al
JOIN entities e ON e.id = al.entity_id
WHERE e.slug = $1
ORDER BY al.ts DESC
LIMIT $2`, slug, limit), "change_log"), nil
})
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.state IS NOT NULL
OR st.health IS NOT NULL
ORDER BY st.health, e.slug
LIMIT 200
`), "fleet_snapshot"), nil
})
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
pubkey, _ := args["caller_pubkey"].(string)
// Match entities where age_pubkey attribute contains the caller's key.
query := `
SELECT e.slug, e.type, e.name,
e.attributes->>'age_pubkey' AS age_pubkey
FROM entities e
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
var dbArgs []any
if pubkey != "" {
query += ` AND e.attributes->>'age_pubkey' = $1`
dbArgs = append(dbArgs, pubkey)
}
query += ` ORDER BY e.slug LIMIT 100`
return queryRows(ctx, pool, query, dbArgs...), nil
})
return s return s
} }
@@ -1672,12 +973,16 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
// a fresh UUID here had no matching entities row, so the INSERT silently // a fresh UUID here had no matching entities row, so the INSERT silently
// failed, orphaning the execution and never alerting the operator. One // failed, orphaning the execution and never alerting the operator. One
// execution maps to at most one approval, so the 1:1 identity holds. // execution maps to at most one approval, so the 1:1 identity holds.
if _, err := pool.Exec(ctx, ` if err := sqlcgen.New(pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, EntityID: execID,
kind, payload, status, expires_at, created_at) SubjectEntityID: &targetID,
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending', Action: action,
now() + interval '1 hour', now())`, RiskClass: riskClass,
execID, targetID, action, riskClass, string(payload)); err != nil { Kind: "execution",
Payload: payload,
TokenHash: nil,
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
slog.Error("createApproval: insert approval", "error", err, "execution", execID) slog.Error("createApproval: insert approval", "error", err, "execution", execID)
return return
} }

724
internal/mcp/tools.go Normal file
View File

@@ -0,0 +1,724 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// toolReg pairs a tool definition with its handler. allTools returns a slice
// of these; newServer iterates it and registers each one wrapped with
// withActivityLogging.
type toolReg struct {
tool *mcp.Tool
handler toolHandler
}
// allTools returns every MCP tool registration. Tool definitions, schemas,
// descriptions, and handler bodies are kept verbatim from the former inline
// newServer registrations.
func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return []toolReg{
{tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
idOrSlug, _ := args["slug_or_id"].(string)
return queryEntity(ctx, pool, idOrSlug), nil
}},
{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
InputSchema: objSchema(
prop{"type", "string", "Filter by entity type"},
prop{"state", "string", "Filter by lifecycle state"},
prop{"q", "string", "Substring match on slug or name"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
FROM entities e
WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
ORDER BY e.slug LIMIT $4`,
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
}},
{tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
return queryRows(ctx, pool, `
SELECT r.type, src.slug AS source, tgt.slug AS target
FROM relationships r
JOIN entities src ON src.id = r.source_id
JOIN entities tgt ON tgt.id = r.target_id
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
ORDER BY r.type`, slug), nil
}},
{tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
prop{"depth", "integer", "Traversal depth (default 3)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
depth := int(getFloat(args, "depth", 3))
return queryRows(ctx, pool,
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
slug, depth), nil
}},
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
InputSchema: objSchema(),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, `
SELECT e.slug, e.type, st.health, st.last_check_at
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check'
ORDER BY e.slug`), nil
}},
{tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
FROM audit_log
WHERE ($1::text IS NULL OR entity_id::text = $1)
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
}},
{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
q := nStr(args["query"])
return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1),
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
FragmentDelimiter=" ... "') AS snippet,
ke.source, ke.tags
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.search @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20`, q), "knowledge_results"), nil
}},
{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN relationships r ON r.source_id = ke.entity_id
JOIN entities target ON target.id = r.target_id
WHERE target.slug = $1
AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
UNION
SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN relationships r ON r.source_id = ke.entity_id
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL
AND r.type = 'procedure-for'
ORDER BY 1`, slug), "knowledge_results"), nil
}},
{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
return queryRows(ctx, pool, `
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1`, slug), nil
}},
{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
InputSchema: objSchema(
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return upsertKnowledge(ctx, pool, args)
}},
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
attrsStr, _ := args["attributes"].(string)
if slug == "" || attrsStr == "" {
return textResult("error: slug and attributes are required"), nil
}
var attrs map[string]any
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON))
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
}},
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err := pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
}},
{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hours := int(getFloat(args, "hours", 24))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg,
ROUND(min(value)::numeric, 2) AS min,
ROUND(max(value)::numeric, 2) AS max
FROM metric_samples
WHERE ts > now() - make_interval(hours => $1)
GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
}},
// ─── Phase 4: new tools ──────────────────────────────────────────
{tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
InputSchema: objSchema(
prop{"entity_slug", "string", "Filter by target entity slug"},
prop{"state", "string", "Filter by signal state (raised, resolved)"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, `
SELECT s.entity_id::text, s.kind, s.severity, s.state,
s.occurrence_count, e.slug AS target_slug,
s.first_seen_at, s.last_seen_at
FROM signals s
LEFT JOIN entities e ON e.id = s.target_entity_id
WHERE ($1::text IS NULL OR e.slug = $1)
AND ($2::text IS NULL OR s.state = $2)
ORDER BY s.last_seen_at DESC LIMIT $3`,
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
}},
{tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
InputSchema: objSchema(
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
prop{"entity_type", "string", "Filter by applies_type"},
prop{"action", "string", "Filter by action"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
p.confidence, p.evidence_count, p.success_count, p.failure_count,
p.status, p.quarantined, p.version, p.last_validated_at
FROM patterns p
WHERE ($1::text IS NULL OR p.status = $1)
AND ($2::text IS NULL OR p.applies_type = $2)
AND ($3::text IS NULL OR p.action = $3)
ORDER BY p.applies_type, p.action`,
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
}},
{tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills",
InputSchema: objSchema(
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return queryRows(ctx, pool, `
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
s.applies_type, s.action, s.status, s.success_rate,
s.changed_by::text, s.change_reason, s.last_used_at
FROM skills s
WHERE ($1::text IS NULL OR s.status = $1)
ORDER BY s.name, s.version DESC`,
nStr(args["status"])), nil
}},
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
// All mutations now route through `run`. The handler functions
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
// for future runbook extraction — especially pct_create DNS/VMID logic.
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong) or lxc:<slug> (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."},
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug, _ := args["target"].(string)
command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
if targetSlug == "" || command == "" {
return textResult("error: target and command are required"), nil
}
var targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
}},
{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
InputSchema: objSchema(
prop{"url", "string", "Absolute http(s) URL to fetch"},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
rawURL, _ := args["url"].(string)
return httpGet(ctx, rawURL), nil
}},
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
execID, _ := args["execution_id"].(string)
if execID == "" {
return textResult("execution_id required"), nil
}
eid, err := uuid.Parse(execID)
if err != nil {
// Try finding by exec slug prefix
var found uuid.UUID
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
if err2 != nil {
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
}
eid = found
}
return queryRows(ctx, pool, `
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
e.result::text, e.duration_ms, e.started_at::text,
e.completed_at::text, e.correlation_id
FROM executions e
WHERE e.entity_id = $1`, eid), nil
}},
{tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
InputSchema: objSchema(
prop{"entity_id", "string", "Entity slug"},
prop{"days", "integer", "Look-back window in days (default 7)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_id"].(string)
days := int(getFloat(args, "days", 7))
return queryRows(ctx, pool, `
SELECT metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(stddev(value)::numeric, 2) AS std_val,
count(*) AS sample_count,
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
FROM metric_samples ms
JOIN entities e ON e.id = ms.entity_id
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
GROUP BY metric
ORDER BY metric`, slug, days), nil
}},
{tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
InputSchema: objSchema(
prop{"severity", "string", "Filter by severity (info, warn, error)"},
prop{"entity_slug", "string", "Filter by entity slug"},
prop{"limit", "integer", "Max rows (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, `
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
ev.data::text AS message, ev.correlation_id
FROM events ev
LEFT JOIN entities e ON e.id = ev.entity_id
WHERE ($1::text IS NULL OR ev.severity = $1)
AND ($2::text IS NULL OR e.slug = $2)
ORDER BY ev.ts DESC LIMIT $3`,
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
}},
{tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
InputSchema: objSchema(
prop{"limit", "integer", "Max rows (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 50))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
WHERE agent_id = $1
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
}},
// ─── Phase 5: operational MCP tools ──────────────────────────────
{tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
InputSchema: objSchema(
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
state, _ := argsMap(req)["state"].(string)
var statePtr *string
if state != "" {
statePtr = &state
}
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
e.attributes->>'lan_ip' AS lan_ip,
e.state,
st.health, st.last_check_at,
(SELECT MAX(k.created_at)
FROM relationships r
JOIN knowledge_entities k ON k.entity_id = r.source_id
WHERE r.target_id = e.id
AND r.type = 'about'
AND r.valid_to IS NULL
AND (k.tags @> ARRAY['audit']::text[]
OR k.tags @> ARRAY['update']::text[]
OR k.title ILIKE '%audit%'
OR k.title ILIKE '%update%')
) AS last_audited_at
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type = 'lxc'
AND ($1::text IS NULL OR e.state = $1)
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
}},
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}
rows, err := pool.Query(ctx, `
SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
WHERE e.slug = $1`, slug)
if err != nil {
return textResult(fmt.Sprintf("query error: %v", err)), nil
}
defer rows.Close()
if !rows.Next() {
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
}
var health, lastCheck, url string
rows.Scan(&health, &lastCheck, &url)
if url == "" {
url = "(no URL in entity attributes)"
}
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
}},
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
prop{"lines", "integer", "Number of lines (default 50)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
n := int(getFloat(args, "lines", 50))
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
InputSchema: objSchema(
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("service_slug is required"), nil
}
host, user, err := resolveHost(ctx, pool, slug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
svc := strings.TrimPrefix(slug, "lxc:")
out, err := sshExec(ctx, host, user,
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
InputSchema: objSchema(
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["lxc_slug"].(string)
if slug == "" {
return textResult("lxc_slug is required"), nil
}
var pveID string
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
if err != nil || pveID == "" {
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
}
// Resolve the Proxmox host — find the host that runs this LXC
var hostID uuid.UUID
err = pool.QueryRow(ctx, `
SELECT t.id FROM entities t
JOIN relationships r ON r.source_id = t.id
JOIN entities s ON s.id = r.target_id
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
LIMIT 1`, slug).Scan(&hostID)
if err != nil {
// Fallback: use the inventory host attribute if no relationship
var hostSlug string
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
if err != nil || hostSlug == "" {
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
}
var host, user string
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err2 != nil {
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
}
return textResult(out), nil
}
var hostSlug string
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
host, user, err := resolveHost(ctx, pool, hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
}
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
if err != nil {
return textResult(fmt.Sprintf("ssh: %v", err)), nil
}
return textResult(out), nil
}},
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
{tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hostname, _ := args["hostname"].(string)
if hostname == "" {
return textResult("error: hostname required"), nil
}
slug := "ws:" + hostname
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check,
e.attributes->>'mesh_ip' AS mesh_ip,
e.attributes->>'age_pubkey' AS age_pubkey,
e.enrolled_at
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1
ORDER BY e.slug`, slug), "entity_card"), nil
}},
{tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
if slug == "" {
return textResult("error: service_slug required"), nil
}
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check,
e.version, e.updated_at,
COALESCE(e.attributes::text, '{}') AS attrs
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1`, slug), "entity_card"), nil
}},
{tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
InputSchema: objSchema(
prop{"service_slug", "string", "Entity slug"},
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["service_slug"].(string)
action, _ := args["action"].(string)
if slug == "" || action == "" {
return textResult("error: service_slug and action required"), nil
}
return queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state,
CASE
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
ELSE 'read_only'
END AS risk_class,
CASE
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
WHEN $2 = 'config_mutation' THEN 'operator-approval'
ELSE 'operator-approval+confirmation'
END AS approval
FROM entities e WHERE e.slug = $1`, slug, action), nil
}},
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
InputSchema: objSchema(
prop{"entity_slug", "string", "Entity slug"},
prop{"limit", "integer", "Max entries (default 20)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
limit := int(getFloat(args, "limit", 20))
return annotateJSONResult(queryRows(ctx, pool, `
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
al.action, al.method, al.path,
al.detail::text AS details
FROM audit_log al
JOIN entities e ON e.id = al.entity_id
WHERE e.slug = $1
ORDER BY al.ts DESC
LIMIT $2`, slug, limit), "change_log"), nil
}},
{tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
InputSchema: objSchema(),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state,
COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.state IS NOT NULL
OR st.health IS NOT NULL
ORDER BY st.health, e.slug
LIMIT 200
`), "fleet_snapshot"), nil
}},
{tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
pubkey, _ := args["caller_pubkey"].(string)
// Match entities where age_pubkey attribute contains the caller's key.
query := `
SELECT e.slug, e.type, e.name,
e.attributes->>'age_pubkey' AS age_pubkey
FROM entities e
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
var dbArgs []any
if pubkey != "" {
query += ` AND e.attributes->>'age_pubkey' = $1`
dbArgs = append(dbArgs, pubkey)
}
query += ` ORDER BY e.slug LIMIT 100`
return queryRows(ctx, pool, query, dbArgs...), nil
}},
}
}

View File

@@ -282,23 +282,6 @@ func generateApprovalToken(approvalID uuid.UUID, secret string) string {
return hex.EncodeToString(mac.Sum(nil)) return hex.EncodeToString(mac.Sum(nil))
} }
// VerifyApprovalToken checks a token against the stored hash.
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
var tokenHash *string
var status string
var expiresAt time.Time
err := pool.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
approvalID).Scan(&tokenHash, &status, &expiresAt)
if err != nil || tokenHash == nil {
return false
}
if status != "pending" || expiresAt.Before(time.Now()) {
return false
}
return *tokenHash == hashToken(token)
}
func hashToken(token string) string { func hashToken(token string) string {
h := sha256.Sum256([]byte(token)) h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])

View File

@@ -0,0 +1,187 @@
package notifier
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
var hex64Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
func TestHashToken(t *testing.T) {
// sha256("") == e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
emptyHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
t.Run("determinism same input same output", func(t *testing.T) {
a := hashToken("approval-token-123")
b := hashToken("approval-token-123")
if a != b {
t.Fatalf("hashToken not deterministic: %q vs %q", a, b)
}
})
t.Run("empty string known sha256", func(t *testing.T) {
got := hashToken("")
if got != emptyHash {
t.Fatalf("hashToken(\"\") = %q, want %q", got, emptyHash)
}
})
t.Run("different inputs different outputs", func(t *testing.T) {
a := hashToken("one")
b := hashToken("two")
if a == b {
t.Fatalf("hashToken collided for different inputs: %q", a)
}
})
t.Run("output is valid 64-char hex", func(t *testing.T) {
for _, in := range []string{"", "abc", "some-longer-token-value-xyz"} {
got := hashToken(in)
if !hex64Re.MatchString(got) {
t.Fatalf("hashToken(%q) = %q, not 64-char lowercase hex", in, got)
}
// also must decode cleanly to 32 bytes
b, err := hex.DecodeString(got)
if err != nil {
t.Fatalf("hashToken(%q) decode error: %v", in, err)
}
if len(b) != 32 {
t.Fatalf("hashToken(%q) decoded len = %d, want 32", in, len(b))
}
}
})
}
func TestGenerateApprovalToken(t *testing.T) {
id := uuid.New()
t.Run("output is 64-char hex", func(t *testing.T) {
tok := generateApprovalToken(id, "super-secret")
if !hex64Re.MatchString(tok) {
t.Fatalf("generateApprovalToken = %q, not 64-char lowercase hex", tok)
}
b, err := hex.DecodeString(tok)
if err != nil {
t.Fatalf("decode error: %v", err)
}
if len(b) != 32 {
t.Fatalf("decoded len = %d, want 32 (sha256)", len(b))
}
})
t.Run("empty secret falls back to dev secret no panic", func(t *testing.T) {
tok := generateApprovalToken(id, "")
if tok == "" {
t.Fatal("empty secret produced empty token")
}
if !hex64Re.MatchString(tok) {
t.Fatalf("empty-secret token %q not 64-char hex", tok)
}
})
// Non-determinism: time.Now().UnixNano() is embedded in the HMAC message,
// so two calls with identical inputs produce different tokens (unless the
// clock has nanosecond-identical reads, which we do not assert against).
t.Run("same inputs twice produce different tokens (time-based)", func(t *testing.T) {
a := generateApprovalToken(id, "stable-secret")
b := generateApprovalToken(id, "stable-secret")
if a == b {
// Not a hard failure (clock granularity), but document expectation.
t.Logf("note: two immediate calls returned identical token %q — clock resolution collapsed", a)
}
})
t.Run("different secrets produce different tokens", func(t *testing.T) {
a := generateApprovalToken(id, "secret-a")
b := generateApprovalToken(id, "secret-b")
if a == b {
t.Fatalf("different secrets produced same token %q", a)
}
})
// HMAC correctness: re-derive the token with the same secret + approvalID
// using a freshly captured timestamp window is impossible because we don't
// observe the embedded timestamp. Instead, verify the token is a valid
// HMAC-SHA256 by brute-forcing a small time window around now: reconstruct
// mac(secret, approvalID || ts) for ts in [now-N, now] and confirm one
// matches. This proves the token genuinely is an HMAC over (approvalID, ts)
// with the supplied secret.
t.Run("token is HMAC-SHA256 over approvalID+timestamp with secret", func(t *testing.T) {
secret := "hmac-verify-secret"
before := nowNanos()
tok := generateApprovalToken(id, secret)
after := nowNanos()
// The token's embedded ts is captured inside generateApprovalToken,
// which is called after `before` was sampled — so ts ∈ [before, after].
// Add a tiny ±band to absorb scheduler jitter on loaded runners.
lo := before - 10_000
hi := after + 10_000
matched := false
for ts := lo; ts <= hi; ts++ {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(id.String()))
mac.Write([]byte(formatInt(ts)))
cand := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(cand), []byte(tok)) {
matched = true
break
}
}
if !matched {
t.Fatalf("token %q did not match any HMAC in window [%d,%d]; not a valid HMAC-SHA256 over approvalID+ts", tok, lo, hi)
}
})
t.Run("empty secret HMAC uses dev fallback secret", func(t *testing.T) {
before := nowNanos()
tok := generateApprovalToken(id, "")
after := nowNanos()
dev := "dev-secret-do-not-use-in-prod"
lo := before - 10_000
hi := after + 10_000
matched := false
for ts := lo; ts <= hi; ts++ {
mac := hmac.New(sha256.New, []byte(dev))
mac.Write([]byte(id.String()))
mac.Write([]byte(formatInt(ts)))
cand := hex.EncodeToString(mac.Sum(nil))
if hmac.Equal([]byte(cand), []byte(tok)) {
matched = true
break
}
}
if !matched {
t.Fatalf("empty-secret token %q did not match dev-fallback HMAC", tok)
}
})
// sanity: token should not leak the secret in plaintext
t.Run("token does not contain secret substring", func(t *testing.T) {
secret := "leakcheck-secret-xyz"
tok := generateApprovalToken(id, secret)
if strings.Contains(tok, secret) {
t.Fatalf("token %q contains secret substring %q", tok, secret)
}
})
}
// nowNanos returns the current nanosecond count, matching the time source
// used by generateApprovalToken (time.Now().UnixNano()).
func nowNanos() int64 {
return time.Now().UnixNano()
}
// formatInt mirrors fmt.Sprintf("%d", ...) used by the production code so the
// re-derivation in tests is byte-identical.
func formatInt(n int64) string {
return fmt.Sprintf("%d", n)
}

View File

@@ -92,21 +92,7 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
} }
// Determine route // Determine route
route := "escalate" route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired)
autonomyCheck := ""
if globalAutoAct == "off" || globalAutoAct == "false" {
route = "escalate"
autonomyCheck = "blocked: global auto_act disabled"
} else if entityAutoAct == "true" {
route = "escalate"
autonomyCheck = "blocked: per-entity kill-switch"
} else if approvalRequired == "none" {
route = "auto-act"
autonomyCheck = "allowed"
} else {
autonomyCheck = "requires approval: " + approvalRequired
}
// Compute blast radius // Compute blast radius
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID) blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
@@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
}, nil }, nil
} }
// determineRoute evaluates autonomy settings and approval requirements to
// decide whether a signal should auto-act, escalate, or hold for approval.
func determineRoute(globalAutoAct, entityAutoAct, approvalRequired string) (route, autonomyCheck string) {
if globalAutoAct == "off" || globalAutoAct == "false" {
return "escalate", "blocked: global auto_act disabled"
}
if entityAutoAct == "true" {
return "escalate", "blocked: per-entity kill-switch"
}
if approvalRequired == "none" {
return "auto-act", "allowed"
}
return "escalate", "requires approval: " + approvalRequired
}
// computeBlastRadius traverses relationships to find affected entities. // computeBlastRadius traverses relationships to find affected entities.
func computeBlastRadius(ctx context.Context, dbc interface { func computeBlastRadius(ctx context.Context, dbc interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)

View File

@@ -0,0 +1,62 @@
package policy
import "testing"
func TestDetermineRoute(t *testing.T) {
tests := []struct {
name string
globalAutoAct string
entityAutoAct string
approvalRequired string
wantRoute string
wantCheck string
}{
{
"global auto_act off blocks everything",
"off", "", "none",
"escalate", "blocked: global auto_act disabled",
},
{
"global auto_act false blocks everything",
"false", "", "none",
"escalate", "blocked: global auto_act disabled",
},
{
"per-entity kill-switch blocks",
"on", "true", "none",
"escalate", "blocked: per-entity kill-switch",
},
{
"approval none auto-acts",
"on", "", "none",
"auto-act", "allowed",
},
{
"approval required escalates",
"on", "", "operator",
"escalate", "requires approval: operator",
},
{
"approval none with empty global defaults to auto-act",
"", "", "none",
"auto-act", "allowed",
},
{
"global on, no entity kill, approval confirmation",
"on", "", "confirmation",
"escalate", "requires approval: confirmation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
route, check := determineRoute(tt.globalAutoAct, tt.entityAutoAct, tt.approvalRequired)
if route != tt.wantRoute {
t.Errorf("route = %q, want %q", route, tt.wantRoute)
}
if check != tt.wantCheck {
t.Errorf("autonomyCheck = %q, want %q", check, tt.wantCheck)
}
})
}
}

View File

@@ -0,0 +1,208 @@
package scheduler
import (
"testing"
)
func TestParsePingLatency(t *testing.T) {
cases := []struct {
name string
out []byte
want float64
}{
{
name: "linux rtt format",
out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nrtt min/avg/max/mdev = 0.1/2.5/5.0/1.2 ms\n"),
want: 2.5,
},
{
name: "macos round-trip format",
out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nround-trip min/avg/max/stddev = 0.1/2.5/5.0/1.2 ms\n"),
want: 2.5,
},
{
name: "no match empty string",
out: []byte(""),
want: 0,
},
{
name: "malformed number in avg slot",
out: []byte("rtt min/avg/max/mdev = 0.1/abc/5.0/1.2 ms\n"),
want: 0,
},
{
name: "truly empty input nil",
out: nil,
want: 0,
},
{
name: "linux integer avg",
out: []byte("rtt min/avg/max/mdev = 1/3/5/0.5 ms\n"),
want: 3.0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parsePingLatency(tc.out)
if got != tc.want {
t.Fatalf("parsePingLatency(%q) = %v, want %v", tc.out, got, tc.want)
}
})
}
}
func TestAllowlistedScript(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{name: "cpu_check.sh", in: "cpu_check.sh", want: true},
{name: "disk_usage_check.sh", in: "disk_usage_check.sh", want: true},
{name: "hyphen allowed", in: "foo-bar.sh", want: true},
{name: "underscore allowed", in: "foo_bar.sh", want: true},
{name: "uppercase rejected", in: "Foo.sh", want: false},
{name: "bare .sh rejected (no name)", in: ".sh", want: false},
{name: "path traversal rejected", in: "../etc/passwd", want: false},
{name: "empty rejected", in: "", want: false},
// foo.sh.sh: regex ^[a-z][a-z0-9_-]+\.sh$ — the [a-z0-9_-]+ cannot
// cross the first '.', so after matching "foo.sh" the trailing ".sh"
// breaks the $ anchor → no match.
{name: "doubled .sh.sh rejected", in: "foo.sh.sh", want: false},
{name: "wrong extension rejected", in: "foo.txt", want: false},
{name: "single char name rejected (needs 2+)", in: "a.sh", want: false},
{name: "two char name accepted", in: "ab.sh", want: true},
{name: "digit after first char", in: "a1.sh", want: true},
{name: "leading digit rejected", in: "1foo.sh", want: false},
{name: "dot in middle rejected", in: "foo.bar.sh", want: false},
{name: "space rejected", in: "foo bar.sh", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := allowlistedScript(tc.in)
if got != tc.want {
t.Fatalf("allowlistedScript(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestEvaluateSeverity(t *testing.T) {
cases := []struct {
name string
kind string
signalKind string
config []byte
metrics map[string]float64
want string
}{
{
name: "empty config down signal",
kind: "ping",
signalKind: "down",
config: nil,
metrics: map[string]float64{},
want: "critical",
},
{
name: "empty config high_temp signal",
kind: "ssh-script",
signalKind: "high_temp",
config: nil,
metrics: map[string]float64{},
want: "warning",
},
{
name: "crit only exceeded",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"crit":90}}`),
metrics: map[string]float64{"temp": 95},
want: "critical",
},
{
name: "warn only exceeded",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80}}`),
metrics: map[string]float64{"temp": 85},
want: "warning",
},
{
name: "warn and crit, warn exceeded only",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"temp": 85},
want: "warning",
},
{
name: "warn and crit, crit exceeded",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"temp": 95},
want: "critical",
},
{
name: "metric below thresholds falls through to warning",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"temp": 70},
want: "warning",
},
{
name: "metric below thresholds falls through to down critical",
kind: "ping",
signalKind: "down",
config: []byte(`{"ping_latency_ms":{"warn":100,"crit":200}}`),
metrics: map[string]float64{"ping_latency_ms": 50},
want: "critical",
},
{
name: "crit zero skipped falls through",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"crit":0}}`),
metrics: map[string]float64{"temp": 95},
want: "warning",
},
{
name: "metric not in thresholds ignored",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"cpu": 99},
want: "warning",
},
{
name: "equal to warn triggers warning",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"temp": 80},
want: "warning",
},
{
name: "equal to crit triggers critical",
kind: "ssh-script",
signalKind: "high_temp",
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
metrics: map[string]float64{"temp": 90},
want: "critical",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := evaluateSeverity(tc.kind, tc.signalKind, tc.config, tc.metrics)
if got != tc.want {
t.Fatalf("evaluateSeverity(%q, %q, %s, %v) = %q, want %q",
tc.kind, tc.signalKind, tc.config, tc.metrics, got, tc.want)
}
})
}
}

View File

@@ -1,618 +1,28 @@
# Homelab inventory — canonical structured topology. # DEPRECATED — 2026-07-17 (R5). Do not edit. Do not consume.
# #
# Single source of truth. hosts/*.yaml is generated from this file by # This root-level inventory.yaml is the Python-era topology file. It was
# mcp/build_host_files.py; do NOT edit those by hand. # superseded on 2026-07-07 by the DB-native model (ADR 0003):
# #
# Conventions: # - The Postgres database is the single source of truth for all structured
# - hostname keys MUST match the actual `hostname` of the machine (on # data. Query it via MCP `get_entity` / `list_entities` or the REST API.
# macOS: `scutil --get LocalHostName` if set). # - `seeds/inventory.yaml` is the bootstrap + DR seed manifest, ingested
# - `os:` one of: linux, macos # idempotently into the DB at deploy time (content-hashed via
# - `kind:` one of: proxmox-host, lxc, vm, workstation, external # `seed_versions`). `oikos export` regenerates it from the DB for VC.
# ("external" is reserved for hosts the homelab CLI manages via ssh but # - `archive/knowledge/` holds the frozen legacy narrative wiki.
# that aren't homelab clients themselves — e.g. the IONOS netbird VPS
# with no /etc/age/key.txt and no /opt/homelab-context clone.)
# - `mesh:` lists addresses the host is reachable at. Both `netbird` and
# `tailscale` are accepted during the migration (see infrastructure/mesh.md).
# Prefer netbird FQDNs over raw IPs.
# - `age_pubkey:` provisioned by secrets-issuance on first bootstrap and
# committed back via `homelab client add --finalize-pubkey <key>`.
# - When a service moves hosts, update only the `services:` section here;
# never duplicate addresses elsewhere.
# - `ssh.user:` per-host login user. Default is `root` if omitted (matches
# every LXC + the PVE host). Set explicitly for workstations whose login
# user differs from `root`. Used by the `homelab` CLI to build
# `user@host` and to inform anyone running raw `netbird ssh` (which
# defaults to the LOCAL username — the gotcha that creates "user not
# found" errors when ssh'ing INTO machines that only have `root`).
# #
# `homelab client add/remove` does surgical line-edits — comments survive. # This file is kept only because AGENTS.md §1/§2 still point clients at
# Avoid round-tripping the file through yaml.safe_dump (it strips comments). # `/opt/homelab-context/inventory.yaml` (the on-client clone path). Reconcile
# that path to `seeds/inventory.yaml` and delete this stub — tracked as R13
mesh: # in plans/2026-07-17-codebase-review-and-cleanup.md.
primary: netbird #
accepted: # The live topology is in the DB. The seed is `seeds/inventory.yaml`. This
- netbird # file is not read by any Go code, script, or test in the repo.
- tailscale ---
netbird_subnet: 100.122.0.0/16 deprecated: true
netbird_domain: netbird.selfhosted superseded_by: seeds/inventory.yaml
# Service contract (Oikos, 2026-07-05): each service should carry source_of_truth: postgres (see ADR 0003)
# backend host/container that runs it (required) see_also:
# url public URL if ingress-exposed - seeds/inventory.yaml
# doc_page owning wiki page - seeds/ontology.yaml
# config_repo tracked config repo, if any (mutations go commit+push) - seeds/policy.yaml
# health health-check URL if it differs from `url` - docs/adr/0003-db-native-ontology-yaml-seeds.md
# risk_notes what an agent must know before touching it
# See oikos/ontology.yaml + oikos/policy.yaml.
services:
proxmox_ui:
url: https://proxmox.hubris.network
backend: hubris
port: 8006
doc_page: knowledge/wiki/hosts/hubris.md
risk_notes: hypervisor UI — changes here affect every guest on the node
gitea:
url: https://git.hubris.network
backend: gitea
backend_url: http://192.168.8.121:3000
doc_page: knowledge/wiki/containers/104-gitea.md
config_repo: dtoro/gitea-customizations
risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
caddy:
backend: caddy
role: reverse-proxy
note: terminates all *.hubris.network
doc_page: knowledge/wiki/containers/121-caddy.md
config_repo: dtoro/caddy-conf
risk_notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
authentik:
url: https://auth.hubris.network
backend: netbird-vps
doc_page: knowledge/wiki/containers/106-auth-outpost.md
note: >-
core runs on the VPS since 2026-05-31; LAN forward-auth outpost is
auth-outpost (LXC 106) at 192.168.8.6:9000. Previous backend value
"authentik" referenced the retired embedded-outpost host (LXC 124).
risk_notes: SSO provider — outage locks login to OIDC/forward-auth services
dns:
backend: dns
note: Technitium DNS, split-horizon zone
doc_page: knowledge/wiki/containers/107-dns.md
risk_notes: LAN-wide resolver — misconfig breaks name resolution for every client
jellyfin:
url: https://media.hubris.network
backend: jellyfin
doc_page: knowledge/wiki/containers/101-jellyfin.md
risk_notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
nextcloud:
url: https://cloud.hubris.network
backend: nextcloud
doc_page: knowledge/wiki/containers/114-nextcloud.md
paperless:
url: https://paperless.hubris.network
backend: paperless
doc_page: knowledge/wiki/containers/103-paperless.md
risk_notes: document archive — treat data as irreplaceable; DB operations are destructive-class
matrix:
url: https://matrix.hubris.network
backend: elementsynapse
doc_page: knowledge/wiki/containers/118-elementsynapse.md
risk_notes: alert/approval channel for Oikos — outage silences agent escalation
photos:
url: https://photos.hubris.network
backend: mule-images
doc_page: knowledge/wiki/containers/120-mule-images.md
config_repo: dtoro/mule-image
arr_stack:
backend: arriman
note: jellyseerr / qbit / sab on docker compose
doc_page: knowledge/wiki/containers/122-arriman.md
artifacto:
backend: apps
url: https://artifacto.hubris.network
doc_page: knowledge/wiki/containers/105-apps.md
config_repo: dtoro/Artifacto
trmnl:
backend: trmnl
url: https://trmnl.hubris.network
note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud)
doc_page: knowledge/wiki/containers/128-trmnl.md
config_repo: dtoro/terminalito
zimaos:
url: https://zimaos.hubris.network
backend: zimaos
doc_page: knowledge/wiki/vms/100-zimaos.md
haos:
backend: haos
doc_page: knowledge/wiki/vms/108-haos.md
teddycloud:
url: https://teddy.hubris.network
backend: teddycloud
doc_page: knowledge/wiki/containers/131-teddycloud.md
note: self-hosted TeddyCloud (Toniebox cloud reimplementation), docker compose
risk_notes: no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) —
reachable to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented
in inventory.yaml until 2026-07-06 (drift-caught)
homelab_mcp:
backend: apps
port: 9810
systemd_unit: homelab-mcp
public_host: mcp.hubris.network
endpoint: https://mcp.hubris.network/mcp
doc_page: knowledge/wiki/infrastructure/homelab-context.md
config_repo: dtoro/oikos
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy
and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through
hubris).
risk_notes: agents' primary read surface — outage degrades every agent to grepping the clone
secrets_issuance:
backend: apps
port: 9820
systemd_unit: secrets-issuance
public_host: secrets.hubris.network
endpoint: https://secrets.hubris.network/issue
doc_page: .agents/operations/agent-enrollment.md
config_repo: dtoro/oikos
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN
subnets in MESH_SUBNETS).
risk_notes: identity issuance — any change is security-sensitive; key operations are destructive-class
hosts:
hubris:
kind: proxmox-host
os: linux
role: hypervisor
lan_ip: 192.168.8.77
mesh:
netbird:
ip: 100.122.38.109
fqdn: proxmox-server.netbird.selfhosted
ssh:
port: 22
netbird_port: 22022
user: root
mounts:
- /mnt/library
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
trmnl:
kind: lxc
pve_id: 128
host: hubris
os: linux
role: trmnl-middleware
lan_ip: 192.168.8.211
public_host: trmnl.hubris.network
# not yet mesh/SOPS-enrolled — see containers/128-trmnl.md
house:
kind: lxc
pve_id: 129
host: strong
os: linux
role: family-planner
lan_ip: 192.168.8.244
public_host: house.hubris.network
notes:
- Docker host for Yuvomi (family planner). Created 2026-06-26.
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
- Runs Yuvomi container + WebDAV doc bridge to paperless
- 192.168.8.212 was the hubris IP before migration (briefly picked up by teddycloud via
DHCP; teddycloud has since been given a static IP, see hosts.teddycloud)
age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
jellyfin:
kind: lxc
pve_id: 101
host: strong
os: linux
role: media-server
lan_ip: 192.168.8.246
public_host: media.hubris.network
mesh:
tailscale:
fqdn: jellyfin
mounts:
- /mnt/media_local
notes:
- Jellyfin 10.11.11 with VAAPI hardware acceleration (Radeon 680M iGPU on strong)
- 4 cores / 8 GiB RAM / 1 GiB swap
- SSO-Auth plugin v4.0.0.4 with Authentik OIDC (no Caddy forward-auth gate)
- GPU passed via dev0+dev1: /dev/dri/renderD128 + card0
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
age_pubkey: ''
nfs-export:
kind: lxc
pve_id: 102
host: hubris
os: linux
role: storage-export
lan_ip: 192.168.8.200
paperless:
kind: lxc
pve_id: 103
host: hubris
os: linux
role: document-archive
lan_ip: 192.168.8.130
public_host: paperless.hubris.network
mesh:
tailscale:
fqdn: paperless
mounts:
- /mnt/library
age_pubkey: ''
gitea:
kind: lxc
pve_id: 104
host: hubris
os: linux
role: git-server
lan_ip: 192.168.8.121
public_host: git.hubris.network
backend_port: 3000
mesh:
tailscale:
fqdn: gitea
mounts:
- /mnt/library
notes:
- Bare repos live at /mnt/library/repos/dtoro/*.git
age_pubkey: ''
apps:
kind: lxc
pve_id: 105
host: hubris
os: linux
role: docker-apps
lan_ip: 192.168.8.205
public_hosts:
- artifacto.hubris.network
mesh:
tailscale:
ip: 100.121.171.122
fqdn: apps
mounts:
- /mnt/library
runs:
- artifacto
- plantuml
- homelab-mcp
- secrets-issuance
# booklore removed 2026-06-29 → migrated to grimmory (LXC 130)
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
auth-outpost:
kind: lxc
pve_id: 106
host: hubris
os: linux
role: authentik-gateway
lan_ip: 192.168.8.6
notes:
- Runs Authentik outpost (reverse-proxy/SSO enforcement) for protected services
dns:
kind: lxc
pve_id: 107
host: hubris
os: linux
role: dns-server
lan_ip: 192.168.8.2
notes:
- Technitium DNS, split-horizon zone for *.hubris.network
- Primary DNS for 192.168.8.0/24 LAN (inventory.services.dns references this)
nextcloud:
kind: lxc
pve_id: 114
host: hubris
os: linux
role: file-sync
lan_ip: 192.168.8.224
public_host: cloud.hubris.network
mesh:
tailscale:
fqdn: nextcloud
mounts:
- /mnt/library
age_pubkey: ''
elementsynapse:
kind: lxc
pve_id: 118
host: strong
os: linux
role: matrix-server
lan_ip: 192.168.8.242
public_host: matrix.hubris.network
mesh:
tailscale: {}
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
sophia:
kind: lxc
pve_id: 119
host: hubris
os: linux
role: workshop
lan_ip: 192.168.8.109
mesh:
tailscale:
fqdn: sophia
mounts:
- /mnt/library
age_pubkey: ''
mule-images:
kind: lxc
pve_id: 120
host: hubris
os: linux
role: photo-management
lan_ip: 192.168.8.136
public_host: photos.hubris.network
mesh:
tailscale:
fqdn: muleimage
mounts:
- /mnt/library
age_pubkey: ''
caddy:
kind: lxc
pve_id: 121
host: hubris
os: linux
role: reverse-proxy
lan_ip: 192.168.8.175
notes:
- Terminates all *.hubris.network
- /etc/caddy is a git checkout of dtoro/caddy-conf
peers:
- authentik
- gitea
arriman:
kind: lxc
pve_id: 122
host: strong
os: linux
role: arr-stack
lan_ip: 192.168.8.245
public_hosts:
- jellyseerr.hubris.network
- qbit.hubris.network
- sab.hubris.network
mesh:
tailscale:
fqdn: arr
mounts:
- /mnt/media_local
notes:
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
- Contains homarr, radarr, sonarr, lidarr, sabnzbd, qbittorrent, bazarr, flaresolverr, prowlarr, jellyseerr
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 (for seanime + Caddy access)
age_pubkey: ''
grimmory:
kind: lxc
pve_id: 130
host: strong
os: linux
role: book-library
lan_ip: 192.168.8.247
public_host: books.hubris.network
mounts:
- /mnt/media_local
notes:
- Docker host for Grimmory (community fork of Booklore). Created 2026-06-29.
- Migrated from hubris to strong 2026-07-05 (Phase 2d). Books on ludo-lvm.
age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet
teddycloud:
kind: lxc
pve_id: 131
host: hubris
os: linux
role: teddycloud
lan_ip: 192.168.8.150
public_host: teddy.hubris.network
mounts:
- /mnt/library
state: active
notes:
- Docker host for TeddyCloud (ghcr.io/toniebox-reverse-engineering/teddycloud), a
self-hosted reimplementation of the Toniebox cloud backend. Debian 12 (bookworm).
- 1 core / 1 GiB RAM / 512 MiB swap / 16 GiB rootfs (local-lvm).
- Predates the client-enrollment convention — undocumented in inventory.yaml until
2026-07-06, when Oikos's drift detector (oikos/drift.py) caught pve_id 131 live on
hubris (`pct list`) with no inventory entry. Static IP assigned 2026-07-05 during the
strong migration (was picking up 192.168.8.243 via DHCP before that — see
hosts/strong.md's 2026-07-05 changelog).
- No age_pubkey / homelab-context enrollment — not a homelab CLI client, just a
docker-compose app container. Not a required follow-up unless it needs secrets.
seanime:
kind: lxc
pve_id: 133
host: strong
os: linux
role: anime-media-server
lan_ip: 192.168.8.248
public_host: seanime.hubris.network
mounts:
- /mnt/media_local/anime
notes:
- Seanime anime media server for online streaming + local library scanning
- Created 2026-07-05. Binary at /opt/seanime/bin/seanime, systemd service.
- Connected to qBittorrent on arriman (192.168.8.245:8080)
- 8 online streaming extensions installed (HiAnime, AniWatch, KickAssAnime, etc.)
- /anime mounted from strong ludo-lvm (/mnt/media_local/anime)
- Caddy: https://seanime.hubris.network → 192.168.8.248:43211
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 for seanime access
romm:
kind: lxc
pve_id: 134
host: strong
os: linux
role: rom-manager
lan_ip: 192.168.8.249
public_host: roms.hubris.network
mounts:
- /mnt/media_local
notes:
- Docker host for RomM (romm.app) self-hosted ROM manager. Created 2026-07-05.
- MariaDB sidecar at /opt/romm/docker-compose.yml.
- ROMs on ludo-lvm media volume at /mnt/media_local/roms.
- 1 core / 2 GiB RAM / 16 GiB rootfs (ludo-lvm).
zimaos:
kind: vm
pve_id: 100
host: hubris
os: linux
role: nas-frontend-eval
lan_ip: 192.168.8.195
public_host: zimaos.hubris.network
haos:
kind: vm
pve_id: 108
host: hubris
os: linux
role: home-automation
lan_ip: 192.168.8.101
mesh:
tailscale:
fqdn: homeassistant
republic-laptop:
kind: workstation
os: linux
role: primary-dev
mesh:
netbird:
fqdn: republic-laptop.netbird.selfhosted
ssh:
user: dtoro
mac-mini:
kind: workstation
os: macos
role: dev
lan_ip: 192.168.178.182
mesh:
netbird:
fqdn: mac-mini-234-17.netbird.selfhosted
ssh:
user: dtoro
notes:
- Only macOS in the fleet. Bootstrap uses launchd.
age_pubkey: age169104ee1a9e1577d493820830560197f0adf56bf8f1c369d57c152c03f9437ae
strong:
kind: proxmox-host
os: linux
role: hypervisor
lan_ip: 192.168.178.181
ssh:
user: root
notes:
- Reformatted from Linux workstation ("ludo-mini" in this wiki, still
the machine's nickname) to Proxmox VE 9.2.3 on 2026-07-01. Renamed
the inventory/wiki identity from ludo-mini to strong on the same day
so it matches the OS/cluster hostname everywhere (bootstrap looks up
hosts/$(hostname).yaml, so a mismatch would break enrollment).
- Joined hubris's "Homelab" cluster same day. 2-node, no QDevice
tiebreaker yet — see hosts/hubris.md quorum note.
- Netbird not yet installed (fresh OS wiped prior enrollment); reachable
today only via the household LAN / existing Fritz static route to
192.168.8.0/24. Re-enroll in mesh as a follow-up if off-LAN access
to this host itself (not just its future guests) is needed.
- First step of the planned library-SSD migration — see
.nomos/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md
(filename kept as-is, it's a historical planning doc). Only Phase 1
(Proxmox install + cluster join) is done; no physical
drive move, service migration, or GPU passthrough has happened yet.
age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4
netbird-vps:
kind: external
os: linux
role: netbird-mgmt
mesh:
netbird:
ip: 100.122.165.149
fqdn: netbird-ionos.netbird.selfhosted
ssh:
user: root
notes:
- Public IONOS VPS — hosts the vanilla netbird mgmt+signal+relay+dashboard
stack + host coturn (see infrastructure/vps-hardening.md +
infrastructure/mesh.md changelog 2026-05-21).
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context
clone. Managed via ssh from hubris; sshd is locked to hubris's pubkey.
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
- Configs rendered by `homelab render-vps-configs` from
vps/turnserver.conf.tmpl + vps/management.json.tmpl, with secrets
decrypted from secrets/turn-shared-secret.yaml +
secrets/netbird-authentik-oidc.yaml on hubris.
rclone:
kind: lxc
os: linux
role: backup
mesh:
netbird:
fqdn: rclone.netbird.selfhosted
age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
# Destroyed nodes (lifecycle state: destroyed — see oikos/ontology.yaml).
# Kept so agents can answer "what happened to X?" from structured data and
# so drift detectors can flag anything still referencing them.
# Full narrative table: containers/index.md "Recently destroyed".
archaeology:
claudio-bot:
kind: lxc
pve_id: 123
destroyed: 2026-06-04
reason: replaced by Nomos Agent on mac-mini; monitoring moved to homelab-health-watchdog cron
plato:
kind: lxc
pve_id: 126
destroyed: 2026-06-28
reason: notes workspace decommissioned; data retained at /mnt/library/documents/plato
mule-photos-new:
kind: lxc
pve_id: 127
destroyed: 2026-05-22
reason: PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge)
heaper:
kind: lxc
pve_id: 116
destroyed: 2026-05-14
reason: decommissioned; data retained at /mnt/library/heaper
syncthing:
kind: lxc
pve_id: 109
destroyed: 2026-05-14
reason: decommissioned; library subtree was empty
seafile:
kind: lxc
pve_id: 125
destroyed: 2026-05-13
reason: Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns
arr-yunohost:
kind: lxc
pve_id: 100
destroyed: 2026-04-28
reason: migrated to docker stack on arriman (LXC 122)
flaresolverr:
kind: lxc
pve_id: 106
destroyed: 2026-04-28
reason: folded into the arriman docker compose
marimo:
kind: lxc
pve_id: 107
destroyed: 2026-04-28
reason: decommissioned
photoprism:
kind: lxc
pve_id: 110
destroyed: 2026-04-28
reason: replaced by mule-images (LXC 120)
karakeep:
kind: lxc
pve_id: 111
destroyed: 2026-04-28
reason: decommissioned
immich:
kind: lxc
pve_id: 112
destroyed: 2026-04-28
reason: replaced by mule-images (LXC 120)
reticulum:
kind: lxc
pve_id: 115
destroyed: 2026-04-28
reason: decommissioned

View File

@@ -1,6 +1,6 @@
-- 020_session_reliability.up.sql -- 020_session_reliability.up.sql
-- Plan step generation tracking + audit log session linkage. -- Plan step generation tracking + audit log session linkage.
-- See plans/2026-07-14-session-reliability-and-ux-audit.md. -- See plans/done/2026-07-14-session-reliability-and-ux-audit.md.
-- Plan step generation: when the agent revises a plan mid-flight, new steps -- Plan step generation: when the agent revises a plan mid-flight, new steps
-- get a higher generation number so the frontend can group/collapse old ones. -- get a higher generation number so the frontend can group/collapse old ones.

View File

@@ -0,0 +1,463 @@
# 2026-07-17 — Codebase review, lint audit, and documentation maintenance
Status: **Report delivered** — doc/tooling fixes applied in this commit; code
refactors listed below are actionable recommendations pending approval.
Scope: full review of Go (`internal/`, `cmd/`), Svelte SPA (`web/`), and all
documentation (`README`, `AGENTS.md`, `.agents/**`, `docs/**`, `plans/**`,
`seeds/**`). Research-only review followed by targeted doc-maintainability
fixes. No production code was refactored in this pass.
Method: three parallel research passes (Go, web, docs) plus `go vet`, `go
build`, `go test -race`, and `npm run build`. `go vet` is clean; all tests
pass; the SPA builds with Svelte 5 warnings (listed in §B.4).
## A. Headline findings
| # | Area | Finding | Severity |
| - | ---- | ------- | -------- |
| A1 | Go | `internal/httpapi/phase3.go` is a 2627-line god file holding 12+ unrelated resource domains, misnamed after a project phase | High |
| A2 | Go | `internal/mcp/server.go:67` `newServer` is a 708-line function registering 33 tools inline; no registry pattern | High |
| A3 | Go | 17 sqlc queries are defined but never called; ~50% of DB access bypasses sqlc with raw inline SQL in `httpapi/` | High |
| A4 | Go | Test coverage violates the documented gates: `learning` (0%, gate 80%), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` all 0% | High |
| A5 | Web | Entire tool-renderer registry is dead — 21 files (~1.5k lines): `tool-renderers.ts`, `renderers/index.ts`, 10 `.ts` + 10 `.svelte` registrars; `getToolRenderer` is never called | High |
| A6 | Web | No `lint`/`check`/`test` scripts in `package.json`; zero test files; `any` is pervasive in the SSE/event payload plumbing | High |
| A7 | Docs | `.agents/domains/knowledge/schema.md` and `.agents/shared/llm-wiki.md` describe the deleted Python substrate (`bin/homelab`, `oikos/cards/`, `oikos/ledger.py`, root `inventory.yaml`) — they contradict the DB-native model in AGENTS.md / ADR 0003 | High |
| A8 | Docs | Brittle hardcoded counts in 5 docs: "33 tools", "15 tools", "36 documents", "20 migrations", "001011" — rot on every seed regen | Medium |
| A9 | Build | Desktop version hardcoded `0.1.0` in `cmd/desktop/main.go:39` and `Makefile:70` while repo is at `0.7.6` — breaks the auto-update comparison | Medium |
| A10 | Docs | 4 broken markdown links + `plans/index.md` out of sync with filesystem (4 done plans not moved, 4 entries missing) | Low |
## B. Go codebase
`go vet ./...` clean. `go build` clean. `go test -race` passes for all packages
that have tests. 387 `.go` files, ~33k LOC.
### B.1 Naming & conventions — mostly idiomatic
- All packages lowercase single words; no casing/abbreviation inconsistency.
- `internal/httpapi/phase3.go`**temporal naming** (named after a project
phase, not a domain). Contents span checks, executions, approvals, patterns,
skills, policy, metrics, trends, agent-activity, relationships, entity-types,
autonomy, risk-classes. Should be split into ~12 resource files.
- `internal/httpapi/stubs.go` — 5-line file, comment-only, no declarations.
Orphan. **Delete.**
- `cmd/desktop/main.go` uses stdlib `log` while the rest of the codebase
standardizes on `slog` via `internal/observability/logging.go:11`.
### B.2 Dead code
No TODO/FIXME/XXX/HACK/DEPRECATED comments anywhere. No commented-out blocks.
No panics in non-test code. No global mutable state.
Dead exported symbols:
- `internal/notifier/notifier.go:286``VerifyApprovalToken` has **zero call
sites**. Truly dead. **Delete.**
- `internal/checkdefaults/defaults.go:22,60,125,133``ResolveHost`,
`ForEntityType`, `ShortSlug`, `DefaultInterval` are exported but only called
within their own package. **Unexport.**
Dead file:
- `internal/httpapi/stubs.go` — comment-only orphan. **Delete.**
### B.3 Dead sqlc queries (17)
Defined in `internal/db/queries/*.sql`, generated into `internal/db/sqlcgen/`,
never called anywhere in the codebase:
| Query | File:line |
| ----- | --------- |
| `ListEntityRelations` | `internal/db/queries/relationships.sql:1` |
| `ListGraphEdges` | `internal/db/queries/relationships.sql:13` |
| `UpsertCurrentRelationship` | `internal/db/queries/relationships.sql:25` |
| `EndCurrentRelationship` | `internal/db/queries/relationships.sql:31` |
| `GetEntityBySlug` | `internal/db/queries/entities.sql:7` |
| `ListEntitiesCapped` | `internal/db/queries/entities.sql:30` |
| `GetEntityStatus` | `internal/db/queries/operations.sql:268` |
| `ListEntityStatus` | `internal/db/queries/operations.sql:17` |
| `UpdateSignalState` | `internal/db/queries/operations.sql:92` |
| `InsertApproval` | `internal/db/queries/operations.sql:212` |
| `InsertClassification` | `internal/db/queries/operations.sql:109` |
| `InsertFeedback` | `internal/db/queries/operations.sql:156` |
| `InsertSkill` | `internal/db/queries/operations.sql:204` |
| `ListEntityTypes` | `internal/db/queries/ontology.sql:1` |
| `ListRelationshipTypes` | `internal/db/queries/ontology.sql:4` |
| `ListLifecycleDefs` | `internal/db/queries/ontology.sql:7` |
| `WithTx` | `internal/db/sqlcgen/db.go` |
**Whole `relationships.sql` file is dead** — graph/relationship access is done
via raw inline SQL in `phase3.go` and `impl.go`. Either delete the queries or
migrate the inline SQL to use them.
### B.4 Pattern divergence — raw inline SQL vs sqlc
CONTRIBUTING §SQL says sqlc is the convention. ~50% of DB access bypasses it:
- `internal/httpapi/phase3.go:164,212,260,276,338,346,386,394,418,486,546,554,555,563,583` — raw `pool.Query/Exec` with inline SQL strings.
- `internal/httpapi/dashboard.go:21,39,59,96,117,123,144` — all raw inline SQL.
- `internal/httpapi/activity.go:81,148,185`, `learning_view.go:31,103` — raw inline SQL.
- `internal/httpapi/server.go:204`, `sse.go:112,142` — raw SQL (`LISTEN oikos_events`).
This is why the 17 queries above are dead — the equivalent logic is hand-written
inline. **Pick one DB-access pattern.** Recommendation: migrate inline SQL to
sqlc queries (deletes the dead queries' replacements and centralizes SQL).
### B.5 God files & functions (>800 lines / >100 lines)
Files (excluding generated):
- `internal/httpapi/phase3.go`**2627 lines** (split by resource).
- `internal/mcp/server.go`**1691 lines**.
- `internal/httpapi/impl.go`**1639 lines**.
- `cmd/nomos/store.go`**1472 lines**.
- `cmd/nomos/main.go` — 914 lines.
- `cmd/nomos/agent.go` — 861 lines.
- `internal/httpapi/server.go` — 842 lines.
- `cmd/desktop/main.go` — 784 lines.
- `internal/scheduler/scheduler.go` — 761 lines.
Functions (>100 lines, worst):
- `internal/mcp/server.go:67` `newServer`**708 lines** (33 tools inline).
- `cmd/nomos/agent.go:187` `chatWith`**405 lines**.
- `internal/httpapi/phase3.go:270` `executeApprovedAction`**356 lines**, 5+
levels of nested switch/if, 8 duplicated `UPDATE executions SET
status=failed` error-bail blocks.
- `cmd/nomos/main.go:168` `handleChat` — 193 lines.
- `cmd/desktop/main.go:124` `startOIDCServer` — 182 lines.
- `internal/httpapi/dashboard.go:13` `GetDashboardSummary` — 160 lines.
- `internal/httpapi/impl.go:855` `CreateEntity` — 158 lines.
- `internal/httpapi/phase3.go:1366` `DecideApproval` — 156 lines.
- `internal/mcp/server.go:1264` `classifyAndGate` — 154 lines.
### B.6 `interface{}` vs `any`
Module is `go 1.26.3`; `any` is preferred. 409 `any` uses vs 11 `interface{}`.
The 11 are in `internal/mcp/server.go:1123,1131,1133,1581`,
`internal/httpapi/phase3.go:169,182`, and tests — all untyped-JSON unmarshaling.
**Replace with `any`** for consistency.
### B.7 Test coverage
CONTRIBUTING §Testing gates: policy + learning ≥ 80%, others ≥ 60%.
| Package | Tests | Status |
| ------- | ----- | ------ |
| `internal/learning` | 0 | ❌ violates 80% gate |
| `internal/actuator` | 0 | ❌ mutation code, untested |
| `internal/scheduler` | 0 | ❌ 761 lines of check logic |
| `internal/domain` | 0 | ❌ core types |
| `internal/notifier` | 0 | ❌ Matrix approval flow |
| `internal/knowledge` | 0 | ❌ seed ingestion |
| `internal/observability` | 0 | ❌ |
| `internal/checkdefaults` | 0 | ❌ |
| `internal/policy` | 1 | ⚠️ covers `classify.go` only |
| `internal/db`, `httpapi`, `mcp`, `secrets`, `config`, `ontology`, `safego` | ✅ | OK |
| `cmd/nomos` | 3 | ✅ |
### B.8 Generated code & migrations — clean
- `internal/httpapi/gen/api.gen.go` and `internal/db/sqlcgen/*.go` all carry
`DO NOT EDIT` headers. No hand-edits detected.
- Migrations 001020: sequential, no gaps, no down migrations, `embed.go`
present. ✅
### B.9 OpenAPI vs implementation drift
- `api/openapi.yaml` defines 46 paths.
- `internal/httpapi/` implements ~40 strict handlers + ~8 manually-registered
`chi.Get` routes (`serveRecentActivity`, `serveSessionDigest`,
`serveKnowledgeContent`, `serveRecentKnowledge`, `serveLearningTimeline`,
`serveLearningTrend`, `serveOIDC*`, `serveSSE`) that are **not in
`openapi.yaml`**.
- OpenAPI is therefore not the source of truth for ~8 routes (violates
CONTRIBUTING §OpenAPI codegen). **Add them to `openapi.yaml`** or document
the carve-out.
## C. Web SPA (`web/`)
`npm run build` succeeds with Svelte 5 warnings. 722 KB JS bundle (222 KB
gzip), no code splitting.
### C.1 Tooling gaps — fixed in this pass + R8
- `package.json` had only `dev`/`build`/`preview`. **Added** `check`
(`svelte-check`), `typecheck` (`tsc --noEmit`), and `lint` scripts, plus
`svelte-check` + `typescript` devDeps. Run `npm install` to pick them up.
- **R8 added:** `eslint` (flat config) + `eslint-plugin-svelte` +
`typescript-eslint` + `globals`; `prettier` + `prettier-plugin-svelte`;
`vitest` (jsdom env) with a sample test (`src/lib/utils.test.ts`, 6 tests).
New scripts: `lint`, `lint:fix`, `format`, `format:check`, `test`,
`test:watch`. Vitest config wired into `vite.config.ts` via
`/// <reference types="vitest/config" />`.
- **CI:** new `web` job in `.gitea/workflows/ci.yml` runs `npm ci`,
`npm run check` (advisory), `npm run lint` (advisory),
`npm run format:check` (advisory), `npm run test` (gate),
`npm run build` (gate). Advisory steps use `continue-on-error: true`
until the lint/check baseline is clean — matching the existing
`golangci-lint` advisory pattern.
- **Known baseline:** svelte-check reports 154 pre-existing errors (133
files, "No Svelte configuration found in vite config" cascade — not
caused by R8); eslint reports 126 errors + 12 warnings (unused vars,
`@html` XSS, unused CSS); prettier reports 175 unformatted files. These
are real findings surfaced by the new toolchain — fixing them is a
follow-up cleanup task.
### C.2 Dead code — the tool-renderer registry (21 files, ~1.5k lines)
`src/main.ts:25` lazy-imports `./lib/renderers`, which runs `renderers/index.ts`
calling 10 `init*()` functions that each `registerToolRenderer(...)`. But
**`getToolRenderer` is never called anywhere**. The whole subsystem is dead:
- `src/lib/tool-renderers.ts`
- `src/lib/renderers/index.ts`
- `src/lib/renderers/{blast-radius,change-log,entity-card,entity-table,execution-status,fleet-snapshot,health-summary,knowledge-results,lxc-list,metric-chart}.ts` (10)
- `src/lib/renderers/{BlastRadius,ChangeLog,EntityCard,EntityTable,ExecutionStatus,FleetSnapshot,HealthSummary,KnowledgeResults,LXCList,MetricChart}.svelte` (10)
**Either wire it up or delete all 21 files.** Note: `HealthSummary.svelte:30`
emits a `state_referenced_locally` Svelte 5 warning — dead code generating
lint noise.
### C.3 Dead components, stores, deps
Dead Svelte components (never imported outside self/comments):
- `src/lib/components/ToolCallGroup.svelte`
- `src/lib/components/PlanProgress.svelte`
- `src/lib/components/GoalHeader.svelte` (only in a comment)
- `src/lib/components/InlineApproval.svelte` (only in a comment)
- `src/lib/components/SessionDigest.svelte` + its API fn `fetchSessionDigest`
(`src/lib/api.ts:354,363`) — dead chain.
Dead store exports (written, never read):
- `src/lib/stores/context.ts:10` `pendingApprovals`
- `src/lib/stores/events.ts:18` `connectionState`
Dead npm deps:
- `mode-watcher` (`package.json:16`) — 0 imports; superseded by
`src/lib/stores/theme.svelte.ts`.
- `@internationalized/date` (`package.json:12`) — 0 imports.
Naming collision: `src/lib/components/EntityTable.svelte` (live) vs
`src/lib/renderers/EntityTable.svelte` (dead) — same filename, easy to grab
the wrong one.
### C.4 Type safety
No `@ts-ignore`/`@ts-expect-error`. But `any` is pervasive in the SSE/event
plumbing — defining an `OikosEvent` discriminated union would eliminate ~15
`any` sites:
- `src/lib/api.ts:32,107``ChatEvent.data: any`, interface `data: any`
- `src/lib/stores/chat.ts:58-59``ToolCallResult.args?: any; result?: any`
- `src/lib/stores/activity.ts:73-74``(t.args as any)?.seq`
- `src/lib/stores/workspace.ts:68,83,104,152``data: any`, `as any`, `s: any`
- All 10 dead renderers use `(tool.result as any).data` + `as any[]`
- `src/pages/Config.svelte:57,74``(window as any).wails`, `catch (e: any)`
- `src/lib/utils.ts:45,47``T extends { child?: any }`
- `vite.config.ts:18-24``proxy: any`, `proxyReq: any`
Missing return types on exported functions: `src/lib/utils.ts:4` (`cn`),
`src/lib/config.ts:23,42,50`, `src/lib/tool-renderers.ts:11`,
`src/lib/stores/context.ts:18`, `src/lib/stores/events.ts:23,46`,
`src/lib/stores/chat.ts:98,185,422,448,469`, `src/lib/oidc.ts:270`.
### C.5 Svelte 5 idioms — mostly clean
- `export let`: 0. `$:` labels: 0. `on:click`: 0. `createEventDispatcher`: 0.
`<slot>`: 0 real usage. ✅ App is cleanly on runes.
- Mix of `svelte/store` classic stores (`stores/{activity,chat,context,events,workspace}.ts`) and `.svelte.ts` runes modules (`theme`, `is-mobile`, sidebar context). Deliberate but could be unified.
- `src/lib/components/ActivityTimeline.svelte:103``<svelte:component>` is
**deprecated in runes mode**; components are dynamic by default. Replace with
direct `{@const Comp = icon}{<Comp .../>}` or inline.
- `src/lib/components/DetailSection.svelte:18``let open = $state(defaultOpen)`
triggers `state_referenced_locally`; wrap in `$derived`/init via `$effect` if
reactivity to `defaultOpen` is intended.
### C.6 Build/config
- `vite.config.ts:6-14` — reads `../VERSION` or `./VERSION`; **no fallback if
both missing** — `readFileSync('VERSION')` throws and crashes `vite
build`/`dev` silently. Add a fallback or a build-time check.
- `vite.config.ts:38-50``server.proxy` hardcodes `localhost:8090` (API) and
`localhost:8092` (nomos). Not env-driven.
- `vite.config.ts:31-34``define: { __OIKOS_VERSION__: ... }` global is used
in `src/lib/version.ts:1` but its declaration in `vite-env.d.ts` should be
verified.
- Bundle warning: single 722 KB JS chunk. Add `build.rollupOptions.output.
manualChunks` or route-level dynamic imports.
### C.7 Hardcoded values
- `src/lib/oidc.ts:117` — `http://127.0.0.1:18901/oidc/start` (desktop OIDC
broker port). Magic number, no constant.
- No tokens/secrets in `src/`. Auth via `localStorage`/OIDC. ✅
- 4 `fetch()` calls, all via `apiBase(...)`. No hardcoded hosts in fetch. ✅
### C.8 Accessibility
Generally decent (aria-label, role="button", tabindex, keyboard handlers).
Gaps:
- `src/pages/Chat.svelte:182` — bare `×` dismiss button missing `type="button"`.
- `src/lib/components/{SessionGraph,EntityGraph}.svelte` — SVG `<g role="button">`
nodes keyboard-activatable but no `aria-label` (node identity not announced).
- `src/pages/Chat.svelte:107` — scroll container has no `role="log"`/`aria-live`
for streamed messages.
## D. Documentation
### D.1 Stale references — fixed in this pass
- `AGENTS.md:115-117` — **ghost of retired `request_execution`**. Second `run`
bullet listed the retired enum actions and contradicted the retire notice
above it. Deleted.
- `AGENTS.md:148` — referenced `knowledge/wiki/` (does not exist); corrected to
`archive/knowledge/`.
- `README.md:116` — broken link to `plans/2026-07-12-wails-desktop-app.md`
(moved to `plans/done/`). Fixed.
- `.agents/OIKOS.md:103` — broken link to
`../plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md`
(in `plans/done/`). Fixed.
- `.agents/operations/commands.md:52` — broken link with extra `/archive/`
segment. Fixed.
- `.agents/operations/commands.md:85` — broken link to wails plan. Fixed.
- `.agents/shared/page-templates.md:12` — listed `HERMES.md` (renamed to
`NOMOS.md` per ADR 0012). Fixed.
### D.2 Brittle counts — fixed in this pass
Replaced hardcoded rot-prone numbers with pointers to the source of truth:
- `AGENTS.md:45-46` — "36 documents, 6 investigations, 12 runbooks" → pointer
to `seeds/knowledge.yaml`.
- `AGENTS.md:165` — "33 MCP tools" → "see §3 for the current tool list".
- `AGENTS.md:195,203` — "as of 2026-07-12" point-in-time dates removed.
- `.agents/OIKOS.md:108` — "migrations/ (001011)" → "(001020, forward-only)".
- `.agents/OIKOS.md:111-112` — duplicate brittle counts → pointer.
- `.agents/OIKOS.md:142` — "15 MCP tools" → pointer to AGENTS.md §3.
- `README.md:52` — "15 tools" → pointer.
Remaining brittle numbers (left as-is, intrinsic to evidence trail):
- `docs/mbse/README.md` carries many counts/dates as part of its audited
evidence trail. Recommend adding a "Last verified: YYYY-MM-DD" header to that
file and a scheduled re-verification (see §F).
### D.3 Substrate docs describing deleted Python architecture — fixed in R5
`.agents/domains/knowledge/schema.md` and `.agents/shared/llm-wiki.md` previously described
`bin/homelab`, `oikos/cards/`, `oikos/ledger.py`, root `inventory.yaml`, `knowledge/sources/`,
`get_page`/`search_docs` MCP tools — none of which exist. **Rewritten** for the DB-native model
(ADR 0003): DB is the single source of truth for both structured data and narrative knowledge;
`seeds/*.yaml` are the bootstrap+DR manifests; `archive/knowledge/` is the frozen legacy wiki;
MCP `search_knowledge`/`get_entity_knowledge` replace `get_page`/`search_docs`. Substrate refs in
`.agents/shared/{writing-style,page-templates}.md` and
`.agents/domains/operations/schema.md` swept clean. Root `inventory.yaml` marked deprecated
(stub points to `seeds/inventory.yaml` + DB; full on-client path reconciliation deferred to R13).
### D.4 ADR format
- 00010015 sequential, no gaps, indexed in `docs/adr/README.md`. ✅
- Template drift: `0013-signal-triggers.md` uses `## Overview` (no
Context/Decision/Consequences); `0014-entity-model.md` uses numbered
sections, no MADR template. Status-line format differs between 00010010/0015
(plain) and 00110014 (bold split). **Normalize** (low priority — ADRs are
immutable history; consider a formatting pass only).
### D.5 Plans — fixed in this pass
- Moved 4 "Done" 2026-07-14 plans from `plans/` to `plans/done/`
(session-reliability-and-ux-audit, tool-timeline-sidebar,
unified-agent-indicator, post-fix-session-remainders).
- Added 2 missing 2026-07-14 plans + 2 missing 2026-07-15 `done/` plans to
`plans/index.md`.
- Updated `plans/index.md` Done table to reflect the moves.
### D.6 Missing docs — fixed in this pass
- Created `docs/index.md` (top-level docs index, per `writing-style.md` §folder
READMEs).
- Created `docs/operations/README.md` (operations docs index).
### D.7 On-client path inconsistency
`AGENTS.md` uses `/opt/homelab-context/`; `CLIENTS.md` and `AGENTS.md:200`
itself use `/opt/homelab/`. **Pick one and use consistently** (recommend
`/opt/homelab/` per `CLIENTS.md:70-71`). Deferred — touches many lines and
the actual deployed path needs confirming against an enrolled client.
### D.8 Legacy root `inventory.yaml` — deprecated in R5
20387-byte Python-era file superseded by `seeds/inventory.yaml` on 2026-07-07. **Replaced with a
deprecation stub** pointing to `seeds/inventory.yaml` and the DB (ADR 0003). Kept as a stub rather
than deleted because AGENTS.md §1/§2 still point clients at `/opt/homelab-context/inventory.yaml`
(the on-client clone path); full path reconciliation is R13. `.agents/shared/*` and
`.agents/domains/*` references to bare `inventory.yaml` swept to `seeds/inventory.yaml` or
qualified as `archive/knowledge/` history.
## E. Build & tooling
### E.1 `Makefile`
- `make build` (`BINARY := oikos`) writes to `oikos/oikos` because `oikos/`
exists as a directory. Functionally works (gitignored) but confusing — the
gitignore comment says `bin/oikos`. **Recommend `BINARY := bin/oikos`** or
rename the directory.
- `Makefile:70` `desktop-package` hardcodes `sed 's/$$(VERSION)/0.1.0/'`. Fixed
in this pass to read from the `VERSION` file.
- `lint` target only runs `go vet` + optional `golangci-lint`. **Recommend
installing golangci-lint + staticcheck + govulncheck** in CI (none are
installed locally; CI config at `.gitea/workflows/ci.yml` should be checked).
### E.2 Desktop version hardcode — fixed in R6
`cmd/desktop/main.go:39` had `version = "0.1.0"` as a const while the repo is at `0.7.8`. Per
`CONTRIBUTING.md:54`, the auto-update feature compares against this value — so every release tag >
0.1.0 triggered a spurious update prompt. **Fixed:** `version` is now a `var` (default
`"0.1.0-dev"` fallback for bare `go build`), injected from the `VERSION` file at link time via
`make desktop` (`-ldflags "-X main.version=$(cat VERSION)"`). `CONTRIBUTING.md` updated to match.
### E.3 `Makefile` `BINARY` collision — fixed in R6
`BINARY := oikos` wrote to `./oikos`, which collided with the `oikos/` directory (Go's `-o oikos`
into a directory named `oikos` created `oikos/oikos`). **Fixed:** `BINARY := bin/oikos` (matches
the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `bin/`. Stale
`oikos/` cruft directory removed.
## F. Recommendations (actionable, ordered)
| ID | Action | Effort | Risk |
| -- | ------ | ------ | ---- |
| R1 | Delete dead Go: `notifier.VerifyApprovalToken`, `httpapi/stubs.go`; unexport 4 `checkdefaults` symbols | S | Low | ✅ done (c3973e7) |
| R2 | Delete dead web: 21-file tool-renderer registry, 5 dead components, 2 dead store exports, 2 dead npm deps | S | Low | ✅ done (c3973e7+1) |
| R3 | Decide sqlc vs raw SQL: delete 17 dead queries OR migrate inline SQL to use them | M | Medium | ✅ done (hybrid: 8 deleted, 9 migrated) |
| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | ✅ done |
| R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low | ✅ done |
| 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 | ✅ 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 |
| R13 | Reconcile on-client path (`/opt/homelab/` vs `/opt/homelab-context/`) across AGENTS.md + CLIENTS.md | S | Low | ✅ done |
| R14 | Install `golangci-lint`/`staticcheck`/`govulncheck` locally + in CI | S | Low | ✅ done — `.golangci.yml` config + split Makefile targets |
## G. Verification
- `go vet ./...` — clean.
- `go build -tags timetzdata ./cmd/oikos` — clean.
- `go test -race -short ./...` — all tested packages pass.
- `npm run build` — succeeds with Svelte 5 warnings (listed §C.5).
- Doc fixes: all link targets verified to exist.
## H. What this commit changed
Applied (low-risk, reversible):
- Created this plan.
- Fixed 7 stale/broken doc references (AGENTS.md, README.md, OIKOS.md,
commands.md, page-templates.md).
- Removed 8 brittle hardcoded counts/dates; replaced with pointers to source.
- Created `docs/index.md` and `docs/operations/README.md`.
- Moved 4 done plans to `plans/done/`; reconciled `plans/index.md`.
- Added `check`/`typecheck`/`lint` scripts + `svelte-check` devDep to
`web/package.json`.
- Fixed `Makefile:70` `desktop-package` version substitution.
- Bumped `VERSION` 0.7.6 → 0.7.7.
Deferred (listed as recommendations R1R14 above): all code deletions,
refactors, test additions, and the substrate-doc rewrite.

View File

@@ -14,10 +14,9 @@ went sideways, open an investigation.
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — `request_execution` enum retired (60effcb); only auto-act revival (item 10) still open | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — `request_execution` enum retired (60effcb); only auto-act revival (item 10) still open |
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
| 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed | | 2026-07-14 | [Activity gaps](2026-07-14-activity-gaps.md) | In Progress |
| 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 | | 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
| 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 | | 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](2026-07-14-post-fix-session-remainders.md) | Done — all 18 fixes shipped, eval-validated (4/4 golden evals pass), committed (337d577 + 3de359b + dd3076a), deployed v0.5.3. OIDC token-refresh fix (PM) also shipped (3b98097) |
## Done ## Done
@@ -49,6 +48,12 @@ See [`done/`](done/) for executed plans:
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) | | 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) | | 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) | | 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
| 2026-07-14 | [Session reliability & UX audit](done/2026-07-14-session-reliability-and-ux-audit.md) |
| 2026-07-14 | [Tool timeline in sidebar](done/2026-07-14-tool-timeline-sidebar.md) |
| 2026-07-14 | [Unified agent activity indicator](done/2026-07-14-unified-agent-indicator.md) |
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](done/2026-07-14-post-fix-session-remainders.md) |
| 2026-07-15 | [Plan-first and iteration](done/2026-07-15-plan-first-and-iteration.md) |
| 2026-07-15 | [WhatsApp session audit](done/2026-07-15-whatsapp-session-audit.md) |
## Conventions ## Conventions

4
web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
build/
package-lock.json

9
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

39
web/eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
files: ['**/*.{ts,js,svelte}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['dist/', 'node_modules/', 'build/', '*.config.{ts,js}']
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
}
)

3971
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,23 +6,38 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
"mode-watcher": "^1.1.0", "eslint": "^9.0.0",
"eslint-plugin-svelte": "^2.46.0",
"globals": "^15.0.0",
"jsdom": "^25.0.0",
"prettier": "^3.3.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^6.0.0" "typescript-eslint": "^8.0.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -29,7 +29,7 @@ export interface Message {
id: string id: string
session_id: string session_id: string
role: string role: string
content: any content: MessageContent | string
created_at: string created_at: string
} }
@@ -102,13 +102,6 @@ export async function answerQuestion(sessionId: string, questionId: string, answ
return res.ok return res.ok
} }
export interface ChatEvent {
type: string
data: any
session_id?: string
iteration?: number
}
export function streamChat( export function streamChat(
message: string, message: string,
sessionId: string | null, sessionId: string | null,
@@ -351,21 +344,6 @@ export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
return data.items ?? [] return data.items ?? []
} }
export interface SessionDigest {
session_id: string
total_executions: number
by_status: Record<string, number>
entities_touched: string[]
executions: { target: string; verb: string; summary: string; risk_class: string; status: string }[]
knowledge_created: string[]
}
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
if (!res.ok) return null
return res.json()
}
export interface CapabilityTimelineItem { export interface CapabilityTimelineItem {
verb: string verb: string
first_success: string | null first_success: string | null

View File

@@ -100,7 +100,8 @@
{:else if entry.status === 'failed'} {:else if entry.status === 'failed'}
<CircleXIcon class="size-3.5" /> <CircleXIcon class="size-3.5" />
{:else if icon} {:else if icon}
<svelte:component this={icon} class="size-3" /> {@const IconComp = icon}
<IconComp class="size-3" />
{:else} {:else}
<CircleDotIcon class="size-3" /> <CircleDotIcon class="size-3" />
{/if} {/if}

View File

@@ -15,7 +15,10 @@
children: Snippet children: Snippet
} = $props() } = $props()
let open = $state(defaultOpen) let open = $state(false)
$effect(() => {
open = defaultOpen
})
</script> </script>
<Collapsible.Root bind:open class="rounded-md border bg-card"> <Collapsible.Root bind:open class="rounded-md border bg-card">

View File

@@ -7,7 +7,7 @@
// Lets a relation click inside the sheet drill into that entity in place, // Lets a relation click inside the sheet drill into that entity in place,
// without closing/reopening. Resets to the externally-requested slug // without closing/reopening. Resets to the externally-requested slug
// whenever the caller opens the sheet on a different entity. // whenever the caller opens the sheet on a different entity.
let currentSlug = $state(slug) let currentSlug = $state<string | null>(null)
$effect(() => { $effect(() => {
currentSlug = slug currentSlug = slug
}) })

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { currentTask } from '$lib/stores/workspace'
import { Badge } from '$lib/components/ui/badge'
type StatusStyle = { label: string; dot: string; pulse: boolean; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
function statusStyle(status: string | undefined, outcome: string | undefined): StatusStyle {
switch (status) {
case 'awaiting_input':
return { label: 'Needs your input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
case 'done':
return outcome === 'partial'
? { label: 'Done · partial', dot: 'bg-warning', pulse: false, variant: 'secondary' }
: { label: 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
case 'failed':
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
case 'planning':
return { label: 'Planning', dot: 'bg-primary', pulse: true, variant: 'secondary' }
case 'executing':
return { label: 'Executing', dot: 'bg-primary', pulse: true, variant: 'secondary' }
default:
return { label: 'Active', dot: 'bg-muted-foreground', pulse: false, variant: 'outline' }
}
}
</script>
{#if $currentTask}
{@const st = statusStyle($currentTask.status, $currentTask.outcome)}
<div class="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2.5">
<div class="flex items-center gap-1.5">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
<Badge variant={st.variant} class="text-[10px]">{st.label}</Badge>
</div>
<p class="text-sm font-medium leading-snug">
{$currentTask.goal || $currentTask.title || 'Untitled task'}
</p>
</div>
{/if}

View File

@@ -1,261 +0,0 @@
<script lang="ts">
import type { PendingApproval } from '$lib/stores/chat'
import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { SvelteMap } from 'svelte/reactivity'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import Spinner from './Spinner.svelte'
import NetworkIcon from '@lucide/svelte/icons/network'
let { approvals }: { approvals: PendingApproval[] } = $props()
// Downstream entities the target affects, keyed by executionId — fetched
// once per approval so the operator sees the graph-walk impact ("this
// affects 3 downstream") before deciding, not after. depth 0 is the target
// itself, excluded here since it's already shown as "on {target}".
const blastRadius = new SvelteMap<string, string[]>()
const blastRadiusFetched = new Set<string>()
async function loadBlastRadius(a: PendingApproval) {
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
blastRadiusFetched.add(a.executionId)
const items = await fetchBlastRadius(a.target)
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
if (affected.length) blastRadius.set(a.executionId, affected)
}
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
// action buttons permanently so the banner clears after a click and can
// never re-POST /decision.
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied' | 'stalled'
const phase = new SvelteMap<string, Phase>()
// Latest execution row (for status/result display), keyed by executionId.
const exec = new SvelteMap<string, Execution>()
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
function errorText(e: Execution | undefined): string {
const r = e?.result as Record<string, unknown> | undefined | null
const v = r?.error
return typeof v === 'string' && v ? v : 'Execution failed.'
}
function outputText(e: Execution | undefined): string {
const r = e?.result as Record<string, unknown> | undefined | null
const v = r?.output
return typeof v === 'string' ? v.trim() : ''
}
function elapsedSeconds(e: Execution | undefined): number | null {
if (!e?.created_at) return null
return Math.max(0, Math.round((now - new Date(e.created_at).getTime()) / 1000))
}
function fmtDuration(s: number): string {
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
return `${m}m ${s % 60}s`
}
// Live clock for the elapsed-time display on running cards. Tied to
// component lifecycle via $effect so the interval is guaranteed cleared on
// unmount — a bare setInterval field here would leak a 1Hz timer for the
// lifetime of the page every time this component was mounted.
let now = $state(Date.now())
$effect(() => {
const t = setInterval(() => { now = Date.now() }, 1000)
return () => clearInterval(t)
})
// Backend commands are hard-capped at 10 minutes (internal sshExec
// timeout) before the execution is force-finalized as failed — so polling
// must outlast that with margin, or the UI gives up and goes stale before
// the backend ever resolves. Poll for 14 minutes; anything still running
// past that is a genuine anomaly worth surfacing distinctly rather than
// silently going quiet.
const POLL_CEILING_MS = 14 * 60 * 1000
// Poll the execution until it reaches a terminal state, so the operator sees
// provisioning progress and the final outcome without leaving the chat.
async function track(id: string) {
const deadline = Date.now() + POLL_CEILING_MS
while (Date.now() < deadline) {
const e = await getExecution(id)
if (e) {
exec.set(id, e)
if (e.status === 'completed') { phase.set(id, 'completed'); return }
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
}
await new Promise((r) => setTimeout(r, 2500))
}
// Genuinely outlasted the backend's own hard timeout — this means
// something is wrong beyond a slow command (e.g. the API is down).
// Say so explicitly instead of freezing on "running" with no signal.
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'stalled')
}
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
const id = approval.executionId
const cur = phase.get(id)
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
phase.set(id, 'deciding')
const ok = await decideApproval(id, decision)
if (!ok) { phase.set(id, 'failed'); return }
if (decision === 'deny') { phase.set(id, 'denied'); return }
phase.set(id, 'running')
void track(id)
}
// Self-heal: a pending approval can be decided somewhere other than this
// button — chat assent ("go ahead" in the next message), the Ops page, or
// Matrix. Without this, the banner would sit showing Approve/Deny forever
// while the action was already running or done behind the scenes. Poll
// every card that's still showing buttons; the moment its execution leaves
// pending_approval, adopt that outcome exactly as if the button had been
// clicked. Stops immediately if the operator clicks the button first
// (phase becomes non-empty, ending this loop's reason to exist).
const watching = new Set<string>()
async function watchExternal(id: string) {
if (watching.has(id)) return
watching.add(id)
for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
if (phase.get(id)) return // resolved locally (button click) or already picked up
const e = await getExecution(id)
if (e && e.status !== 'pending_approval') {
exec.set(id, e)
if (e.status === 'completed') { phase.set(id, 'completed'); return }
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
// 'approved' or 'running': someone said yes elsewhere — switch to
// the same tracking the button click would have started.
phase.set(id, 'running')
void track(id)
return
}
await new Promise((r) => setTimeout(r, 3000))
}
}
$effect(() => {
for (const a of approvals) {
if (!phase.get(a.executionId)) {
void watchExternal(a.executionId)
void loadBlastRadius(a)
}
}
})
</script>
{#each approvals as approval (approval.executionId)}
{@const p = phase.get(approval.executionId)}
{@const e = exec.get(approval.executionId)}
{#if p === 'completed'}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
<div class="flex items-center gap-2">
<CheckIcon class="size-4 shrink-0" />
<span>Completed{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''} on {approval.target}.</span>
</div>
{#if outputText(e)}
<pre class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words pl-6 opacity-80">{outputText(e)}</pre>
{/if}
</div>
{:else if p === 'failed'}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div class="flex items-center gap-2">
<XIcon class="size-4 shrink-0" />
<span class="font-medium">Execution failed</span>
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
</div>
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
</div>
{:else if p === 'denied'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<XIcon class="size-4" /><span>Denied.</span>
</div>
{:else if p === 'running' || p === 'deciding'}
{@const secs = elapsedSeconds(e)}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
<div class="flex items-center gap-2">
<Spinner class="size-4 shrink-0 text-warning" />
<span>
{#if p === 'deciding'}
Submitting approval…
{:else}
Running on {approval.target}{secs !== null ? ` — ${fmtDuration(secs)} elapsed` : '…'}
{/if}
</span>
</div>
{#if p === 'running' && approval.command}
<code class="ml-6 block truncate opacity-70">{approval.command}</code>
{/if}
{#if p === 'running'}
<span class="ml-6 opacity-60">
Execution <code>{approval.executionId.slice(0, 8)}</code> — long installs can take several minutes; this
will resolve on its own (capped at 10 min) or you can check the Operations page for live output.
</span>
{/if}
</div>
{:else if p === 'stalled'}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div class="flex items-center gap-2">
<XIcon class="size-4 shrink-0" />
<span class="font-medium">No update from the server in over 14 minutes.</span>
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => { phase.delete(approval.executionId); void track(approval.executionId) }}>
Check again
</Button>
</div>
<span class="pl-6 opacity-90">
The command itself is capped at 10 minutes server-side, so this is unusual — the API may be unreachable.
Execution <code>{approval.executionId}</code>. Check the Operations page directly.
</span>
</div>
{:else if approval.destructive}
{@const affected = blastRadius.get(approval.executionId)}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
<div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
<span class="flex-1 text-xs text-destructive">
<strong>DESTRUCTIVE</strong>{approval.action} on {approval.target}. Type
"I confirm" in chat, or use the button.
</span>
<Button size="sm" variant="destructive" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
<CheckIcon class="size-3" /><span class="ml-1">Confirm</span>
</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
<XIcon class="size-3" /><span class="ml-1">Deny</span>
</Button>
</div>
{#if approval.command}
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
{/if}
{#if affected}
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
</div>
{/if}
</div>
{:else}
{@const affected = blastRadius.get(approval.executionId)}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
<div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
<XIcon class="size-3" /><span class="ml-1">Deny</span>
</Button>
</div>
{#if affected}
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
</div>
{/if}
</div>
{/if}
{/each}

View File

@@ -1,116 +0,0 @@
<script lang="ts">
import { planSteps } from '$lib/stores/workspace'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import CircleIcon from '@lucide/svelte/icons/circle'
import Spinner from './Spinner.svelte'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
const total = $derived($planSteps.length)
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
// Group steps by generation. The latest generation is shown expanded;
// older ones are collapsible.
const byGeneration = $derived.by(() => {
const groups: Map<number, typeof $planSteps> = new Map()
for (const s of $planSteps) {
const g = s.generation ?? 1
if (!groups.has(g)) groups.set(g, [])
groups.get(g)!.push(s)
}
return Array.from(groups.entries()).sort(([a], [b]) => a - b)
})
const latestGen = $derived(byGeneration.length > 0 ? byGeneration[byGeneration.length - 1][0] : 0)
let openGens = $state(new Set<number>())
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
function openStep(targetSlug: string | undefined) {
if (!targetSlug) return
sheetSlug = targetSlug
sheetOpen = true
}
function toggleGen(gen: number) {
if (openGens.has(gen)) openGens.delete(gen)
else openGens.add(gen)
openGens = new Set(openGens)
}
</script>
{#if total > 0}
<div class="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span class="font-semibold uppercase tracking-wider">Plan</span>
<span>{done}/{total}</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
</div>
{#each byGeneration as [gen, steps] (gen)}
{@const isLatest = gen === latestGen}
{#if byGeneration.length > 1}
<button
type="button"
class="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
onclick={() => toggleGen(gen)}
>
{#if openGens.has(gen) || isLatest}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
<span>{isLatest ? 'Current plan' : `Plan v${gen} (replaced)`}</span>
</button>
{/if}
{#if isLatest || openGens.has(gen)}
<ol class="flex flex-col gap-1">
{#each steps as step (step.id)}
<li>
<button
type="button"
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'} {gen !== latestGen ? 'opacity-50' : ''}"
onclick={() => openStep(step.target_slug)}
>
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3.5 text-success" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'running'}
<Spinner class="size-3.5 text-primary" />
{:else if step.status === 'skipped' || step.status === 'replaced'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
{step.title}
</span>
{#if step.target_slug}
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
{/if}
</span>
</button>
</li>
{/each}
</ol>
{/if}
{/each}
</div>
{/if}
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />

View File

@@ -1,215 +0,0 @@
<script lang="ts">
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
import { currentSession, streaming, toolTimeline, type ToolTimelineEntry } from '$lib/stores/chat'
import { currentTask, planSteps } from '$lib/stores/workspace'
import { Badge } from '$lib/components/ui/badge'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleIcon from '@lucide/svelte/icons/circle'
import Spinner from './Spinner.svelte'
import WrenchIcon from '@lucide/svelte/icons/wrench'
let digest = $state<SessionDigest | null>(null)
let open = $state(false)
let openTools = $state(false)
let loadedKey = $state<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
$effect(() => {
const sid = $currentSession
const busy = $streaming
const status = $currentTask?.status ?? ''
if (!sid || busy) return
const key = `${sid}:${status}`
if (loadedKey !== key) {
loadedKey = key
fetchSessionDigest(sid).then((d) => (digest = d))
}
if (status !== 'done' && status !== 'failed') {
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
} else {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
return () => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
})
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
if (status === 'completed') return 'default'
if (['running', 'approved'].includes(status)) return 'secondary'
return 'outline'
}
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
const planTotal = $derived($planSteps.length)
// Group tool timeline entries by message (turn), showing only unique tool names per entry.
const toolGroups = $derived.by(() => {
const groups: { msgIndex: number; entries: ToolTimelineEntry[] }[] = []
for (const e of $toolTimeline) {
const last = groups[groups.length - 1]
if (last && last.msgIndex === e.msgIndex) {
last.entries.push(e)
} else {
groups.push({ msgIndex: e.msgIndex, entries: [e] })
}
}
return groups
})
const toolCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use').length)
const runningCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use' && !$toolTimeline.some((r) => r.type === 'tool_result' && r.id === t.id)).length)
function toolSummary(t: ToolTimelineEntry): string {
if (!t.args || typeof t.args !== 'object') return t.name
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
if (typeof firstArg === 'string') return `${t.name} ${firstArg.slice(0, 40)}`
return t.name
}
</script>
{#if $streaming && $currentSession}
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
<Spinner class="size-3 shrink-0 text-primary" />
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
</div>
{/if}
{#if $currentTask?.outcome}
<div
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
? 'bg-destructive/5 text-destructive'
: $currentTask.outcome === 'partial'
? 'bg-warning/5 text-warning'
: 'bg-success/5 text-success'}"
>
{#if $currentTask.outcome === 'failure'}
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
{:else}
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
{/if}
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
</div>
{/if}
{#if $planSteps.length > 0}
<div class="flex shrink-0 flex-col gap-1 border-b px-3 py-2">
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span class="font-semibold uppercase tracking-wider">Plan</span>
<span>{planDone}/{planTotal}</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
</div>
<ol class="flex flex-col gap-0.5">
{#each $planSteps as step (step.id)}
<li class="flex items-start gap-1.5 text-[11px] {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3 text-success" />
{:else if step.status === 'running'}
<Spinner class="size-3 text-primary" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3 text-destructive" />
{:else}
<CircleIcon class="size-3 text-muted-foreground" />
{/if}
</span>
<span class="leading-tight">{step.title}</span>
</li>
{/each}
</ol>
</div>
{/if}
{#if toolCount > 0}
<div class="border-b">
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
onclick={() => (openTools = !openTools)}
>
<span class="flex items-center gap-1.5">
{#if openTools}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
<WrenchIcon class="size-3 text-muted-foreground" />
Tool activity
</span>
<span class="text-muted-foreground">{toolCount} call{toolCount === 1 ? '' : 's'}</span>
</button>
{#if openTools}
<div class="flex flex-col gap-0.5 px-3 pb-2 text-xs">
{#each toolGroups as group}
{@const isLatest = group.msgIndex === toolGroups[toolGroups.length - 1]?.msgIndex}
<div class="rounded border px-2 py-1 {isLatest && $streaming ? 'border-primary/30 bg-primary/5' : ''}">
{#each group.entries as t (t.id)}
<div class="flex items-start gap-1.5 {t.type === 'tool_result' && t.error ? 'text-destructive' : ''}">
<span class="mt-0.5 shrink-0">
{#if t.type === 'tool_result' && t.error}
<CircleXIcon class="size-3 text-destructive" />
{:else if t.type === 'tool_result'}
<CircleCheckIcon class="size-3 text-success" />
{:else}
<Spinner class="size-3 text-primary" />
{/if}
</span>
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if digest && digest.total_executions > 0}
<div class="border-b">
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
onclick={() => (open = !open)}
>
<span class="flex items-center gap-1.5">
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
This session
</span>
<span class="flex items-center gap-1.5 text-muted-foreground">
{digest.total_executions} execution{digest.total_executions === 1 ? '' : 's'}
{#if digest.knowledge_created.length}
<span class="flex items-center gap-0.5 text-primary">
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
</span>
{/if}
</span>
</button>
{#if open}
<div class="flex flex-col gap-2 px-3 pb-3 text-xs">
<div class="flex flex-wrap gap-1">
{#each Object.entries(digest.by_status) as [status, count]}
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
{/each}
</div>
{#if digest.knowledge_created.length}
<div>
<div class="mb-1 flex items-center gap-1 text-primary">
<SparklesIcon class="size-3" />Learned this session
</div>
<ul class="list-inside list-disc text-muted-foreground">
{#each digest.knowledge_created as title}
<li>{title}</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import * as Collapsible from '$lib/components/ui/collapsible'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import Spinner from './Spinner.svelte'
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
let open = $state(false)
let wasActive = $state(active)
const bodyTools = $derived(unmatched ?? tools)
const inlineCount = $derived(tools.length - bodyTools.length)
$effect(() => {
if (active && !wasActive) open = true
if (!active && wasActive) open = false
wasActive = active
})
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
const total = $derived(bodyTools.length)
const runningTool = $derived(
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
)
function toolLabel(t: ToolCallResult): string {
if (!t.args || typeof t.args !== 'object') return t.name
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
if (typeof firstArg === 'string' && firstArg.length < 50) return `${t.name} ${firstArg}`
return t.name
}
</script>
{#if bodyTools.length}
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded border bg-card/50 text-xs">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
<span class="shrink-0">
{#if active && doneCount < total}
<Spinner class="size-3 text-primary" />
{:else if hasError}
<XIcon class="size-3 text-destructive" aria-hidden="true" />
{:else}
<CheckIcon class="size-3 text-muted-foreground" aria-hidden="true" />
{/if}
</span>
<span class="text-muted-foreground">
{#if active && doneCount < total}
{doneCount}/{total}
{:else}
{total} tool{total === 1 ? '' : 's'}
{/if}
{#if inlineCount > 0}
<span> · {inlineCount} inline</span>
{/if}
</span>
{#if active && runningTool}
<span class="font-mono truncate">{toolLabel(runningTool)}<span class="animate-pulse"></span></span>
{/if}
<ChevronDownIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 ml-auto {open ? 'rotate-180' : ''}"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1">
<div class="flex flex-col divide-y border-t px-2 py-1">
{#each bodyTools as tool (tool.id)}
<div class="flex items-center gap-1.5 py-0.5">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<Spinner class="size-3 shrink-0 text-primary" />
{/if}
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const grouped = $derived.by(() => {
if (!rows) return null
const g: Record<number, string[]> = {}
for (const r of rows) {
const d = Number(r.depth) || 0
if (!g[d]) g[d] = []
g[d].push(r.slug)
}
return g
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(rows?.length ?? 0)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="animate-pulse text-muted-foreground">calculating…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-destructive">{error}</span>
</div>
{:else if grouped && total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
<div class="px-3 py-2">
<div class="mb-1 font-medium text-muted-foreground">
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
</div>
<div class="flex flex-wrap gap-1">
{#each slugs as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-muted-foreground">no affected entities found</span>
</div>
{/if}

View File

@@ -1,92 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
function shortTs(ts: string): string {
try {
const d = new Date(ts)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts?.slice(11, 19) ?? ''
}
}
function shortDate(ts: string): string {
return ts?.slice(0, 10) ?? ''
}
function shortId(id: string): string {
if (!id) return ''
return id.length > 12 ? id.slice(0, 12) : id
}
const activityRows = $derived.by(() => {
if (!rows) return null
return rows.map((r) => ({
time: shortTs(r.timestamp || r.ts || ''),
date: shortDate(r.timestamp || r.ts || ''),
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
action: r.action || r.activity_type || '',
toolName: r.tool_name || r.path || '',
status: r.success ?? (r.error ? 'false' : undefined),
}))
})
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-destructive">{error}</span>
</div>
{:else if activityRows && activityRows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each activityRows as row}
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
<span class="shrink-0 text-muted-foreground">{row.time}</span>
{#if row.date !== activityRows[0].date}
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
{/if}
<span class="text-muted-foreground">{row.action}</span>
<span class="max-w-32 truncate">{row.toolName}</span>
<span class="text-muted-foreground/60">{row.actor}</span>
{#if row.status === 'true'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
{:else if row.status === 'false'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-muted-foreground">no entries</span>
</div>
{/if}

View File

@@ -1,118 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { Badge } from '$lib/components/ui/badge'
import { relativeTime } from '$lib/utils'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import BoxIcon from '@lucide/svelte/icons/box'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import ContainerIcon from '@lucide/svelte/icons/container'
import GlobeIcon from '@lucide/svelte/icons/globe'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import ZapIcon from '@lucide/svelte/icons/zap'
let { tool }: { tool: ToolCallResult } = $props()
const entity = $derived.by(() => {
if (tool.type !== 'tool_result') return null
const r = tool.result
if (!r) return null
if (Array.isArray(r)) return r[0]
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
return r
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
const typeIcon: Record<string, typeof BoxIcon> = {
host: MonitorIcon,
lxc: ContainerIcon,
service: GlobeIcon,
check: ZapIcon,
}
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
const keyAttrs = $derived.by(() => {
if (!entity) return [] as [string, string][]
const out: [string, string][] = []
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
const v = entity[k]
if (v && typeof v === 'string') {
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
}
}
const attrs = entity.attributes ?? entity.attrs
if (attrs && typeof attrs === 'object') {
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
if (!skip.has(k) && v != null && v !== '') {
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
}
}
}
return out.slice(0, 4)
})
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-destructive">{error}</span>
</div>
{:else if entity}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug}{entity.type}{entity.health || 'no health data'}">
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex items-center gap-1.5">
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
</div>
{#if entity.type}
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
{/if}
{#if entity.state}
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
{/if}
{#if entity.health && entity.health !== 'unknown'}
<span class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
{entity.health}
</span>
{/if}
{#if entity.last_check}
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
{/if}
</div>
{#if keyAttrs.length > 0}
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
{#each keyAttrs as [k, v]}
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
<div class="max-h-56 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
{#each cols as col}
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
{#each cols as col}
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
<XIcon class="size-3 shrink-0" aria-hidden="true" />
<span>No entities found</span>
</div>
{/if}

View File

@@ -1,108 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { getExecution } from '$lib/api'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ClockIcon from '@lucide/svelte/icons/clock'
let { tool }: { tool: ToolCallResult } = $props()
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const executions = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = Array.isArray(tool.result) ? tool.result : (tool.result as any)?.data
return Array.isArray(data) ? data as any[] : null
})
const statusColors: Record<string, string> = {
completed: 'var(--success)',
failed: 'var(--destructive)',
cancelled: 'var(--destructive)',
denied: 'var(--destructive)',
revoked: 'var(--destructive)',
running: 'var(--warning)',
approved: 'var(--warning)',
pending_approval: 'var(--muted-foreground)',
queued: 'var(--muted-foreground)',
}
function statusLabel(s: string): string {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function fmtDuration(ms?: number): string {
if (!ms) return ''
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
function truncate(s: string, n: number): string {
if (!s) return ''
return s.length > n ? s.slice(0, n) + '…' : s
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="animate-pulse text-muted-foreground">checking…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="text-destructive">{error}</span>
</div>
{:else if executions && executions.length > 0}
<div class="rounded-lg border bg-card text-xs">
<div class="flex flex-col divide-y">
{#each executions as exec (exec.execution_id ?? exec.id)}
{@const status = exec.status ?? 'unknown'}
{@const color = statusColors[status] ?? 'var(--muted-foreground)'}
{@const isRunning = status === 'running' || status === 'approved'}
<div class="flex items-center gap-2 px-3 py-2">
{#if status === 'completed'}
<CheckIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
{:else if status === 'failed' || status === 'cancelled' || status === 'denied' || status === 'revoked'}
<XIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
{:else if isRunning}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin" style="color: {color}" aria-hidden="true" />
{:else}
<ClockIcon class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
{/if}
<span class="font-mono font-medium">{truncate(exec.execution_id ?? exec.id ?? '', 12)}</span>
<span class="text-muted-foreground">{statusLabel(status)}</span>
{#if exec.action}
<span class="text-muted-foreground">· {truncate(exec.action, 40)}</span>
{/if}
{#if exec.duration_ms}
<span class="text-muted-foreground">· {fmtDuration(exec.duration_ms)}</span>
{/if}
<span class="ml-auto inline-block rounded px-1.5 py-0.5 font-medium text-[10px]" style="background: {color}22; color: {color}">
{statusLabel(status)}
</span>
</div>
{#if exec.result || exec.error}
<div class="max-h-32 overflow-y-auto bg-background/60 px-3 py-1.5">
{#if exec.error}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-destructive">{exec.error}</pre>
{:else if exec.result}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{typeof exec.result === 'string' ? exec.result : JSON.stringify(exec.result, null, 2)}</pre>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="text-muted-foreground">no active executions</span>
</div>
{/if}

View File

@@ -1,93 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const summary = $derived.by(() => {
if (!rows) return null
const health: Record<string, number> = {}
const types: Record<string, number> = {}
for (const r of rows) {
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
}
return { health, types, total: rows.length }
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
unknown: 'var(--muted-foreground)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-destructive">{error}</span>
</div>
{:else if summary && summary.total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{summary.total} entities</span>
</div>
<div class="px-3 py-2 space-y-2">
<!-- Health -->
<div>
<div class="text-muted-foreground mb-1">Health</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
{#if summary.health[h]}
<div class="flex items-center gap-1.5 font-mono">
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
<span class="text-muted-foreground">{h}</span>
<span class="tabular-nums">{summary.health[h]}</span>
</div>
{/if}
{/each}
</div>
</div>
<!-- Types -->
{#if Object.keys(summary.types).length > 0}
<div>
<div class="text-muted-foreground mb-1">By type</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
<div class="flex items-center gap-1.5 font-mono">
<span class="text-muted-foreground">{type}</span>
<span class="tabular-nums">{count}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -1,82 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data : null
})
const counts = $derived.by(() => {
if (!rows) return null
const m: Record<string, number> = {}
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
return m
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
const bars: { label: string; count: number; color: string }[] = [
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
]
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-destructive">{error}</span>
</div>
{:else if counts && total > 0}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
<div class="flex items-center gap-2 mb-1.5">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} entities</span>
</div>
<div class="flex h-5 w-full overflow-hidden rounded">
{#each bars as bar}
{#if bar.count > 0}
<div
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
title="{bar.label}: {bar.count}"
>
{bar.count}
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
{#each bars as bar}
{#if bar.count > 0}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
{bar.label} {bar.count}
</span>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import FileTextIcon from '@lucide/svelte/icons/file-text'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="animate-pulse text-muted-foreground">searching…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each rows as row}
<div class="px-3 py-2">
<div class="flex items-start gap-2">
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
<div class="min-w-0">
<div class="font-mono font-medium truncate">{row.title}</div>
{#if row.snippet || row.headline}
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
{row.snippet || row.headline}
</div>
{/if}
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
{#if row.source}
<span>{row.source}</span>
{/if}
{#if row.slug}
<span class="font-mono opacity-60">{row.slug}</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-muted-foreground">no results</span>
</div>
{/if}

View File

@@ -1,85 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-3 py-1 text-left font-medium">Name</th>
<th class="px-3 py-1 text-left font-medium">ID</th>
<th class="px-3 py-1 text-left font-medium">IP</th>
<th class="px-3 py-1 text-left font-medium">Health</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
<td class="px-3 py-1">
{#if row.health}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
{row.health}
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-2 py-1 text-left font-medium">Time</th>
<th class="px-2 py-1 text-left font-medium">Metric</th>
<th class="px-2 py-1 text-right font-medium">Avg</th>
<th class="px-2 py-1 text-right font-medium">Min</th>
<th class="px-2 py-1 text-right font-medium">Max</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -1,9 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import BlastRadius from './BlastRadius.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
component: BlastRadius,
})
}

View File

@@ -1,12 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ChangeLog from './ChangeLog.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'get_change_history' ||
t.name === 'get_agent_activity' ||
t.result?.__renderer === 'change_log',
component: ChangeLog,
})
}

View File

@@ -1,11 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityCard from './EntityCard.svelte'
const TOOLS = ['get_entity', 'whoami', 'explain']
export function init() {
registerToolRenderer({
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
component: EntityCard,
})
}

View File

@@ -1,9 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityTable from './EntityTable.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
component: EntityTable,
})
}

View File

@@ -1,9 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ExecutionStatus from './ExecutionStatus.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_execution_status',
component: ExecutionStatus,
})
}

View File

@@ -1,9 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import FleetSnapshot from './FleetSnapshot.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
component: FleetSnapshot,
})
}

View File

@@ -1,9 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import HealthSummary from './HealthSummary.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
component: HealthSummary,
})
}

View File

@@ -1,21 +0,0 @@
import { init as initEntityCard } from './entity-card'
import { init as initHealthSummary } from './health-summary'
import { init as initLXCList } from './lxc-list'
import { init as initEntityTable } from './entity-table'
import { init as initKnowledgeResults } from './knowledge-results'
import { init as initBlastRadius } from './blast-radius'
import { init as initChangeLog } from './change-log'
import { init as initFleetSnapshot } from './fleet-snapshot'
import { init as initMetricChart } from './metric-chart'
import { init as initExecutionStatus } from './execution-status'
initEntityCard()
initHealthSummary()
initLXCList()
initEntityTable()
initKnowledgeResults()
initBlastRadius()
initChangeLog()
initFleetSnapshot()
initMetricChart()
initExecutionStatus()

View File

@@ -1,12 +0,0 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import KnowledgeResults from './KnowledgeResults.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'search_knowledge' ||
t.name === 'get_entity_knowledge' ||
t.result?.__renderer === 'knowledge_results',
component: KnowledgeResults,
})
}

Some files were not shown because too many files have changed in this diff Show More