Compare commits
26 Commits
55781984c7
...
claude/fro
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f4d645d06 | |||
| aee458ce83 | |||
| 58a11ca872 | |||
| aed068de12 | |||
| 8657ac5669 | |||
| e28e0e9ea3 | |||
| 6051fb4845 | |||
| 544afae77f | |||
| bd44626532 | |||
| b0cdf64bbf | |||
| d6b3d3c88b | |||
| 8615f2268f | |||
| 258b14dcbc | |||
| 646373a676 | |||
| 69964abe2e | |||
| 6806fac5fd | |||
| 7dc1c1ae39 | |||
| 8709e01dcb | |||
| c96c795126 | |||
| 463bdacf5c | |||
| fb39a48bef | |||
| a2410cf9c2 | |||
| d2950dd09d | |||
| 0a3654b08f | |||
| c3973e7ac9 | |||
| e3a0326c78 |
@@ -13,13 +13,13 @@ service itself.
|
||||
|
||||
## 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:
|
||||
- Fleet topology (`inventory.yaml`)
|
||||
- Agent behaviour and conventions
|
||||
- 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
|
||||
authoritative at runtime.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Oikos — the operating model
|
||||
|
||||
Oikos (Greek: *household*) is the agent operating system layered on this
|
||||
repo. It is not new infrastructure: `inventory.yaml` is the kernel data
|
||||
structure, the `homelab` CLI and MCP server are the syscall surface, and
|
||||
repo. It is not new infrastructure: `seeds/inventory.yaml` is the kernel data
|
||||
structure, the Oikos REST API and MCP server are the syscall surface, and
|
||||
this page defines the rules everything above them follows.
|
||||
|
||||
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
|
||||
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
|
||||
in the Go binary.
|
||||
|
||||
**Phase 1 — Ontology + DB (DONE):**
|
||||
- `migrations/` (001–011): TimescaleDB hypertables, entity_status, CAGGs,
|
||||
retention policies, knowledge entities with FTS. Forward-only, idempotent.
|
||||
- `migrations/` (001–020, forward-only): TimescaleDB hypertables, entity_status, CAGGs,
|
||||
retention policies, knowledge entities with FTS. Idempotent.
|
||||
- `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap +
|
||||
DR export. Knowledge seed contains 36 documents, 6 investigations, and 12
|
||||
runbooks.
|
||||
DR export. Knowledge seed contents are not hardcoded here — count them
|
||||
from the seed or query the DB.
|
||||
- `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship
|
||||
validation.
|
||||
- Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`,
|
||||
@@ -139,8 +139,8 @@ in the Go binary.
|
||||
|
||||
**Phase 4 — Agent / Nomos (DONE):**
|
||||
- Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode
|
||||
(:8092). Structured queries + natural-language routing to 15 MCP tools.
|
||||
Agent activity logging on every tool call. No SSH keys.
|
||||
(:8092). Structured queries + natural-language routing to the MCP tool
|
||||
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 Docker service in `docker-compose.yml` (profile: full).
|
||||
- Go packages: `cmd/nomos/`, `compose/nomos/`.
|
||||
|
||||
@@ -94,7 +94,12 @@ current phase status). To add a new capability:
|
||||
## SQL conventions
|
||||
|
||||
- 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`
|
||||
- CTEs for graph traversals (blast radius, dependency chains)
|
||||
- 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`
|
||||
- Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,48 +1,93 @@
|
||||
# Knowledge domain — schema
|
||||
|
||||
The knowledge domain is the durable, authoritative current-state documentation of the homelab: one
|
||||
page per node and per cross-cutting system, synthesized from live state and evidence. It answers
|
||||
"what exists and how does it work right now."
|
||||
The knowledge domain is the durable, authoritative current-state documentation of the homelab:
|
||||
narrative for every node and cross-cutting system, synthesized from live state and evidence. It
|
||||
answers "what exists and how does it work right now."
|
||||
|
||||
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)
|
||||
rules.
|
||||
|
||||
## The narrative / substrate split
|
||||
## Source of truth — the database
|
||||
|
||||
The knowledge wiki is **narrative**. It sits alongside a **machine-readable substrate** that it
|
||||
describes but never contains. The split is load-bearing: several programs read the substrate at
|
||||
fixed paths, so the wiki reorganization never moves it.
|
||||
Per ADR 0003, the Postgres database is the single source of truth for all structured data **and**
|
||||
narrative knowledge. The narrative/substrate split of the Python era is gone: the DB holds both the
|
||||
structured graph (entities, relationships, status, metrics) and the narrative layer (documents,
|
||||
investigations, runbooks) in the `knowledge_entities` table.
|
||||
|
||||
| Layer | Location | Consumed by |
|
||||
|-------|----------|-------------|
|
||||
| Substrate — source of truth | `inventory.yaml` (root) | MCP server, `homelab` CLI, `oikos/` scheduler/drift/relations/gen-topology |
|
||||
| Substrate — generated host records | `inventory.yaml` (root) | Go `internal/mcp/` server, `bin/homelab`; the single source of truth |
|
||||
| Substrate — kernel + context cards | `oikos/` (code, `oikos/cards/`, `oikos/state.json`) | MCP `explain`, scheduler |
|
||||
| Narrative — synthesized wiki | `archive/knowledge/{hosts,containers,vms,infrastructure}/` | humans, agents via MCP `get_page` / `search_docs` |
|
||||
| Evidence — immutable sources | `knowledge/sources/` (references + investigations) | synthesis into wiki pages |
|
||||
| Concern | Where it lives | How it gets there |
|
||||
|---------|----------------|-------------------|
|
||||
| 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 |
|
||||
| Seed manifest (bootstrap + DR) | `seeds/knowledge.yaml` | Hand-edited or regenerated; ingested idempotently (content-hashed via `seed_versions`) |
|
||||
| Structured graph — hosts, services, entity types, relationships | `entities`, `relationships`, `entity_types` tables | Seeded from `seeds/{ontology,inventory}.yaml`; mutated via API/MCP |
|
||||
| Archived narrative wiki (read-only history) | `archive/knowledge/` | Frozen 2026-07-07 when the DB became source of truth |
|
||||
|
||||
## Wiki pages
|
||||
### Seed ingest
|
||||
|
||||
- **Node pages** (`archive/knowledge/containers/<id>-<name>.md`, `.../vms/<id>-<name>.md`,
|
||||
`.../hosts/<name>.md`) follow the container/host template in
|
||||
[page-templates.md](../../shared/page-templates.md): opening definition, `## At a glance`,
|
||||
`## Role`, service/port map, storage, auto-deploy, `## Related`, `## Changelog`.
|
||||
- **Cross-cutting pages** (`archive/knowledge/infrastructure/<topic>.md`) follow the cross-cutting
|
||||
template: `## Why`, `## Components`, `## How to apply`, `## Gotchas`, `## Related`, `## Changelog`.
|
||||
- Each `inventory.yaml` host entry carries a `doc_page:` field pointing at its narrative page.
|
||||
Changing where a page lives means updating that field (read by `bin/homelab`).
|
||||
`seeds/knowledge.yaml` has three top-level lists — `documents`, `investigations`, `runbooks` — each
|
||||
entry carrying `slug`, `title`, `content` (markdown), and tags. `internal/knowledge/seed.go`
|
||||
ingests each entry by:
|
||||
|
||||
1. `getOrCreateEntity` — ensures the slug exists in `entities` (type `document` / `investigation` /
|
||||
`runbook`).
|
||||
2. `upsertKnowledgeEntity` — writes the markdown body into `knowledge_entities`, keyed by
|
||||
`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 per-page **`## Changelog`** records infrastructure changes and is machine-parsed
|
||||
(`get_changelog`, the Oikos ledger). Keep the `### YYYY-MM-DD — title` shape.
|
||||
- **`knowledge/log.md`** is append-only and records *documentation-maintenance* operations only
|
||||
(restructures, source ingests, lint sweeps): `## [YYYY-MM-DD] <op> | <summary>`. It never
|
||||
duplicates the Oikos change ledger (`oikos/ledger.py`).
|
||||
- The per-document **`## Changelog`** records infrastructure changes to that node. Keep the
|
||||
`### YYYY-MM-DD — title` shape so the parsed `changelog` field stays structured.
|
||||
- **`archive/knowledge/log.md`** is the append-only record of *documentation-maintenance*
|
||||
operations on the legacy wiki (restructures, source ingests, lint sweeps):
|
||||
`## [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
|
||||
|
||||
A change to a node updates every page that references it in the same session — the node page, the
|
||||
section `README.md` table, the root `README.md`, the Caddy/DNS/ingress pages, the host page, and
|
||||
`inventory.yaml`. See [page-templates.md](../../shared/page-templates.md#same-session-update-rule).
|
||||
A change to a node updates the DB in the same session — the entity's attributes, the relationships
|
||||
that reference it, and any document whose `at_glance` or changelog should reflect the new state. See
|
||||
[page-templates.md](../../shared/page-templates.md#same-session-update-rule) for the legacy wiki
|
||||
equivalent (now scoped to `archive/knowledge/` history).
|
||||
|
||||
@@ -6,9 +6,10 @@ follows [writing-style](../../shared/writing-style.md); runbooks and plans use t
|
||||
exception.
|
||||
|
||||
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
|
||||
reference (command cheatsheet, enrollment, Hermes agent) lives in
|
||||
[`.agents/operations/`](../../operations/); investigations are sources under
|
||||
`knowledge/sources/investigations/`; plans stay in the repo-root `plans/` folder (below).
|
||||
reference (command cheatsheet, enrollment, Nomos agent) lives in
|
||||
[`.agents/operations/`](../../operations/); investigations are `investigation` entities in the DB
|
||||
(historically `archive/knowledge/sources/investigations/`); plans stay in the repo-root `plans/`
|
||||
folder (below).
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -44,12 +45,14 @@ transition: "<from> -> <to>" # only for lifecycle runbooks
|
||||
|
||||
## Investigations
|
||||
|
||||
Incident records live in `knowledge/sources/investigations/YYYY-MM-DD-slug.md` and are **evidence sources** — written
|
||||
once at incident time, then linked from the changelogs of the nodes they implicate. Sections:
|
||||
`## Summary`, `## Timeline`, `## Root cause`, `## Mitigations applied`, `## Open questions`. Resolved
|
||||
incidents move to `knowledge/sources/investigations/archive/`.
|
||||
Incident records are `investigation` entities in the DB, linked to the entities they implicate via
|
||||
`about` edges. They are **evidence sources** — written once at incident time, then back-linked from
|
||||
the changelogs of the nodes they implicate. Sections: `## Summary`, `## Timeline`, `## Root cause`,
|
||||
`## 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
|
||||
|
||||
`plans/log.md` and `knowledge/log.md` are append-only records of documentation operations on
|
||||
those areas (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the Oikos change ledger.
|
||||
`plans/log.md` is the append-only record of documentation operations on plans
|
||||
(`## [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.
|
||||
|
||||
@@ -49,7 +49,7 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
|
||||
- `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/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
|
||||
|
||||
@@ -67,7 +67,7 @@ the dpkg-interrupted recovery procedure specifically.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
| 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
|
||||
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
|
||||
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
# LLM Wiki — the documentation contract
|
||||
|
||||
How the narrative documentation in this repo is organized. The pattern is borrowed from the
|
||||
`sources / wiki / index / log` model: a durable synthesized layer (`archive/knowledge/`) built on top
|
||||
of immutable evidence (`knowledge/sources/`, incident records), with pure-listing indexes and an
|
||||
append-only operations log.
|
||||
How documentation in this repo is organized. The pattern is the `sources / wiki / index / log`
|
||||
model: a durable synthesized layer built on top of immutable evidence, with pure-listing indexes and
|
||||
an append-only operations log.
|
||||
|
||||
This contract governs the **narrative layer only**. The machine-readable substrate — `inventory.yaml`,
|
||||
`secrets/`, `scripts/`, `bin/` — is not part of the wiki and never
|
||||
moves under it. See [the knowledge schema](../domains/knowledge/schema.md) for the split.
|
||||
This contract governs the **narrative layer only**. The machine-readable source of truth — the
|
||||
Postgres database, bootstrapped from `seeds/` — is not part of the wiki and never moves under it.
|
||||
See [the knowledge schema](../domains/knowledge/schema.md) for the split, and ADR 0003 for the
|
||||
DB-native model.
|
||||
|
||||
## Layers
|
||||
|
||||
- **Sources** are immutable raw material: incident records (`knowledge/sources/investigations/`), external reference
|
||||
docs (`knowledge/sources/references/`), and the live system itself (`pct config`, `docker inspect`).
|
||||
Read them; do not rewrite them into other sources.
|
||||
- **Wiki** (`archive/knowledge/`) is the synthesized, authoritative current-state layer: one page per
|
||||
node (`containers/`, `vms/`, host narratives) and per cross-cutting system (`infrastructure/`). A
|
||||
reader understands the topic from the wiki page without reading the sources.
|
||||
- **Source of truth** is the Postgres database. Structured data (entities, relationships, status,
|
||||
metrics) and narrative knowledge (documents, investigations, runbooks) both live there, in the
|
||||
`entities` / `relationships` / `knowledge_entities` tables. It is bootstrapped at deploy time from
|
||||
`seeds/{ontology,inventory,policy,knowledge}.yaml` (idempotent, content-hashed via
|
||||
`seed_versions`) and mutated at runtime via the API/MCP. `oikos export` regenerates
|
||||
`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
|
||||
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.
|
||||
- **Log** (`log.md`) is append-only, recording *doc-maintenance operations* (restructures, source
|
||||
ingests, lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`.
|
||||
- **Log** is append-only, recording *doc-maintenance operations* (restructures, source ingests,
|
||||
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
|
||||
|
||||
- **`## Changelog`** on each node/topic page records *infrastructure* changes to that node. It is
|
||||
machine-parsed (`get_changelog`, the Oikos ledger) — keep the `### YYYY-MM-DD — title` shape.
|
||||
- **`log.md`** per area records *documentation* operations only. It never duplicates the Oikos
|
||||
change ledger (`oikos/ledger.py`), which stays authoritative for infra changes with
|
||||
who/what/risk/approval/verification.
|
||||
- **`## Changelog`** on each node/topic document records *infrastructure* changes to that node. It
|
||||
is stored as a structured field on the `document` entity — keep the `### YYYY-MM-DD — title`
|
||||
shape so it parses cleanly.
|
||||
- **Doc-maintenance logs** record *documentation* operations only. They never duplicate the
|
||||
infrastructure changelog, which stays authoritative for infra changes with
|
||||
who/what/risk/approval/verification (now the DB audit trail, formerly `oikos/ledger.py`).
|
||||
|
||||
## Rules
|
||||
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
@@ -9,12 +9,12 @@ in [writing-style.md](writing-style.md); the layer model (sources / wiki / index
|
||||
**Foundational / entry-point files:** ALL-CAPS
|
||||
|
||||
- **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).
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
@@ -118,7 +118,7 @@ What it looks like after.
|
||||
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
|
||||
# YYYY-MM-DD — <title>
|
||||
@@ -152,16 +152,18 @@ Changelog entries to write, index status to update.
|
||||
## Same-session update rule
|
||||
|
||||
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
|
||||
session.** A change that touches a container page must also update:
|
||||
mount, deploy a new service — **update the DB and every relevant doc page in
|
||||
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 Caddy page site list (if the change affects `*.hubris.network` routing)
|
||||
- The DNS / ingress infrastructure pages (if the change affects routing)
|
||||
- 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
|
||||
is a bug. If you're doing a multi-step migration, document the intermediate
|
||||
|
||||
@@ -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>`.
|
||||
2. **Opening definition.** First paragraph, 1–3 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).
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Sourcing and cross-references
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"name": "web",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
||||
"port": 5173
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,3 +70,30 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: docker build (verify image builds; no push)
|
||||
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
41
.golangci.yml
Normal 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
|
||||
42
AGENTS.md
42
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# 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
|
||||
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.
|
||||
|
||||
- **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:
|
||||
|
||||
/opt/homelab-context/inventory.yaml
|
||||
/opt/homelab/inventory.yaml
|
||||
|
||||
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
|
||||
@@ -39,12 +39,13 @@ the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
|
||||
|
||||
## 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;
|
||||
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
|
||||
documents, 6 investigations, 12 runbooks. Ingested into the DB on deploy.
|
||||
- `/opt/homelab-context/.agents/operations/commands.md` — the operator's cheatsheet
|
||||
- `/opt/homelab/seeds/knowledge.yaml` — full narrative knowledge
|
||||
(documents, investigations, runbooks). Counts are not hardcoded here; count
|
||||
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.
|
||||
|
||||
## 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
|
||||
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:
|
||||
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,
|
||||
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
|
||||
need approval regardless of what you declare. This is the ONLY
|
||||
mutation tool — `request_execution` was retired 2026-07-14.
|
||||
`run` — the general execution primitive. Run any shell
|
||||
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the
|
||||
route for those specific actions; policy-gated the same way `run` is.
|
||||
mutation tool — `request_execution` was retired 2026-07-14; the
|
||||
former enum actions (restart, systemctl, pct_exec, apt_upgrade,
|
||||
pct_create) are all expressed as `run(target, command)` now.
|
||||
get_execution_status(execution_id) — poll progress
|
||||
|
||||
**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": ["..."]}
|
||||
```
|
||||
|
||||
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive
|
||||
per the DB-as-source-of-truth plan.
|
||||
The DB is the truth. The old wiki files are archived at `archive/knowledge/`
|
||||
(historical reference only — use MCP `search_knowledge` for live queries).
|
||||
|
||||
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
|
||||
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
|
||||
|
||||
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
|
||||
operator interface — it has 33 MCP tools for observe/orient/decide/act
|
||||
(§3).
|
||||
operator interface — it routes to the MCP tool list in §3 for
|
||||
observe/orient/decide/act.
|
||||
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
|
||||
`run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
|
||||
immediately; `config_mutation` and `destructive` actions are queued for
|
||||
@@ -178,7 +179,7 @@ per the DB-as-source-of-truth plan.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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:
|
||||
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'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
|
||||
live in the DB as of 2026-07-12.
|
||||
`ssh-script` check kind depends on these actually being there (count is
|
||||
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,
|
||||
commit and push. All enrolled clients pick it up within 5 minutes.
|
||||
|
||||
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
|
||||
wait for the 5-min timer. (This mechanism — and the server-side
|
||||
`tools_changed` detection behind it — only correctly recognized
|
||||
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
|
||||
wait for the 5-min timer. (The server-side `tools_changed` detection only
|
||||
correctly recognizes `setup-*.sh` scripts — earlier it silently matched
|
||||
nothing, so nothing auto-ran on any client via this path.)
|
||||
|
||||
## 9. Versioning
|
||||
|
||||
@@ -51,7 +51,7 @@ The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hu
|
||||
- Checks Gitea releases every 6 hours
|
||||
- System tray → **Check for Updates** triggers an immediate check
|
||||
- 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
|
||||
|
||||
|
||||
21
Makefile
21
Makefile
@@ -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
|
||||
|
||||
BINARY := oikos
|
||||
BINARY := bin/oikos
|
||||
GO ?= go
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||
|
||||
webhook:
|
||||
@@ -19,9 +20,18 @@ test-db:
|
||||
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/
|
||||
|
||||
lint:
|
||||
lint: vet golangci govulncheck
|
||||
|
||||
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:
|
||||
$(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
|
||||
mkdir -p 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)
|
||||
@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"; \
|
||||
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
||||
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 ;; \
|
||||
Linux) \
|
||||
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:
|
||||
rm -f $(BINARY)
|
||||
rm -rf bin
|
||||
rm -rf cmd/desktop/build
|
||||
rm -rf cmd/desktop/frontend/dist
|
||||
$(GO) clean -testcache
|
||||
|
||||
13
README.md
13
README.md
@@ -49,7 +49,7 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
||||
|
||||
| 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 notifier` | — | Approval tokens, Matrix alerts |
|
||||
| `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
|
||||
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
|
||||
output). A native desktop wrapper is planned — see
|
||||
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md).
|
||||
output). A native desktop wrapper exists at `cmd/desktop/` — see
|
||||
[plans/done/2026-07-12-wails-desktop-app.md](plans/done/2026-07-12-wails-desktop-app.md).
|
||||
|
||||
## Repo layout
|
||||
|
||||
@@ -121,9 +121,10 @@ output). A native desktop wrapper is planned — see
|
||||
cmd/oikos/ Go entry point — single binary
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
|
||||
notifier, policy, secrets, db, config, ontology, domain,
|
||||
knowledge)
|
||||
cmd/desktop/ Wails desktop wrapper around the SPA
|
||||
internal/ Go packages (actuator, checkdefaults, config, db, domain,
|
||||
httpapi, knowledge, learning, mcp, notifier, observability,
|
||||
ontology, policy, safego, scheduler, secrets)
|
||||
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
||||
api/openapi.yaml API contract (OpenAPI 3.1)
|
||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||
|
||||
@@ -36,13 +36,17 @@ var iconPNG []byte
|
||||
const (
|
||||
keyringService = "com.hubris.oikos-desktop"
|
||||
keyringUser = "oikos"
|
||||
version = "0.1.0"
|
||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
||||
pollInterval = 30 * time.Second
|
||||
updateInterval = 6 * time.Hour
|
||||
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 {
|
||||
ApiUrl string `json:"apiUrl"`
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
@@ -168,10 +168,10 @@ type toolDef struct {
|
||||
}
|
||||
|
||||
type agentEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
}
|
||||
|
||||
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||
@@ -349,6 +349,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
messages = append(messages, openai.SystemMessage(systemInject))
|
||||
}
|
||||
|
||||
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
|
||||
// track failing `run` calls within this turn so an identical command that
|
||||
// keeps failing is refused after maxRunRetries attempts. Without this,
|
||||
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling
|
||||
// up a zombie process on the target (knfsd was holding a kernel lock).
|
||||
// The tracker is per-turn — a fresh turn after the operator responds can
|
||||
// retry once more, so this doesn't permanently block recovery.
|
||||
retries := newRunRetryTracker()
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(a.model),
|
||||
@@ -467,6 +476,33 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
sawCompleteTask = true
|
||||
}
|
||||
|
||||
// Retry cap: if this `run` call has already failed
|
||||
// maxRunRetries times this turn with the same (target,
|
||||
// command), refuse to dispatch it again. Return a synthetic
|
||||
// tool result directing the agent to investigate *why* the
|
||||
// command hangs instead of retrying. See retrycap.go and
|
||||
// plans/2026-07-18-session-review-three-sessions.md P0.1.
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
if n := retries.failures(key); n >= maxRunRetries {
|
||||
directive := runRetryDirective(t, c, n)
|
||||
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||
tc.Function.Arguments, directive, 0, false, correlationID)
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
||||
SessionID: sessionID,
|
||||
Iteration: i + 1,
|
||||
})
|
||||
messages = append(messages, openai.ToolMessage(directive, tc.ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_use",
|
||||
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||
@@ -508,6 +544,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if callErr != nil {
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||
|
||||
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||
// count toward the cap too. A command that keeps timing
|
||||
// out at the gateway is exactly the pattern we want to
|
||||
// break — see session 1e9c7691's 20+ identical
|
||||
// `chown` timeouts.
|
||||
if tc.Function.Name == "run" {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
|
||||
@@ -551,6 +603,25 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
||||
|
||||
// Retry cap: record failures of `run` calls so the cap above
|
||||
// can refuse a repeated identical failure. A "failure" here
|
||||
// means the dispatch errored OR the MCP result text matches
|
||||
// the "run on <target>: ERROR …" signature — both indicate
|
||||
// the command actually ran and failed, not just that it
|
||||
// queued for approval (pending approvals are not failures).
|
||||
// Pass the RAW result text (not JSON-encoded) so the helper's
|
||||
// HasPrefix check sees "run on …" not "\"run on …\"".
|
||||
if isRunFailure(tc.Function.Name, runResultText(result), callErr) {
|
||||
t, _ := args["target"].(string)
|
||||
c, _ := args["command"].(string)
|
||||
key := runFailureKey(t, c)
|
||||
n := retries.recordFailure(key)
|
||||
if n >= maxRunRetries {
|
||||
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||
"target", t, "failures", n, "session", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// ask_operator pauses the task: the agent has posed a decision only
|
||||
// the operator can make. End the turn here so it doesn't barrel past
|
||||
// its own question — the answer (panel or chat reply) resumes it.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -448,6 +449,21 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
w.WriteHeader(204)
|
||||
|
||||
case http.MethodGet:
|
||||
// getMessages alone can't distinguish "session exists but has no
|
||||
// messages yet" from "session id doesn't exist at all" — it's a
|
||||
// plain WHERE session_id=$1 query that returns zero rows either
|
||||
// way. A frontend window opened for a deleted/invalid session
|
||||
// (persisted layout, a stale link) needs to tell those apart, so
|
||||
// check existence explicitly and 404 rather than silently
|
||||
// returning an empty transcript that looks like a fresh task.
|
||||
if _, err := st.getSession(r.Context(), id); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
http.Error(w, "session not found", 404)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
messages, err := st.getMessages(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
|
||||
170
cmd/nomos/retrycap.go
Normal file
170
cmd/nomos/retrycap.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxRunRetries is the per-turn cap on identical failing `run` tool calls.
|
||||
// After this many failures with the same (target, command) key, the agent
|
||||
// loop refuses to dispatch the call again and instead surfaces a directive
|
||||
// to investigate *why* (ps/strace/lsof) or escalate to the operator.
|
||||
//
|
||||
// Background: session 1e9c7691 (2026-07-18) retried the same
|
||||
// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct
|
||||
// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test`
|
||||
// sanity checks. Each retry piled up another zombie process on the target
|
||||
// (knfsd was holding a kernel lock on the exported directory). The agent
|
||||
// only investigated *why* after the operator explicitly asked
|
||||
// "the command just keeps running?" — see
|
||||
// plans/2026-07-18-session-review-three-sessions.md P0.1.
|
||||
const maxRunRetries = 3
|
||||
|
||||
// runRetryTracker deduplicates failing `run` calls within a single chat
|
||||
// turn (chatWith invocation). It is NOT persisted across turns — the cap
|
||||
// is per-turn, so a fresh turn after the operator responds can retry once
|
||||
// more. The intent is to break a tight retry loop within one turn, not to
|
||||
// permanently block the agent from ever attempting the operation again.
|
||||
//
|
||||
// Threading: the agent loop is single-goroutine per turn, but the tracker
|
||||
// is guarded by a mutex so future callers (e.g. concurrent tool dispatch)
|
||||
// stay safe. The mutex is uncontended on the current hot path.
|
||||
type runRetryTracker struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
}
|
||||
|
||||
func newRunRetryTracker() *runRetryTracker {
|
||||
return &runRetryTracker{counts: make(map[string]int)}
|
||||
}
|
||||
|
||||
// runFailureKey is the dedup key for "this is the same command against the
|
||||
// same target." Whitespace is collapsed so trivial reformatting
|
||||
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
|
||||
// purpose field is intentionally NOT part of the key: the agent often
|
||||
// rephrases purpose between retries while issuing the same command.
|
||||
func runFailureKey(target, command string) string {
|
||||
collapsed := strings.Join(strings.Fields(command), " ")
|
||||
target = strings.TrimSpace(target)
|
||||
h := sha256.Sum256([]byte(target + "\x00" + collapsed))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// recordFailure increments the failure count for the given key and returns
|
||||
// the new count. The caller should check `count > maxRunRetries` BEFORE
|
||||
// dispatching to decide whether to skip the call.
|
||||
func (r *runRetryTracker) recordFailure(key string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.counts[key]++
|
||||
return r.counts[key]
|
||||
}
|
||||
|
||||
// failures returns the current failure count for a key (0 if unseen).
|
||||
func (r *runRetryTracker) failures(key string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.counts[key]
|
||||
}
|
||||
|
||||
// isRunFailure reports whether a `run` tool call's outcome should count
|
||||
// as a failure for retry-cap purposes. A call counts as failed when:
|
||||
// - the dispatch itself errored (callErr != nil), OR
|
||||
// - the result text starts with "run on <target>: ERROR" — the
|
||||
// shape classifyAndGate/sshExec produce when SSH or the command fails.
|
||||
//
|
||||
// Approvals queued ("requires approval") do NOT count as failures: they
|
||||
// are pending operator action, not a command execution failure. A read
|
||||
// of the existing code paths (classifyAndGate in internal/mcp/server.go)
|
||||
// confirms the "ERROR" prefix is the stable failure signature for `run`.
|
||||
//
|
||||
// The resultText parameter is the MCP tool's RAW text result (not JSON-
|
||||
// re-encoded): when classifyAndGate returns a textResult like
|
||||
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a
|
||||
// plain Go string (see mcpClient.callTool). The caller should pass that
|
||||
// raw string, not json.Marshal's output (which would quote-wrap it).
|
||||
func isRunFailure(toolName string, resultText string, callErr error) bool {
|
||||
if callErr != nil {
|
||||
return true
|
||||
}
|
||||
if toolName != "run" {
|
||||
return false
|
||||
}
|
||||
// "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..."
|
||||
// Both shapes start with "run on ".
|
||||
if !strings.HasPrefix(resultText, "run on ") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(resultText, ": ERROR")
|
||||
}
|
||||
|
||||
// runResultText extracts the raw text from a `run` tool's result value as
|
||||
// returned by mcpClient.callTool — typically a Go string, but may also be
|
||||
// a []string (multi-content result) or other JSON-decoded shape. Returns
|
||||
// "" for shapes we don't recognize. Used by the retry-cap path so
|
||||
// isRunFailure receives the un-quoted text form (see its doc comment).
|
||||
func runResultText(result any) string {
|
||||
switch v := result.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
case []any:
|
||||
var b strings.Builder
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runRetryDirective is the synthetic tool result returned to the model
|
||||
// when the retry cap is hit, in place of dispatching the call again. It
|
||||
// directs the agent to investigate *why* the command keeps failing before
|
||||
// retrying, or to surface the blocker to the operator.
|
||||
func runRetryDirective(target, command string, failures int) string {
|
||||
return "Refused: this `run` against " + target + " has failed " +
|
||||
itoa(failures) + " times this turn — retry cap hit. The command:\n " +
|
||||
command + "\nis almost certainly blocked by something on the target " +
|
||||
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
|
||||
"session, …) — NOT a transient gateway issue. Do NOT retry with " +
|
||||
"different routing or quoting. Instead, BEFORE calling `run` again, " +
|
||||
"investigate *why* the command hangs: e.g. `ps aux | grep <cmd>`, " +
|
||||
"`lsof <path>`, `strace -f -p <pid>` or `strace -f <cmd>`, " +
|
||||
"`mount | grep <path>`, `dmesg | tail`. If you find a structural " +
|
||||
"blocker (e.g. a kernel lock on an exported NFS directory → " +
|
||||
"unexport → mutate → re-export), say so to the operator and fix it " +
|
||||
"with a different command. If you genuinely cannot diagnose, " +
|
||||
"surface the blocker to the operator with what you've tried — do " +
|
||||
"not just retry the same command."
|
||||
}
|
||||
|
||||
// itoa is a tiny strconv.Itoa to keep this file dependency-free.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
129
cmd/nomos/retrycap_test.go
Normal file
129
cmd/nomos/retrycap_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
|
||||
cases := []struct{ a, b string }{
|
||||
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local",
|
||||
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||
{"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local",
|
||||
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local ",
|
||||
" chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||
}
|
||||
for i, c := range cases {
|
||||
ka := runFailureKey("host:strong", c.a)
|
||||
kb := runFailureKey("host:strong", c.b)
|
||||
if ka != kb {
|
||||
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailureKey_DiffersByTarget(t *testing.T) {
|
||||
a := runFailureKey("host:strong", "echo hi")
|
||||
b := runFailureKey("host:hubris", "echo hi")
|
||||
if a == b {
|
||||
t.Error("keys should differ when target differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailureKey_DiffersByCommand(t *testing.T) {
|
||||
a := runFailureKey("host:strong", "echo hi")
|
||||
b := runFailureKey("host:strong", "echo bye")
|
||||
if a == b {
|
||||
t.Error("keys should differ when command differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
|
||||
r := newRunRetryTracker()
|
||||
key := runFailureKey("host:strong", "chown :10000 /mnt/media_local")
|
||||
for i := 1; i <= maxRunRetries; i++ {
|
||||
if got := r.recordFailure(key); got != i {
|
||||
t.Errorf("recordFailure #%d = %d, want %d", i, got, i)
|
||||
}
|
||||
}
|
||||
// At the cap, failures() should report maxRunRetries, and the next
|
||||
// identical call should be refused by the agent loop (failures() >=
|
||||
// maxRunRetries).
|
||||
if got := r.failures(key); got != maxRunRetries {
|
||||
t.Errorf("failures = %d, want %d", got, maxRunRetries)
|
||||
}
|
||||
if r.failures(key) < maxRunRetries {
|
||||
t.Errorf("cap should be enforced at maxRunRetries=%d", maxRunRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
|
||||
// Different keys don't interfere.
|
||||
r := newRunRetryTracker()
|
||||
k1 := runFailureKey("host:strong", "echo a")
|
||||
k2 := runFailureKey("host:strong", "echo b")
|
||||
r.recordFailure(k1)
|
||||
r.recordFailure(k1)
|
||||
if got := r.failures(k2); got != 0 {
|
||||
t.Errorf("k2 failures = %d, want 0 (keys are isolated)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRunFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
tool string
|
||||
result string
|
||||
callErr error
|
||||
want bool
|
||||
}{
|
||||
{"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true},
|
||||
{"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true},
|
||||
{"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false},
|
||||
{"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false},
|
||||
{"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false},
|
||||
{"non-run tool", "get_entity", "lxc list result", nil, false},
|
||||
{"callErr set (dispatch failure)", "run", "", errFake{}, true},
|
||||
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
|
||||
}
|
||||
for i, c := range cases {
|
||||
got := isRunFailure(c.tool, c.result, c.callErr)
|
||||
if got != c.want {
|
||||
t.Errorf("case %d (%s): isRunFailure = %v, want %v", i, c.desc, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type errFake struct{}
|
||||
|
||||
func (errFake) Error() string { return "fake dispatch error" }
|
||||
|
||||
func TestRunRetryDirective_Content(t *testing.T) {
|
||||
d := runRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
|
||||
for _, want := range []string{
|
||||
"Refused:",
|
||||
"host:strong",
|
||||
"3 times",
|
||||
"retry cap hit",
|
||||
"Do NOT retry",
|
||||
"strace",
|
||||
"ps aux",
|
||||
"lsof",
|
||||
"surface the blocker",
|
||||
} {
|
||||
if !strings.Contains(d, want) {
|
||||
t.Errorf("directive missing %q; got:\n%s", want, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItoa(t *testing.T) {
|
||||
cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42",
|
||||
100: "100", -1: "-1", -42: "-42"}
|
||||
for in, want := range cases {
|
||||
if got := itoa(in); got != want {
|
||||
t.Errorf("itoa(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,10 +492,34 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
|
||||
// happens — not in reopenSession — because set_goal is the explicit signal
|
||||
// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it
|
||||
// won't destroy the plan the operator just approved.
|
||||
//
|
||||
// P1.4 (2026-07-18): when a non-empty prior goal is being overwritten by a
|
||||
// different goal, emit a `task.superseded` event carrying the prior goal.
|
||||
// This gives the UI/audit trail a clear signal that the operator pivoted —
|
||||
// without it, the prior goal just silently disappears from
|
||||
// agent_sessions.goal and there's no record the session ever had a
|
||||
// different starting intent. See plans/2026-07-18-session-review-three-
|
||||
// sessions.md P1.4 (session 55927f0a had two set_goal calls with the first
|
||||
// implicitly abandoned when the operator said "lets just keep ludo-library
|
||||
// then").
|
||||
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
// Capture the prior goal BEFORE the UPDATE overwrites it. If non-empty
|
||||
// and different from the new goal, emit task.superseded so the audit
|
||||
// trail records the pivot — the row's goal column won't.
|
||||
var priorGoal string
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||
sessionID).Scan(&priorGoal)
|
||||
if priorGoal != "" && priorGoal != goal {
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.superseded",
|
||||
s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID,
|
||||
map[string]any{"prior_goal": priorGoal, "new_goal": goal})
|
||||
slog.Info("nomos: task goal superseded by a new set_goal",
|
||||
"session", sessionID, "prior_goal", priorGoal, "new_goal", goal)
|
||||
}
|
||||
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
||||
// The rows are kept for the generation counter + audit trail; proposePlan
|
||||
// excludes `replaced` from its in-flight check, so the next propose_plan
|
||||
@@ -868,7 +892,7 @@ type staleGoalSession struct {
|
||||
}
|
||||
|
||||
// staleGoalSessions finds sessions that framed themselves as a real task
|
||||
// (goal != '', so the inline safety net in agent.go intentionally left them
|
||||
// (goal != ”, so the inline safety net in agent.go intentionally left them
|
||||
// alone) but have sat non-terminal past idleThreshold. completion_nudges
|
||||
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
|
||||
// see processIdleSweep in continue.go.
|
||||
|
||||
@@ -311,3 +311,69 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetGoal_SupersessionEvent is the store-level proof for P1.4 from
|
||||
// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called
|
||||
// and a non-empty prior goal already exists with a DIFFERENT value, a
|
||||
// task.superseded event must be emitted (so the audit trail records the
|
||||
// pivot — the row's goal column will be overwritten, losing the prior intent
|
||||
// without this event). When the goal is identical OR no prior goal exists,
|
||||
// no supersession event is emitted.
|
||||
//
|
||||
// Background: session 55927f0a had two set_goal calls; the first was
|
||||
// implicitly abandoned when the operator said "lets just keep ludo-library
|
||||
// then." Without the event, the prior goal silently disappeared.
|
||||
func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "goal pivot test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
// First set_goal — no prior, no supersession event expected.
|
||||
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
|
||||
t.Fatalf("setGoal #1: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
|
||||
t.Errorf("after first set_goal: %d task.superseded events, want 0", n)
|
||||
}
|
||||
|
||||
// Second set_goal with a DIFFERENT goal — supersession event expected.
|
||||
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
t.Fatalf("setGoal #2: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||
t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n)
|
||||
}
|
||||
|
||||
// Third set_goal with the SAME goal as the second — no new supersession
|
||||
// event (idempotent: same goal is a no-op, not a pivot).
|
||||
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
t.Fatalf("setGoal #3: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||
t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n)
|
||||
}
|
||||
|
||||
// The session's current goal must be the latest one set.
|
||||
got, err := s.getSession(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" {
|
||||
t.Errorf("session goal = %q, want the second (latest) goal", got.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
// countEvents counts observability events of the given type correlated to
|
||||
// the given session. Used by TestSetGoal_SupersededEvent to assert the
|
||||
// task.superseded audit-trail signal was emitted.
|
||||
func countEvents(ctx context.Context, s *store, sessionID, eventType string) int {
|
||||
var n int
|
||||
s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,
|
||||
sessionID, eventType).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
17
docs/index.md
Normal file
17
docs/index.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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 |
|
||||
| [mascot/](mascot/README.md) | MBSE subsystem model for the desktop mascot (planned) |
|
||||
| [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.
|
||||
335
docs/mascot/README.md
Normal file
335
docs/mascot/README.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# Oikos — Desktop Mascot Subsystem Model
|
||||
|
||||
> Companion to [the platform Model](../mbse/README.md) and
|
||||
> [the Framework](../mbse/framework.md). This document is a **subsystem
|
||||
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
|
||||
> Viewpoints, Markdown + Mermaid Notation) rather than restating it, scoped
|
||||
> to a single not-yet-built subsystem of the `web` component: the desktop
|
||||
> mascot ("Cluck"), a pixel-art chicken that lives on the desktop shell.
|
||||
> Where the platform-wide Views in [../mbse/README.md](../mbse/README.md)
|
||||
> and the component View for `web/src` in
|
||||
> [../mbse/components.md](../mbse/components.md#5-web-control-room) speak
|
||||
> at the level of "the SPA," this document goes one layer deeper into one
|
||||
> feature of it — the same relationship [components.md](../mbse/components.md)
|
||||
> has to [README.md](../mbse/README.md), applied recursively.
|
||||
|
||||
**Status of this Model:** the subsystem it describes does not exist in
|
||||
code yet. Every View below is marked **Planned**, not **Verified** —
|
||||
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
|
||||
which this document borrows. The corresponding implementation plan is
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
||||
|
||||
## Views in this model
|
||||
|
||||
| # | View | Concern it addresses |
|
||||
|---|---|---|
|
||||
| [1](#1-mission--system-context) | Mission & System Context | Why a mascot, and what is it never allowed to do? |
|
||||
| [2](#2-requirements) | Requirements | What must it do, traced from the original request? |
|
||||
| [3](#3-structural-view) | Structural View | What modules make it up, and which are the extension points? |
|
||||
| [4](#4-behavioral-view) | Behavioral View | How does it move, live, and react, moment to moment? |
|
||||
| [5](#5-interfaces-view) | Interfaces View | What does it read from the rest of the system, and how does it persist itself? |
|
||||
| [6](#6-extension-guide) | Extension Guide | How does a future engineer add an animation, behavior, menu action, or reaction? |
|
||||
| [7](#7-verification-view) | Verification View | How will we know it works, once built? |
|
||||
|
||||
## 1. Mission & System Context
|
||||
|
||||
**Stakeholders:** the operator (delight, ambient awareness of system
|
||||
state without opening a window); future engineers extending the mascot's
|
||||
behaviors/reactions/menu.
|
||||
|
||||
**Mission:** give the desktop shell a persistent, living presence that
|
||||
makes background system activity legible at a glance — a chat streaming,
|
||||
a knowledge-graph write, a critical signal — without requiring a window to
|
||||
be open, while doubling as a lightweight tamagotchi for its own sake
|
||||
(delight is a legitimate requirement here, not a side effect).
|
||||
|
||||
**Boundary — what the mascot is, and is not:**
|
||||
|
||||
- It is a **purely client-side, read-only observer**. It subscribes to
|
||||
existing `web` stores (chat, activity, events, dashboard summary) the
|
||||
same way any other UI component does.
|
||||
- It **never calls a mutating API endpoint** and is not a new actuation
|
||||
path — it has no relationship to the `run` gate, `Execution`, or
|
||||
`Approval` entities described in [the platform Ontology](../mbse/ontology.md).
|
||||
Its only "mutation" is its own tamagotchi state, stored client-side.
|
||||
- It is scoped entirely inside the `web` component
|
||||
([../mbse/components.md §5](../mbse/components.md#5-web-control-room));
|
||||
it introduces no new backend surface, no new MCP tool, no new REST route.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph SURFACE["Desktop shell surface (Desktop.svelte)"]
|
||||
ICONS["Icon layer\nz-0"]
|
||||
LAUNCH["Task launcher\nz-10"]
|
||||
WIN["WindowLayer\nz-40"]
|
||||
MASCOT["MascotLayer\nz-45\n(this subsystem)"]
|
||||
MENU["Desktop context menu\nz-50"]
|
||||
end
|
||||
|
||||
MASCOT -->|subscribes, read-only| EVENTS["stores/events.ts\nliveEvents (SSE)"]
|
||||
MASCOT -->|subscribes, read-only| CHAT["stores/chat.ts\nstreaming"]
|
||||
MASCOT -->|subscribes, read-only| ACTIVITY["stores/activity.ts\nactivityLog"]
|
||||
MASCOT -->|subscribes, read-only| CONTEXT["stores/context.ts\nsummary"]
|
||||
MASCOT -->|reads/writes| LS["localStorage\noikos-mascot"]
|
||||
|
||||
style MASCOT fill:#fff3e0,stroke:#e65100
|
||||
```
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
Traced from the original feature request. All **Planned**.
|
||||
|
||||
| ID | Statement | Source | Status |
|
||||
|---|---|---|---|
|
||||
| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned |
|
||||
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned |
|
||||
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned |
|
||||
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
|
||||
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned |
|
||||
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned |
|
||||
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned |
|
||||
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned |
|
||||
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned |
|
||||
| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned |
|
||||
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned |
|
||||
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned |
|
||||
|
||||
## 3. Structural View
|
||||
|
||||
**Stakeholders:** an engineer implementing or extending the mascot.
|
||||
**Why this View earns its place:** MASC-9 (extensibility) is only real if
|
||||
the module boundaries actually separate data (registries) from engine
|
||||
code; this View is the check that they do.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class types_ts {
|
||||
<<module>>
|
||||
PixelGrid
|
||||
AnimName
|
||||
MascotStage
|
||||
BehaviorId
|
||||
Stimulus
|
||||
RadialAction
|
||||
}
|
||||
class palette_ts {
|
||||
<<module, registry>>
|
||||
PALETTE: char to CSS color
|
||||
}
|
||||
class sprites_ts {
|
||||
<<module, registry>>
|
||||
SPRITES: Stage to AnimName to AnimDef
|
||||
resolveAnim(stage, name)
|
||||
}
|
||||
class render_ts {
|
||||
<<module, stateless>>
|
||||
drawFrame(ctx, grid, palette, flip)
|
||||
}
|
||||
class state_svelte_ts {
|
||||
<<module, runes>>
|
||||
MascotModel state
|
||||
grantXp() feed() pet() setName()
|
||||
tickLifecycle() advanceStageIfReady()
|
||||
persist (debounced, oikos-mascot)
|
||||
}
|
||||
class behavior_ts {
|
||||
<<module, registry>>
|
||||
BEHAVIORS: BehaviorId to BehaviorDef
|
||||
stepMascot(rt, model, now, dt)
|
||||
}
|
||||
class stimuli_ts {
|
||||
<<module, registry>>
|
||||
REACTIONS: id to ReactionDef
|
||||
attachStimuli(emit)
|
||||
}
|
||||
class actions_ts {
|
||||
<<module, registry>>
|
||||
MASCOT_ACTIONS: RadialAction tree
|
||||
registerMascotAction()
|
||||
}
|
||||
class Mascot_svelte {
|
||||
<<component>>
|
||||
canvas render loop 30fps
|
||||
pointer drag/click/contextmenu
|
||||
}
|
||||
class MascotLayer_svelte {
|
||||
<<component>>
|
||||
z-45 absolute overlay
|
||||
hosts Mascot + RadialMenu + bubble
|
||||
}
|
||||
class RadialMenu_svelte {
|
||||
<<component>>
|
||||
z-60 fixed, nested rings
|
||||
}
|
||||
class NameDialog_svelte {
|
||||
<<component>>
|
||||
}
|
||||
|
||||
sprites_ts --> palette_ts : indexes
|
||||
sprites_ts --> types_ts : uses
|
||||
Mascot_svelte --> render_ts : draws frames
|
||||
Mascot_svelte --> sprites_ts : resolves anim
|
||||
Mascot_svelte --> behavior_ts : steps FSM
|
||||
Mascot_svelte --> state_svelte_ts : reads/mutates model
|
||||
MascotLayer_svelte --> Mascot_svelte : hosts
|
||||
MascotLayer_svelte --> RadialMenu_svelte : hosts, on contextmenu
|
||||
MascotLayer_svelte --> stimuli_ts : attaches on mount
|
||||
MascotLayer_svelte --> NameDialog_svelte : hosts, on hatch/rename
|
||||
RadialMenu_svelte --> actions_ts : renders tree
|
||||
stimuli_ts --> behavior_ts : forceBehavior(react)
|
||||
```
|
||||
|
||||
**The four extension registries** (MASC-9's concrete answer — see also
|
||||
[§6 Extension Guide](#6-extension-guide)): `SPRITES` (animations),
|
||||
`BEHAVIORS` (autonomous states), `MASCOT_ACTIONS` (radial menu tree),
|
||||
`REACTIONS` (environment stimuli). Each is plain data; the engine
|
||||
(`behavior.ts`'s `stepMascot`, `Mascot.svelte`'s loop, `RadialMenu.svelte`'s
|
||||
renderer) is generic over whatever the registry currently contains.
|
||||
|
||||
**Mount point:** two lines in
|
||||
[`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) —
|
||||
`<MascotLayer />` rendered inside the surface `<div>` (the `relative
|
||||
min-h-0 flex-1 overflow-hidden` element), after `<WindowLayer />`, so its
|
||||
`absolute inset-0` shares the surface's coordinate space and its ground
|
||||
line is exactly the surface's bottom edge (= the taskbar's top edge).
|
||||
|
||||
## 4. Behavioral View
|
||||
|
||||
**Stakeholders:** an engineer reasoning about "what does the mascot do
|
||||
right now, and why." **Why this View earns its place:** a mascot with an
|
||||
implicit, ad-hoc state machine is unmaintainable the moment a second
|
||||
behavior or reaction is added; this View is the state machine made
|
||||
explicit before any of it is coded.
|
||||
|
||||
### 4.1 Behavior FSM (moment-to-moment autonomy)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg
|
||||
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
|
||||
|
||||
state chick_and_adult_behaviors {
|
||||
[*] --> idle
|
||||
idle --> wander : weighted random pick\non behaviorUntil expiry
|
||||
wander --> idle
|
||||
idle --> peck : weighted random pick
|
||||
peck --> idle
|
||||
idle --> sleep : weighted random pick
|
||||
sleep --> idle
|
||||
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
||||
idle --> dragged : pointerdown + move\npast 5px threshold
|
||||
wander --> dragged : pointerdown + move
|
||||
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
||||
dragged --> falling : pointerup, released mid-air
|
||||
falling --> land : y reaches ground
|
||||
land --> idle
|
||||
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
||||
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
|
||||
}
|
||||
|
||||
chick --> adult : xp reaches ADULT_XP\n(advanceStageIfReady)
|
||||
```
|
||||
|
||||
`dragged` always wins over any autonomous behavior; `sleep` is broken only
|
||||
by a reaction whose `ReactionDef.interruptsSleep` is true (§4.3) or by a
|
||||
drag. Weighted-random idle selection (`weight` field in `BehaviorDef`)
|
||||
picks the next autonomous behavior only when the current one's `next()`
|
||||
returns null past `behaviorUntil` — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the concrete weights.
|
||||
|
||||
### 4.2 Tamagotchi lifecycle (long-lived state)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg : first load,\ndefaultModel()
|
||||
egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown
|
||||
chick --> adult : xp >= ADULT_XP (200)
|
||||
adult --> [*]
|
||||
```
|
||||
|
||||
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
|
||||
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
|
||||
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame).
|
||||
|
||||
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SSE as stores/events.ts (SSE)
|
||||
participant Stim as stimuli.ts attachStimuli
|
||||
participant FSM as behavior.ts
|
||||
participant Mascot as Mascot.svelte (canvas)
|
||||
|
||||
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
||||
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
|
||||
Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs})
|
||||
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
|
||||
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
|
||||
Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame
|
||||
Note over FSM: after durationMs,\nnext() returns to idle
|
||||
```
|
||||
|
||||
## 5. Interfaces View
|
||||
|
||||
**Stakeholders:** an engineer wiring a new store into the mascot's
|
||||
awareness, or auditing what it depends on.
|
||||
|
||||
| Interface | Direction | Shape | Notes |
|
||||
|---|---|---|---|
|
||||
| [`stores/events.ts`](../../web/src/lib/stores/events.ts) `liveEvents` | consumed | `Writable<OikosEvent[]>`, newest-first, ref-counted via `subscribeEvents()` | `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info'\|'warning'\|'critical'` |
|
||||
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
|
||||
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
|
||||
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
|
||||
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
|
||||
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
|
||||
|
||||
No interface in this table is a write path to the Oikos API — consistent
|
||||
with §1's boundary statement (MASC-11).
|
||||
|
||||
## 6. Extension Guide
|
||||
|
||||
**Stakeholders:** a future engineer adding one new animation, behavior,
|
||||
menu action, or reaction — this is the Viewpoint 4's "why" made concrete
|
||||
as a recipe rather than prose (mirrors [../mbse/framework.md §7](../mbse/framework.md)'s
|
||||
Process Set treatment).
|
||||
|
||||
| To add a... | Touch only | Nothing else changes because |
|
||||
|---|---|---|
|
||||
| **Animation** | Add the name to the `AnimName` union in `types.ts`; add frames to `SPRITES[stage]` in `sprites.ts` | `resolveAnim()` and the renderer are generic over the registry |
|
||||
| **Behavior** | Add the id to `BehaviorId`; add one `BehaviorDef` entry to `BEHAVIORS` in `behavior.ts` | `stepMascot()` and the weighted-random idle selector consume `BEHAVIORS` generically |
|
||||
| **Radial menu action** | Add a `RadialAction` node to `MASCOT_ACTIONS` in `actions.ts` (or call `registerMascotAction()`), optionally nested under `children` | `RadialMenu.svelte` renders whatever tree it's given, including nesting depth |
|
||||
| **Environment reaction** | Add a `ReactionDef` to `REACTIONS` in `stimuli.ts`; wire one `store subscription -> predicate -> emit(reaction)` block inside `attachStimuli()` | priority/cooldown/interrupt dispatch logic in `attachStimuli()` is generic over `REACTIONS` |
|
||||
|
||||
## 7. Verification View
|
||||
|
||||
**Stakeholders:** whoever implements this subsystem and needs to know
|
||||
when it's actually done, not just compiled.
|
||||
|
||||
Manual browser checklist (no automated test harness planned for v1 — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the same list in implementation-order context):
|
||||
|
||||
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
|
||||
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
|
||||
fire from mere walking, only from discrete transitions).
|
||||
- Dragging the egg up and releasing triggers a flutter-fall with no
|
||||
tunneling below the taskbar; dragging past the surface edges clamps.
|
||||
- Forcing hatch (debug menu action) transitions to chick, opens the name
|
||||
dialog, and the name persists across reload.
|
||||
- Chick wanders and flips sprite at surface edges, pecks, sleeps
|
||||
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
||||
- Right-clicking the chicken opens the radial menu centered on it, without
|
||||
triggering the desktop's own right-click menu; a nested submenu (Feed)
|
||||
opens correctly; Escape pops one level then closes; an outside click
|
||||
closes it; the menu stays fully visible when the chicken is near a
|
||||
screen edge or corner.
|
||||
- With one or more windows open (including a maximized one), the chicken
|
||||
visibly walks above them without breaking window drag/resize/close.
|
||||
- Starting a chat and observing it stream triggers the `thinking` reaction
|
||||
for the duration; a simulated knowledge-graph write triggers `eureka`
|
||||
once per cooldown window; a simulated critical signal triggers `alarmed`
|
||||
even while the chicken is asleep.
|
||||
- Resizing the browser viewport re-grounds and re-clamps the chicken.
|
||||
- Both the Terracotta and Carbon themes keep the pixel-art palette legible.
|
||||
- `npm run build` passes with no new errors.
|
||||
18
docs/operations/README.md
Normal file
18
docs/operations/README.md
Normal 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.
|
||||
109
internal/actuator/circuit_breaker_test.go
Normal file
109
internal/actuator/circuit_breaker_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
151
internal/actuator/ssh_test.go
Normal file
151
internal/actuator/ssh_test.go
Normal 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)
|
||||
@@ -19,7 +19,7 @@ type CheckDef struct {
|
||||
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 != "" {
|
||||
return ip
|
||||
}
|
||||
@@ -57,8 +57,8 @@ func resolveSSHPort(attrs map[string]any) int {
|
||||
return 22
|
||||
}
|
||||
|
||||
func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
||||
host := ResolveHost(attrs)
|
||||
func forEntityType(entityType string, attrs map[string]any) []CheckDef {
|
||||
host := resolveHost(attrs)
|
||||
user := resolveSSHUser(attrs)
|
||||
port := resolveSSHPort(attrs)
|
||||
|
||||
@@ -122,7 +122,7 @@ func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShortSlug(slug string) string {
|
||||
func shortSlug(slug string) string {
|
||||
const n = 8
|
||||
if len(slug) > n {
|
||||
return slug[len(slug)-n:]
|
||||
@@ -130,7 +130,7 @@ func ShortSlug(slug string) string {
|
||||
return slug
|
||||
}
|
||||
|
||||
func DefaultInterval(kind string) int32 {
|
||||
func defaultInterval(kind string) int32 {
|
||||
switch kind {
|
||||
case "ping":
|
||||
return 30
|
||||
@@ -156,7 +156,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
|
||||
attrs = map[string]any{}
|
||||
}
|
||||
|
||||
defs := ForEntityType(entityType, attrs)
|
||||
defs := forEntityType(entityType, attrs)
|
||||
if len(defs) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
|
||||
if err != nil {
|
||||
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,
|
||||
`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)
|
||||
VALUES ($1, $2, $3, $4, $5, 30, true)
|
||||
ON CONFLICT (entity_id) DO NOTHING`,
|
||||
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind))
|
||||
checkID, entityID, def.Kind, configJSON, defaultInterval(def.Kind))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ WHERE e.type IN (SELECT name FROM tt)
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: ListEntitiesCapped :many
|
||||
SELECT e.* FROM entities e ORDER BY e.slug LIMIT $1;
|
||||
|
||||
-- name: InsertEntity :one
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
|
||||
@@ -14,11 +14,6 @@ WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
|
||||
ORDER BY se.slug
|
||||
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
|
||||
SELECT response_code, response_body, request_hash FROM idempotency_keys
|
||||
WHERE actor = $1 AND key = $2;
|
||||
@@ -89,9 +84,6 @@ DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateSignalState :exec
|
||||
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1;
|
||||
|
||||
-- name: GetOpenSignalsForAutoAct :many
|
||||
-- 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,
|
||||
@@ -106,12 +98,6 @@ WHERE c.route = 'auto-act'
|
||||
ORDER BY s.last_seen_at ASC
|
||||
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
|
||||
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,
|
||||
@@ -153,11 +139,6 @@ WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
|
||||
ORDER BY te.slug
|
||||
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
|
||||
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
||||
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'))
|
||||
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
|
||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
||||
|
||||
|
||||
@@ -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[]))
|
||||
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
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||
|
||||
@@ -177,43 +177,6 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
||||
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
|
||||
UPDATE entities SET
|
||||
name = COALESCE($1, name),
|
||||
|
||||
@@ -35,11 +35,17 @@ type AgentMessage struct {
|
||||
}
|
||||
|
||||
type AgentSession struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
Goal string
|
||||
Status string
|
||||
Outcome *string
|
||||
Summary string
|
||||
EntityID *uuid.UUID
|
||||
CompletionNudges int32
|
||||
}
|
||||
|
||||
type Approval struct {
|
||||
@@ -83,6 +89,7 @@ type AuditLog struct {
|
||||
Detail []byte
|
||||
SourceIp *string
|
||||
CorrelationID *string
|
||||
SessionID *uuid.UUID
|
||||
}
|
||||
|
||||
type AutonomySetting struct {
|
||||
@@ -289,6 +296,13 @@ type MetricSample struct {
|
||||
Tags []byte
|
||||
}
|
||||
|
||||
type NomosPlanExecution struct {
|
||||
ExecutionID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
ContinuedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Pattern struct {
|
||||
EntityID uuid.UUID
|
||||
AppliesType string
|
||||
@@ -351,6 +365,32 @@ type SeedVersion struct {
|
||||
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 {
|
||||
EntityID uuid.UUID
|
||||
Kind string
|
||||
|
||||
@@ -416,48 +416,6 @@ func (q *Queries) InsertCheckDef(ctx context.Context, arg InsertCheckDefParams)
|
||||
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
|
||||
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
@@ -530,35 +488,6 @@ func (q *Queries) InsertExecution(ctx context.Context, arg InsertExecutionParams
|
||||
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
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
@@ -581,41 +510,6 @@ func (q *Queries) InsertMetricSample(ctx context.Context, arg InsertMetricSample
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||
FROM events
|
||||
@@ -1462,20 +1318,6 @@ func (q *Queries) UpdatePatternStatus(ctx context.Context, arg UpdatePatternStat
|
||||
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
|
||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2
|
||||
`
|
||||
|
||||
@@ -139,27 +139,3 @@ func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
139
internal/domain/domain_test.go
Normal file
139
internal/domain/domain_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
676
internal/httpapi/actuator.go
Normal file
676
internal/httpapi/actuator.go
Normal 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
|
||||
}
|
||||
90
internal/httpapi/agent_activity.go
Normal file
90
internal/httpapi/agent_activity.go
Normal 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
|
||||
}
|
||||
@@ -263,6 +263,20 @@ func TestAPIEndToEnd(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Regression: rel_type is an optional array param (*[]string); when
|
||||
// omitted entirely (not an empty list), passing the nil pointer straight
|
||||
// through to pgx as a query arg panics because pgx can't infer the array
|
||||
// element type from a nil *[]string. root+depth alone must still work.
|
||||
t.Run("graph without rel_type", func(t *testing.T) {
|
||||
rec, body := get(t, h, "/api/v1/graph?root=host:hubris&depth=1", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if len(body["nodes"].([]any)) < 2 {
|
||||
t.Errorf("graph too small: %d nodes", len(body["nodes"].([]any)))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ontology", func(t *testing.T) {
|
||||
rec, body := get(t, h, "/api/v1/ontology", nil)
|
||||
if rec.Code != 200 {
|
||||
|
||||
160
internal/httpapi/approval_rules.go
Normal file
160
internal/httpapi/approval_rules.go
Normal 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
|
||||
}
|
||||
259
internal/httpapi/approvals.go
Normal file
259
internal/httpapi/approvals.go
Normal 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
|
||||
}
|
||||
102
internal/httpapi/autonomy.go
Normal file
102
internal/httpapi/autonomy.go
Normal 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
304
internal/httpapi/checks.go
Normal 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
|
||||
}
|
||||
85
internal/httpapi/classifications.go
Normal file
85
internal/httpapi/classifications.go
Normal 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
|
||||
}
|
||||
160
internal/httpapi/entity_types.go
Normal file
160
internal/httpapi/entity_types.go
Normal 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
|
||||
}
|
||||
267
internal/httpapi/executions.go
Normal file
267
internal/httpapi/executions.go
Normal 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
|
||||
}
|
||||
32
internal/httpapi/helpers.go
Normal file
32
internal/httpapi/helpers.go
Normal 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[:])
|
||||
}
|
||||
@@ -60,19 +60,17 @@ func clampLimit(l *int) int {
|
||||
// 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) {
|
||||
if id, err := uuid.Parse(idOrSlug); err == nil {
|
||||
var found uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE id = $1", id).Scan(&found)
|
||||
if err == pgx.ErrNoRows {
|
||||
entity, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
||||
}
|
||||
return found, err
|
||||
return entity.ID, nil
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
|
||||
if err == pgx.ErrNoRows {
|
||||
entity, err := sqlcgen.New(s.pool).GetEntityBySlug(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
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
|
||||
@@ -188,46 +186,37 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati
|
||||
if req.Params.Direction != nil {
|
||||
dir = string(*req.Params.Direction)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
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 (($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)
|
||||
relType := req.Params.RelType
|
||||
rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
|
||||
Direction: dir,
|
||||
ID: id,
|
||||
RelType: relType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := scanRelationships(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
items := []gen.Relationship{}
|
||||
for _, r := range rows {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
@@ -293,6 +282,15 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
var err error
|
||||
truncated := false
|
||||
|
||||
// pgx can't infer the array element type from a nil *[]string (the
|
||||
// param is absent from the request, not an empty list), so dereference
|
||||
// to a plain []string first — nil there still encodes as SQL NULL, but
|
||||
// pgx has a concrete type to work with.
|
||||
var relTypes []string
|
||||
if req.Params.RelType != nil {
|
||||
relTypes = *req.Params.RelType
|
||||
}
|
||||
|
||||
if req.Params.Root != nil && *req.Params.Root != "" {
|
||||
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
|
||||
if rerr != nil {
|
||||
@@ -302,7 +300,7 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
SELECT `+entityCols+`
|
||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
||||
ORDER BY e.slug`, rootID, depth, relTypes)
|
||||
} else {
|
||||
// Whole-graph view: pick the most-connected entities first so the
|
||||
// graph shows actual topology, not just whatever sorts first
|
||||
@@ -336,21 +334,31 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
for i, n := range nodes {
|
||||
ids[i] = uuid.UUID(n.Id)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
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)
|
||||
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
|
||||
Ids: ids,
|
||||
RelTypes: relTypes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges, err := scanRelationships(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
edges := []gen.Relationship{}
|
||||
for _, r := range edgeRows {
|
||||
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}
|
||||
@@ -421,78 +429,70 @@ func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObje
|
||||
Lifecycles: []gen.LifecycleDef{},
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT name, parent_type, is_abstract, domain, layer, description,
|
||||
lifecycle_id, attribute_schema, schema_version, status
|
||||
FROM entity_types ORDER BY name`)
|
||||
q := sqlcgen.New(s.pool)
|
||||
|
||||
etRows, err := q.ListEntityTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var et gen.EntityType
|
||||
var schemaVersion int
|
||||
var schemaJSON []byte
|
||||
if err := rows.Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain,
|
||||
&et.Layer, &et.Description, &et.LifecycleId, &schemaJSON,
|
||||
&schemaVersion, &et.Status); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
for _, et := range etRows {
|
||||
schemaVersion := int(et.SchemaVersion)
|
||||
var schema *map[string]any
|
||||
if len(et.AttributeSchema) > 0 {
|
||||
var s map[string]any
|
||||
if json.Unmarshal(et.AttributeSchema, &s) == nil && s != nil {
|
||||
schema = &s
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
resp.EntityTypes = append(resp.EntityTypes, gen.EntityType{
|
||||
Name: et.Name,
|
||||
ParentType: et.ParentType,
|
||||
IsAbstract: et.IsAbstract,
|
||||
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, `
|
||||
SELECT name, inverse, source_type, target_type, cardinality, description
|
||||
FROM relationship_types ORDER BY name`)
|
||||
rtRows, err := q.ListRelationshipTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var rt gen.RelationshipType
|
||||
if err := rows.Scan(&rt.Name, &rt.Inverse, &rt.SourceType, &rt.TargetType,
|
||||
&rt.Cardinality, &rt.Description); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
resp.RelationshipTypes = append(resp.RelationshipTypes, rt)
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
for _, rt := range rtRows {
|
||||
resp.RelationshipTypes = append(resp.RelationshipTypes, gen.RelationshipType{
|
||||
Name: rt.Name,
|
||||
Inverse: rt.Inverse,
|
||||
SourceType: rt.SourceType,
|
||||
TargetType: rt.TargetType,
|
||||
Cardinality: gen.RelationshipTypeCardinality(rt.Cardinality),
|
||||
Description: rt.Description,
|
||||
})
|
||||
}
|
||||
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT id, states, default_state, terminal_states, transitions
|
||||
FROM lifecycle_defs ORDER BY id`)
|
||||
lcRows, err := q.ListLifecycleDefs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var lc gen.LifecycleDef
|
||||
var terminal []string
|
||||
var transJSON []byte
|
||||
if err := rows.Scan(&lc.Id, &lc.States, &lc.DefaultState, &terminal, &transJSON); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
for _, lc := range lcRows {
|
||||
terminal := lc.TerminalStates
|
||||
var transitions map[string]any
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.ID, err)
|
||||
}
|
||||
lc.TerminalStates = &terminal
|
||||
if err := json.Unmarshal(transJSON, &lc.Transitions); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.Id, err)
|
||||
}
|
||||
resp.Lifecycles = append(resp.Lifecycles, lc)
|
||||
resp.Lifecycles = append(resp.Lifecycles, gen.LifecycleDef{
|
||||
Id: lc.ID,
|
||||
States: lc.States,
|
||||
DefaultState: lc.DefaultState,
|
||||
TerminalStates: &terminal,
|
||||
Transitions: transitions,
|
||||
})
|
||||
}
|
||||
rows.Close()
|
||||
return resp, rows.Err()
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ─── Signals ──────────────────────────────────────────────────────────
|
||||
@@ -1605,10 +1605,9 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
|
||||
}
|
||||
}
|
||||
case "health-check-answering":
|
||||
var health string
|
||||
err := tx.QueryRow(ctx, "SELECT health FROM entity_status WHERE entity_id = $1", entityID).Scan(&health)
|
||||
if err != nil || health == "unknown" || health == "down" {
|
||||
return fmt.Errorf("health check not answering (status: %s)", health)
|
||||
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||
return fmt.Errorf("health check not answering (status: %s)", st.Health)
|
||||
}
|
||||
case "doc-page-complete":
|
||||
var count int
|
||||
|
||||
180
internal/httpapi/metrics.go
Normal file
180
internal/httpapi/metrics.go
Normal 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
|
||||
}
|
||||
123
internal/httpapi/patterns.go
Normal file
123
internal/httpapi/patterns.go
Normal 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
120
internal/httpapi/relationships.go
Normal file
120
internal/httpapi/relationships.go
Normal 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
|
||||
}
|
||||
33
internal/httpapi/risk_classes.go
Normal file
33
internal/httpapi/risk_classes.go
Normal 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
|
||||
}
|
||||
@@ -97,6 +97,25 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
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.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||
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
|
||||
// Knowledge page's "what the system has learned" view. Registered after
|
||||
// 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)
|
||||
|
||||
// Custom (non-OpenAPI) route: full markdown content for a knowledge
|
||||
// entity (document/investigation/runbook) by its own id or slug — the
|
||||
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
|
||||
// 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)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// 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/session/{id}", s.serveSessionDigest)
|
||||
|
||||
// Learning view: capability timeline + success trend, both derived from
|
||||
// executions (real, growing data) rather than the patterns/skills tables,
|
||||
// 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/trend", s.serveLearningTrend)
|
||||
|
||||
|
||||
196
internal/httpapi/skills.go
Normal file
196
internal/httpapi/skills.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
155
internal/knowledge/seed_test.go
Normal file
155
internal/knowledge/seed_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
||||
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
for _, f := range items {
|
||||
switch f.Outcome {
|
||||
case "success":
|
||||
successCount++
|
||||
case "failure", "unexpected":
|
||||
failureCount++
|
||||
case "partial":
|
||||
successCount++ // partial counts as half-success
|
||||
}
|
||||
}
|
||||
successCount, failureCount := countOutcomes(items)
|
||||
total := successCount + failureCount
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Compute Wilson score lower bound
|
||||
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)
|
||||
confidence := computeConfidence(successCount, failureCount)
|
||||
|
||||
// Get or create pattern — first look up existing entity, then upsert.
|
||||
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
||||
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
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{
|
||||
EntityID: pat.EntityID,
|
||||
Status: "validated",
|
||||
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
"confidence", confidence, "samples", total)
|
||||
}
|
||||
|
||||
// Anomaly check: >10 identical outcomes within 1h
|
||||
if total > 10 {
|
||||
if shouldQuarantine(total) {
|
||||
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
||||
EntityID: pat.EntityID,
|
||||
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.
|
||||
// Conservative estimate of success rate for small sample sizes.
|
||||
func wilsonLowerBound(success, total, z float64) float64 {
|
||||
|
||||
211
internal/learning/learning_test.go
Normal file
211
internal/learning/learning_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
|
||||
register := func(tool *mcp.Tool, handler toolHandler) {
|
||||
s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler))
|
||||
for _, t := range allTools(pool, agentID) {
|
||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.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
|
||||
}
|
||||
|
||||
@@ -1210,16 +511,27 @@ func isPrivateHost(host string) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// resolveExecTarget resolves any target slug (host: or lxc:) to the SSH
|
||||
// resolveExecTarget resolves any target slug (host:, lxc:, or vm:) to the SSH
|
||||
// endpoint that will actually run the command, and a wrap function that turns
|
||||
// a plain shell command into whatever must actually be sent over that SSH
|
||||
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC.
|
||||
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC,
|
||||
// `qm guest exec <pve_id> -- ...` for a VM.
|
||||
//
|
||||
// The lxc.attributes.host value is stored WITHOUT a "host:" prefix (e.g.
|
||||
// "strong", not "host:strong") — see pct_create's entity registration. The
|
||||
// pre-existing pct_exec handler queried resolveHost with that bare value
|
||||
// directly, which can never match a "host:*" slug and always fails; this
|
||||
// prefixes it correctly.
|
||||
//
|
||||
// vm: support (2026-07-18): VMs in inventory.yaml carry `pve_id` and a `host`
|
||||
// attribute (or a `hosts` relationship) just like LXCs, but they're reached
|
||||
// via `qm guest exec` instead of `pct exec`. Previously the agent had to
|
||||
// SSH-hop via `host:hubris` to reach a VM (e.g. `ssh root@<vm_ip> '...'`),
|
||||
// which broke on nested shell quoting and forced manual escaping workarounds
|
||||
// — see plans/2026-07-18-session-review-three-sessions.md P1.6. A VM's
|
||||
// `host` attribute is optional: if absent, fall back to looking up the
|
||||
// `hosts` relationship on the VM entity, then to hubris (the documented
|
||||
// default Proxmox host) — same fallback chain as LXCs.
|
||||
func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) {
|
||||
if strings.HasPrefix(targetSlug, "host:") {
|
||||
host, user, err = resolveHost(ctx, pool, targetSlug)
|
||||
@@ -1235,13 +547,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
||||
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" // documented default Proxmox host when unset
|
||||
}
|
||||
if !strings.HasPrefix(hostSlug, "host:") {
|
||||
hostSlug = "host:" + hostSlug
|
||||
}
|
||||
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
|
||||
host, user, err = resolveHost(ctx, pool, hostSlug)
|
||||
id := pveID
|
||||
return host, user, func(cmd string) string {
|
||||
@@ -1249,7 +555,71 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
||||
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)
|
||||
if strings.HasPrefix(targetSlug, "vm:") {
|
||||
// VMs: same host-resolution chain as LXCs (attributes.host →
|
||||
// `hosts` relationship → hubris default), but reached via
|
||||
// `qm guest exec` instead of `pct exec`. Requires the QEMU
|
||||
// guest agent running inside the VM (the standard Proxmox
|
||||
// setup; ZimaOS/HAOS in this fleet already have it).
|
||||
var pveID, hostAttr string
|
||||
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("VM not found or missing pve_id: %s", targetSlug)
|
||||
}
|
||||
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
|
||||
host, user, err = resolveHost(ctx, pool, hostSlug)
|
||||
id := pveID
|
||||
return host, user, func(cmd string) string {
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
|
||||
// `qm guest exec <id> -- /bin/bash -c '...'` returns JSON by
|
||||
// default; pipe through `jq -r .out` if available, else cat.
|
||||
// The base64 round-trip mirrors the LXC path so nested quoting
|
||||
// (the original VM-target pain point — session 55927f0a) is
|
||||
// handled identically to LXC dispatch.
|
||||
return fmt.Sprintf(
|
||||
"qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash' | jq -r '.out // .err // empty' 2>/dev/null || qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash'",
|
||||
id, b64, id, b64)
|
||||
}, err
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug>, lxc:<slug>, or vm:<slug>", targetSlug)
|
||||
}
|
||||
|
||||
// resolveProxmoxHostSlug resolves the Proxmox host slug that owns a given
|
||||
// LXC/VM target. Resolution order:
|
||||
// 1. hostAttr if non-empty (the entity's attributes.host — stored without
|
||||
// "host:" prefix in inventory.yaml and pct_create).
|
||||
// 2. the `hosts` relationship on the entity (e.g. host:hubris → vm:zimaos),
|
||||
// looked up in the relationships table — the canonical graph source.
|
||||
// 3. "hubris" as a documented default Proxmox host fallback.
|
||||
//
|
||||
// Returns a slug with the "host:" prefix attached, ready for resolveHost.
|
||||
// Extracted from the inline LXC path (2026-07-18) so the VM path shares the
|
||||
// same chain — see plans/2026-07-18-session-review-three-sessions.md P1.6.
|
||||
func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, hostAttr string) string {
|
||||
hostSlug := strings.TrimSpace(hostAttr)
|
||||
if hostSlug == "" {
|
||||
// Fall back to the `hosts` relationship — the graph edge from
|
||||
// the Proxmox host to this LXC/VM. This is the canonical source
|
||||
// for "who owns this VM" in inventory.yaml; the `host` attribute
|
||||
// is a denormalized shortcut that not every entity has.
|
||||
var relHostSlug string
|
||||
// hosts relationship: source=host, target=lxc/vm. Look up the
|
||||
// source slug given the target.
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT e.slug FROM relationships r
|
||||
JOIN entities e ON e.id = r.source_id
|
||||
WHERE r.target_id = (SELECT id FROM entities WHERE slug = $1)
|
||||
AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||
LIMIT 1`, entitySlug).Scan(&relHostSlug); err == nil && relHostSlug != "" {
|
||||
hostSlug = relHostSlug
|
||||
}
|
||||
}
|
||||
if hostSlug == "" {
|
||||
hostSlug = "hubris" // documented default Proxmox host when unset
|
||||
}
|
||||
if !strings.HasPrefix(hostSlug, "host:") {
|
||||
hostSlug = "host:" + hostSlug
|
||||
}
|
||||
return hostSlug
|
||||
}
|
||||
|
||||
// classifyAndGate is the shared classify→execute-or-queue path for every
|
||||
@@ -1672,12 +1042,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
|
||||
// failed, orphaning the execution and never alerting the operator. One
|
||||
// execution maps to at most one approval, so the 1:1 identity holds.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
|
||||
kind, payload, status, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
||||
now() + interval '1 hour', now())`,
|
||||
execID, targetID, action, riskClass, string(payload)); err != nil {
|
||||
if err := sqlcgen.New(pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
|
||||
EntityID: execID,
|
||||
SubjectEntityID: &targetID,
|
||||
Action: action,
|
||||
RiskClass: riskClass,
|
||||
Kind: "execution",
|
||||
Payload: payload,
|
||||
TokenHash: nil,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
slog.Error("createApproval: insert approval", "error", err, "execution", execID)
|
||||
return
|
||||
}
|
||||
@@ -1690,3 +1064,82 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
|
||||
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
||||
}
|
||||
|
||||
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
||||
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
||||
// P1.5). For each target slug, it runs a single read-only shell command
|
||||
// producing mount/df/ls/stat output for the given path, and returns the
|
||||
// results as a map keyed by target slug.
|
||||
//
|
||||
// Why this exists: sessions 1e9c7691 and 55927f0a each spent ~15 `run`
|
||||
// calls gathering identical facts (`mount | grep`, `df`, `ls -la`, `stat`)
|
||||
// across hosts and LXCs to trace where a path lives, who mounts it, and
|
||||
// what permissions it has. One call here replaces that fan-out. All
|
||||
// commands are read-only — the tool bypasses classifyAndGate and runs
|
||||
// directly via sshExec against resolveExecTarget's host/wrap. Failures
|
||||
// (unresolvable target, SSH error) are reported per-target in the result
|
||||
// map, not as a single tool-level error, so one bad target doesn't lose
|
||||
// the others.
|
||||
//
|
||||
// The per-target command is intentionally compact: one combined shell
|
||||
// invocation that prints mount source/dest, df, ls -la of the path's
|
||||
// parent + the path itself, and stat. Output is truncated to 4KB per
|
||||
// target to keep the total result reasonable for an 8-target call.
|
||||
func inspectPathAcrossTargets(ctx context.Context, pool *db.Pool, path string, targets []string) map[string]any {
|
||||
results := make(map[string]any, len(targets))
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wg.Add(len(targets))
|
||||
|
||||
for _, tgt := range targets {
|
||||
go func(target string) {
|
||||
defer wg.Done()
|
||||
entry := inspectOneTarget(ctx, pool, path, target)
|
||||
mu.Lock()
|
||||
results[target] = entry
|
||||
mu.Unlock()
|
||||
}(tgt)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// inspectOneTarget runs the read-only inspection for one target. Returns a
|
||||
// map with keys: "ok" (bool), "output" (string, on success), "error"
|
||||
// (string, on failure). Kept small so the JSON shape is stable across the
|
||||
// parallel-call path.
|
||||
func inspectOneTarget(ctx context.Context, pool *db.Pool, path, target string) map[string]any {
|
||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, target)
|
||||
if rerr != nil {
|
||||
return map[string]any{"ok": false, "error": fmt.Sprintf("resolve target: %v", rerr)}
|
||||
}
|
||||
// One shell invocation, four sections, each guarded by `2>&1 || true`
|
||||
// so a missing path doesn't kill the rest. Stat with -c gives a
|
||||
// stable machine-readable line for ownership/perms; ls -la gives the
|
||||
// human-readable listing of the path and its parent (so we can see
|
||||
// both "what's in here" and "how the parent is laid out" — useful for
|
||||
// NFS-root-vs-subdir permission mismatches, the exact issue in
|
||||
// session 1e9c7691).
|
||||
cmd := fmt.Sprintf(
|
||||
`echo "=== mount ==="; mount 2>/dev/null | grep -- "%[1]s" || echo "(not a mount point)";
|
||||
echo "=== df ==="; df -h "%[1]s" 2>&1 || true;
|
||||
echo "=== stat ==="; stat -c '%%a %%U:%%G (size=%%s, type=%%F)' "%[1]s" 2>&1 || true;
|
||||
echo "=== ls -la path ==="; ls -la "%[1]s" 2>&1 | head -40 || true;
|
||||
echo "=== ls -la parent ==="; ls -la "$(dirname "%[1]s")" 2>&1 | head -20 || true`,
|
||||
path)
|
||||
out, xerr := sshExec(ctx, host, user, wrap(cmd))
|
||||
if xerr != nil {
|
||||
return map[string]any{"ok": false, "error": fmt.Sprintf("ssh: %v: %s", xerr, out)}
|
||||
}
|
||||
// Truncate per-target output to keep an 8-target call's total under
|
||||
// ~32KB. 4KB per target is enough for the head -40/head -20 listings
|
||||
// above; if a directory is enormous, the truncation keeps the result
|
||||
// usable without flooding the model's context.
|
||||
const maxPerTarget = 4096
|
||||
if len(out) > maxPerTarget {
|
||||
out = out[:maxPerTarget] + fmt.Sprintf("\n...truncated (%d bytes total)", len(out))
|
||||
}
|
||||
return map[string]any{"ok": true, "output": out}
|
||||
}
|
||||
|
||||
762
internal/mcp/tools.go
Normal file
762
internal/mcp/tools.go
Normal file
@@ -0,0 +1,762 @@
|
||||
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, LXC, or VM. 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), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
|
||||
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
|
||||
}},
|
||||
|
||||
// inspect_path is the bulk fact-gathering tool from
|
||||
// plans/2026-07-18-session-review-three-sessions.md P1.5.
|
||||
// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
|
||||
// gathering identical facts (`mount | grep`, `df`, `ls -la`,
|
||||
// `stat`) across hosts and LXCs to understand where a path
|
||||
// lives, who mounts it, and what permissions it has. This tool
|
||||
// collapses that fan-out into one call: pass a path and a list
|
||||
// of targets, get back per-target mount/df/ls/stat output as
|
||||
// JSON. All commands are read-only, so no approval is needed.
|
||||
{tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.",
|
||||
InputSchema: objSchema(
|
||||
prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."},
|
||||
prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return textResult("error: path is required"), nil
|
||||
}
|
||||
rawTargets, _ := args["targets"].([]any)
|
||||
if len(rawTargets) == 0 {
|
||||
return textResult("error: at least one target is required"), nil
|
||||
}
|
||||
if len(rawTargets) > 8 {
|
||||
return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil
|
||||
}
|
||||
targets := make([]string, 0, len(rawTargets))
|
||||
for _, t := range rawTargets {
|
||||
if s, ok := t.(string); ok && s != "" {
|
||||
targets = append(targets, s)
|
||||
}
|
||||
}
|
||||
results := inspectPathAcrossTargets(ctx, pool, path, targets)
|
||||
out, _ := json.MarshalIndent(results, "", " ")
|
||||
return textResult(string(out)), 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
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -282,23 +282,6 @@ func generateApprovalToken(approvalID uuid.UUID, secret string) string {
|
||||
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 {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
|
||||
187
internal/notifier/notifier_test.go
Normal file
187
internal/notifier/notifier_test.go
Normal 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)
|
||||
}
|
||||
@@ -92,21 +92,7 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
||||
}
|
||||
|
||||
// Determine route
|
||||
route := "escalate"
|
||||
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
|
||||
}
|
||||
route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired)
|
||||
|
||||
// Compute blast radius
|
||||
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
||||
@@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
||||
}, 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.
|
||||
func computeBlastRadius(ctx context.Context, dbc interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
|
||||
62
internal/policy/classify_test.go
Normal file
62
internal/policy/classify_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
208
internal/scheduler/scheduler_test.go
Normal file
208
internal/scheduler/scheduler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
640
inventory.yaml
640
inventory.yaml
@@ -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
|
||||
# mcp/build_host_files.py; do NOT edit those by hand.
|
||||
# This root-level inventory.yaml is the Python-era topology file. It was
|
||||
# superseded on 2026-07-07 by the DB-native model (ADR 0003):
|
||||
#
|
||||
# Conventions:
|
||||
# - hostname keys MUST match the actual `hostname` of the machine (on
|
||||
# macOS: `scutil --get LocalHostName` if set).
|
||||
# - `os:` one of: linux, macos
|
||||
# - `kind:` one of: proxmox-host, lxc, vm, workstation, external
|
||||
# ("external" is reserved for hosts the homelab CLI manages via ssh but
|
||||
# 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`).
|
||||
# - The Postgres database is the single source of truth for all structured
|
||||
# data. Query it via MCP `get_entity` / `list_entities` or the REST API.
|
||||
# - `seeds/inventory.yaml` is the bootstrap + DR seed manifest, ingested
|
||||
# idempotently into the DB at deploy time (content-hashed via
|
||||
# `seed_versions`). `oikos export` regenerates it from the DB for VC.
|
||||
# - `archive/knowledge/` holds the frozen legacy narrative wiki.
|
||||
#
|
||||
# `homelab client add/remove` does surgical line-edits — comments survive.
|
||||
# Avoid round-tripping the file through yaml.safe_dump (it strips comments).
|
||||
|
||||
mesh:
|
||||
primary: netbird
|
||||
accepted:
|
||||
- netbird
|
||||
- tailscale
|
||||
netbird_subnet: 100.122.0.0/16
|
||||
netbird_domain: netbird.selfhosted
|
||||
# Service contract (Oikos, 2026-07-05): each service should carry
|
||||
# backend host/container that runs it (required)
|
||||
# url public URL if ingress-exposed
|
||||
# doc_page owning wiki page
|
||||
# config_repo tracked config repo, if any (mutations go commit+push)
|
||||
# health health-check URL if it differs from `url`
|
||||
# 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
|
||||
# This file is kept only because AGENTS.md §1/§2 still point clients at
|
||||
# `/opt/homelab-context/inventory.yaml` (the on-client clone path). Reconcile
|
||||
# that path to `seeds/inventory.yaml` and delete this stub — tracked as R13
|
||||
# in plans/2026-07-17-codebase-review-and-cleanup.md.
|
||||
#
|
||||
# The live topology is in the DB. The seed is `seeds/inventory.yaml`. This
|
||||
# file is not read by any Go code, script, or test in the repo.
|
||||
---
|
||||
deprecated: true
|
||||
superseded_by: seeds/inventory.yaml
|
||||
source_of_truth: postgres (see ADR 0003)
|
||||
see_also:
|
||||
- seeds/inventory.yaml
|
||||
- seeds/ontology.yaml
|
||||
- seeds/policy.yaml
|
||||
- docs/adr/0003-db-native-ontology-yaml-seeds.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- 020_session_reliability.up.sql
|
||||
-- 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
|
||||
-- get a higher generation number so the frontend can group/collapse old ones.
|
||||
|
||||
@@ -336,6 +336,51 @@ port is busy, find a free one. Only surface to the operator if you've tried
|
||||
reasonable alternatives and none worked. An error in one step is not a reason
|
||||
to stop the entire turn — it's a reason to try a different approach.
|
||||
|
||||
**A hung command is not a failed command — investigate before retrying.**
|
||||
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
|
||||
gateway timeout), DO NOT immediately retry the same command with different
|
||||
routing/wrapping (direct vs SSH-hop vs split, single quotes vs double,
|
||||
bare `echo test` sanity check, …). That piles up zombie processes on the
|
||||
target and burns tool calls. Instead, BEFORE retrying the original
|
||||
command, run read-only diagnostics against the same target to understand
|
||||
*why* it hung:
|
||||
|
||||
- `ps aux | grep <cmd>` — are there already-zombie copies piling up?
|
||||
- `lsof <path>` — is something holding the file/dir open?
|
||||
- `strace -f -p <pid>` or `timeout 5 strace -f <cmd>` — what syscall is
|
||||
it stuck on? (e.g. `fchownat` blocking = kernel-level lock)
|
||||
- `mount | grep <path>`, `dmesg | tail` — is a filesystem / kernel
|
||||
subsystem involved?
|
||||
- `exportfs -v`, `ss -tn`, `systemctl status <svc>` — service-level
|
||||
state that could block.
|
||||
|
||||
Once you understand the blocker, fix it with a different command (e.g.
|
||||
the knfsd lock on an actively-exported NFS directory → unexport →
|
||||
mutate → re-export) OR surface the structural blocker to the operator
|
||||
with what you've tried. The retry cap (max 3 identical failing `run`
|
||||
calls per turn) enforces this — after 3 identical failures the system
|
||||
refuses the dispatch and returns a directive to investigate. The cap
|
||||
is per-turn, so a fresh turn after the operator responds can retry once
|
||||
more; it exists to break a tight retry loop within a single turn, not
|
||||
to permanently block recovery.
|
||||
|
||||
**Ask before proposing a multi-step migration.** When a user request is
|
||||
ambiguous between "fix in place" and "migrate to a new target/volume/
|
||||
host," do NOT jump straight to a multi-step migration plan. Use
|
||||
`ask_operator` with one clarifying question ("fix in place, or migrate?")
|
||||
before producing the plan. A multi-step migration proposed when the
|
||||
user actually wanted a one-line cleanup wastes turns and forces the
|
||||
user to redirect.
|
||||
|
||||
**Multi-goal sessions: summarize the arc, not just the last goal.**
|
||||
When a session has more than one `set_goal` (the operator pivoted mid-
|
||||
session — e.g. "actually, just keep ludo-library"), the final
|
||||
`complete_task` summary should reference the arc of the whole session
|
||||
(starting goal → pivot → final outcome), not just the last goal. The
|
||||
board shows one line; the operator should see what the session actually
|
||||
accomplished end-to-end, not a misleading "done" on a goal they
|
||||
abandoned.
|
||||
|
||||
**Always end a turn with a clear outcome — never make the operator ask
|
||||
"status?".** When you finish (or pause) a piece of work, your final message
|
||||
must state the result plainly: what's now true, what you verified, what (if
|
||||
|
||||
463
plans/2026-07-17-codebase-review-and-cleanup.md
Normal file
463
plans/2026-07-17-codebase-review-and-cleanup.md
Normal 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", "001–011" — 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 001–020: 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/ (001–011)" → "(001–020, 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
|
||||
|
||||
- 0001–0015 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 0001–0010/0015
|
||||
(plain) and 0011–0014 (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 R1–R14 above): all code deletions,
|
||||
refactors, test additions, and the substrate-doc rewrite.
|
||||
414
plans/2026-07-18-session-review-three-sessions.md
Normal file
414
plans/2026-07-18-session-review-three-sessions.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 2026-07-18 — Session review (three recent sessions)
|
||||
|
||||
**Status:** Implemented — P0.1, P0.2, P1.3, P1.4, P1.5, P1.6, P1.8, P2.10
|
||||
landed in v0.7.12. P1.7 and P2.9 deferred (retry cap addresses the same
|
||||
symptom at lower cost); see "Deferred" section at the bottom.
|
||||
**Updated:** 2026-07-18 — session 1 continued after initial audit; outcome
|
||||
upgraded from ⚠️ partial to ✅ success, root cause revised (knfsd kernel
|
||||
lock, not gateway timeout). Implementation landed same day.
|
||||
|
||||
Review of the last three Nomos sessions against the protocol in
|
||||
`.agents/skills/session-review/SKILL.md`. Data pulled from the local
|
||||
sessions API (`http://localhost:8092/sessions`).
|
||||
|
||||
---
|
||||
|
||||
## Session 1 — `1e9c7691` (2026-07-18T11:28)
|
||||
|
||||
**"Diagnose and fix ZimaOS folder move/delete failures on ludo-library"**
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Messages | 13 (6 user / 7 assistant) |
|
||||
| Tool calls | 97 across 7 turns |
|
||||
| Top tools | `run` ×58, `update_plan_step` ×7, `get_execution_status` ×7, `search_knowledge` ×3, `list_entities` ×3, `get_entity` ×3, `whoami` ×2 |
|
||||
| Objective | Diagnose and fix ZimaOS folder move/delete failures on the ludo-library NFS mount |
|
||||
| Outcome | ✅ success — root cause found and fix applied; verified from ZimaOS |
|
||||
| Severity | friction |
|
||||
|
||||
### What worked
|
||||
- Root-cause analysis was fast and correct at the **NFS permissions** layer:
|
||||
the export on `strong` uses `all_squash,anonuid=33,anongid=10000`, mapping
|
||||
every NFS client to `www-data:media`. The export root `/mnt/media_local`
|
||||
was owned `root:root / 755` while subdirs were `2775 media`. Subdir-level
|
||||
ops worked, root-level (rename/delete top-level entries) failed.
|
||||
- After the user prompted "the command just keeps running? does not
|
||||
complete," the agent dug deeper and found the **real root cause**:
|
||||
`knfsd` (kernel NFS server) holds a lock on actively-exported directories,
|
||||
causing `chown` to hang indefinitely at the `fchownat()` syscall.
|
||||
`strace -f chown :10000 /mnt/media_local` confirmed the hang point.
|
||||
25+ zombie `chgrp`/`chown` processes had piled up from the session's
|
||||
repeated attempts.
|
||||
- The correct fix sequence was identified and applied: `killall -9 chgrp
|
||||
chown` to clear zombies, then unexport → `chown :10000` + `chmod 2775`
|
||||
→ re-export. Routed via SSH-hop from `host:hubris` (the `host:strong`
|
||||
direct path kept timing out because the commands genuinely hang, not
|
||||
because of a network issue).
|
||||
- Verification was done from the client side: `touch`, `mv`, `rm`,
|
||||
`mkdir`, `rmdir` all confirmed working at the NFS root from ZimaOS.
|
||||
- Knowledge writeback was good: `upsert_knowledge` recorded an
|
||||
`investigation` linked to `vm:zimaos`, `host:strong`, `pool:ludo-lvm`,
|
||||
with the fix. `complete_task` was called with a clear summary.
|
||||
- Plan lifecycle was followed: `set_goal` → `propose_plan` →
|
||||
`update_plan_step` (running/done) → `complete_task`.
|
||||
|
||||
### What didn't
|
||||
- **20+ blind retries before investigating why.** The agent retried the
|
||||
same one-line `chown`/`chmod` roughly 20 times across direct runs,
|
||||
SSH-hop-via-hubris, wrapping in a shell script, splitting into smaller
|
||||
commands, and bare `echo test` sanity checks — all hung. Each retry
|
||||
piled up another zombie process on `strong`. The agent only
|
||||
investigated *why* the command hung after the user explicitly asked
|
||||
"the command just keeps running?"
|
||||
- **Misdiagnosed the timeout as a gateway/network issue.** The agent's
|
||||
own narrative said "API seems to be struggling with timeouts," "API
|
||||
keeps timing out on strong," "Strong mutations are consistently timing
|
||||
out — read-only works." This framed the problem as the control plane,
|
||||
when in fact the commands were genuinely hanging at the kernel level
|
||||
on the target host. A `strace` on the first failure would have
|
||||
revealed this immediately.
|
||||
- **Approval window kept expiring between retries.** User had to say "go
|
||||
ahead" twice and "proceed" + "status" once each because the assent
|
||||
window closed while the agent was looping on the hung commands.
|
||||
- **No back-off / cap on retries.** 58 `run` calls in 7 turns, of which
|
||||
~20 are essentially the same `chown :10000 /mnt/media_local && chmod
|
||||
2775 …`. Once a command has timed out 3× in a row, the agent should
|
||||
stop retrying and investigate *or* surface the blocker to the operator.
|
||||
|
||||
### Fixes needed
|
||||
- (friction) **Retry cap + "investigate before retry" rule.** In
|
||||
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
|
||||
hash has failed 3× in the session, refuse to issue it again. Force the
|
||||
agent to either change approach (e.g. `strace`, `ps`, `lsof` to see
|
||||
*why*) or surface the blocker to the operator. This single change
|
||||
would have turned session 1 from 58 `run` calls into ~8 and produced
|
||||
the knfsd finding on the first failure instead of the 20th.
|
||||
- (friction) **SOUL.md guidance: a hung command is not a failed command.**
|
||||
When a `run` times out, the agent's first instinct should be to
|
||||
inspect the target (`ps aux | grep <cmd>`, `strace -f -p <pid>`,
|
||||
`lsof <path>`) — not to retry the same command. The current default
|
||||
(retry with different routing/wrapping) wasted 20 calls.
|
||||
- (friction) **Capture the unexport → mutate → re-export pattern as a
|
||||
runbook.** "Mutating an actively-exported NFS directory hangs at
|
||||
`fchownat()`" is a reusable finding. It belongs as a `runbook` entity
|
||||
linked to `host:strong` / `lxc:nfs-export` so the next time someone
|
||||
needs to chown/chmod an exported path, the agent finds it via
|
||||
`get_entity_knowledge` and unexports first.
|
||||
- (friction) Approval window robustness: when an execution times out,
|
||||
extend the assent window for the same plan step automatically — the
|
||||
operator already approved it; we shouldn't make them re-approve
|
||||
because *our* command hung.
|
||||
|
||||
---
|
||||
|
||||
## Session 2 — `55927f0a` (2026-07-18T09:45)
|
||||
|
||||
**"Add NFS export of ludo-lvm (/mnt/library) from strong to ZimaOS, so ZimaOS
|
||||
can see downloads/usenet/movies/ alongside the existing old-library NFS mount"**
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Messages | 25 (12 user / 13 assistant) |
|
||||
| Tool calls | 108 across 13 turns |
|
||||
| Top tools | `run` ×49, `update_plan_step` ×13, `get_entity` ×10, `list_entities` ×6, `update_entity_attributes` ×3, `search_knowledge` ×3, `propose_plan` ×3, `get_relations` ×3, `get_knowledge_content` ×3, `get_execution_status` ×3, `upsert_knowledge` ×2, `set_goal` ×2, `create_relationship` ×2 |
|
||||
| Objective | Originally: fix sabnzbd download folder to use ludo-lvm. Pivoted to: add NFS export of ludo-lvm to ZimaOS. Final outcome: just keep ludo-library (drop redundant /media/media mount) |
|
||||
| Outcome | ✅ success — ZimaOS reduced to two clean tiles, fstab cleaned, knowledge + entity attrs written back |
|
||||
| Severity | friction |
|
||||
|
||||
### What worked
|
||||
- Writeback was thorough: `upsert_knowledge` ×2, `update_entity_attributes`
|
||||
on `vm:zimaos` and `lxc:nfs-export`, `create_relationship` ×2. The
|
||||
knowledge graph is current.
|
||||
- The final cleanup was small and safe: unmount `/media/media` on ZimaOS,
|
||||
remove the fstab entry, `rmdir` the empty directory, clear CasaOS caching
|
||||
artifacts. Each step got its own `run` with a clear result.
|
||||
- Agent correctly noticed the pivot: "wait — `/media/media` IS ludo-lvm
|
||||
too, just via a double NFS hop through nfs-export. Redundant." That
|
||||
insight is what turned a complex migration into a one-step cleanup.
|
||||
|
||||
### What didn't
|
||||
- **Goal pivots were not closed cleanly.** `set_goal` was called twice —
|
||||
once for the sabnzbd fix, once for the NFS export. The first goal was
|
||||
implicitly abandoned when the user said "lets just keep ludo-library
|
||||
then"; there's no `complete_task` for it. If the session state is keyed
|
||||
on the latest `set_goal`, the first goal is orphaned in the UI.
|
||||
- **Excessive fan-out on `run`.** 49 `run` calls, many of which repeat the
|
||||
same diagnostic (`mount | grep`, `cat /etc/exports`, `exportfs -v`,
|
||||
`ls -la /mnt/...`) across `lxc:arriman`, `lxc:jellyfin`, `lxc:nfs-export`,
|
||||
`host:hubris`, `host:strong`. A single bulk "inventory this path across
|
||||
these targets" tool would have collapsed 15+ runs into 1.
|
||||
- **`vm:` targets aren't directly runnable.** Every ZimaOS command had to
|
||||
be `ssh -o StrictHostKeyChecking=no root@192.168.8.195 '…'` from
|
||||
`host:hubris`. Nested shell quoting broke once and the agent had to
|
||||
re-escape. This was called out in the 2026-07-14 review and is still
|
||||
open.
|
||||
- **Agent over-scoped before checking with the user.** After "explain how
|
||||
the migration would work", the agent produced a full multi-LXC migration
|
||||
plan (move `/dev/mapper/library-library` consumers off the old volume,
|
||||
migrate ZimaOS NFS export, etc.). The user replied "lets just keep
|
||||
ludo-library then." A clarifying question — "do you want to migrate, or
|
||||
just clean up the redundant mount?" — would have saved 4 turns.
|
||||
- **Approval friction.** One execution came back with "status=cancelled,
|
||||
but the assent window for this session is not active. The agent will not
|
||||
auto-continue. Reply 'continue' or re-approve the plan to resume." User
|
||||
had to type "proceed" to resume. This is the same assent-window-expiry
|
||||
pattern from session 1.
|
||||
|
||||
### Fixes needed
|
||||
- (friction) Track `set_goal` history per session. When a new goal is set,
|
||||
the previous one should be auto-marked `complete` (or `superseded`) so
|
||||
the UI doesn't show an orphaned active goal.
|
||||
- (friction) Add a bulk inspection tool — e.g. `inspect_path(path, targets)`
|
||||
that returns `mount`, `df`, `ls -la`, and ownership for the same path
|
||||
across multiple entities in one call. Sessions like this routinely spend
|
||||
15+ `run` calls gathering the same facts across hosts.
|
||||
- (friction) `vm:` target support in `run`. Either expose a `qm guest exec`
|
||||
wrapper or accept `vm:<slug>` as a target and route through the host. The
|
||||
manual SSH-hop pattern is error-prone (nested quoting) and slow.
|
||||
- (friction) SOUL.md guidance: before proposing a multi-step migration
|
||||
plan, ask the user "migrate or clean up?" when both are plausible from
|
||||
the prompt. This was a single extra clarification question away from a
|
||||
4-turn savings.
|
||||
|
||||
---
|
||||
|
||||
## Session 3 — `2926de4e` (2026-07-15T22:22)
|
||||
|
||||
**"Deploy apt updates to host:netbird-vps — 92 pending packages including
|
||||
Docker CE, netbird, kernel, ZFS, and security patches."**
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Messages | 9 (4 user / 5 assistant) |
|
||||
| Tool calls | 27 across 5 turns |
|
||||
| Top tools | `update_plan_step` ×7, `run` ×6, `set_goal` ×2, `search_knowledge` ×2, `complete_task` ×2, `upsert_knowledge` ×1, `update_entity_attributes` ×1, `propose_plan` ×1, `list_lxcs` ×1, `get_relations` ×1, `get_knowledge_content` ×1, `get_entity` ×1, `get_execution_status` ×1 |
|
||||
| Objective | Two-phase: (a) fleet-wide update audit by criticality, (b) deploy the 92-package upgrade to host:netbird-vps |
|
||||
| Outcome | ✅ success — 92→0 packages pending; netbird-mgmt OIDC race caught and fixed; knowledge + entity attrs written back |
|
||||
| Severity | cosmetic |
|
||||
|
||||
### What worked
|
||||
- **Two goals, two clean lifecycles.** `set_goal` → `propose_plan` →
|
||||
`update_plan_step` (running/done) → `complete_task` ran twice, once for
|
||||
the audit and once for the upgrade. The session is the model for how
|
||||
multi-goal sessions should look.
|
||||
- **Pre-existing knowledge reuse.** First `search_knowledge` found a
|
||||
today-dated audit; agent used `get_knowledge_content` and presented it
|
||||
without needing any `run` for the audit half. Zero wasted tool calls.
|
||||
- **Long-running upgrade handled correctly.** The 92-package `apt upgrade`
|
||||
hit the HTTP gateway timeout mid-run. Agent didn't retry it — it called
|
||||
`get_execution_status` and then ran a verification `run`
|
||||
(`apt list --upgradable | wc -l`, `uname -r`, `docker ps`) to confirm
|
||||
completion server-side despite the timeout. This is the right pattern;
|
||||
session 1 should have done the same.
|
||||
- **Gotcha caught.** After the upgrade, `docker logs netbird-mgmt`
|
||||
revealed the management container was crash-looping because it tried to
|
||||
fetch OIDC config from `auth.hubris.network` before traefik/authentik
|
||||
were ready. Fix: `docker restart netbird-mgmt` after ~30s. Captured in
|
||||
`upsert_knowledge` as an `investigation` tagged `apt, upgrade, netbird,
|
||||
docker, gotcha` linked to `host:netbird-vps`.
|
||||
- `update_entity_attributes` was called on `host:netbird-vps` to record the
|
||||
new kernel version. Good writeback hygiene.
|
||||
|
||||
### What didn't
|
||||
- (cosmetic) The HTTP timeout on long-running upgrades surfaced as a
|
||||
transient error to the operator. The agent handled it correctly but the
|
||||
UX would be cleaner if `run` returned `PENDING` immediately for known
|
||||
long-running command patterns (`apt upgrade`, `pct migrate`, `rclone
|
||||
sync`, etc.) instead of timing out at the gateway.
|
||||
- (cosmetic) Two `complete_task` calls in one session produced two "task
|
||||
complete" bubbles. Fine, but the second one could have noted the
|
||||
first-task outcome as well in its summary so the chat reads as one
|
||||
coherent arc.
|
||||
|
||||
### Fixes needed
|
||||
- (cosmetic) Long-running command detection in `run`: if the command
|
||||
matches a known-long pattern, return a `PENDING` execution id with a
|
||||
hint to poll `get_execution_status`, rather than blocking at the HTTP
|
||||
layer for 30s and timing out. Session 3 already proved the
|
||||
poll-after-timeout pattern works — make it the default for these
|
||||
commands.
|
||||
- (cosmetic) Encourage the agent to fold the prior task's outcome into
|
||||
the next `complete_task` summary when a session has multiple goals.
|
||||
|
||||
---
|
||||
|
||||
## Cross-session patterns
|
||||
|
||||
| # | Pattern | Sessions | Severity |
|
||||
|---|---|---|---|
|
||||
| 1 | Agent retries hung commands 20× before investigating *why* | 1 | friction |
|
||||
| 2 | Approval window expires between turns forcing re-approval | 1, 2 | friction |
|
||||
| 3 | `vm:` targets not directly runnable — must SSH-hop via `host:hubris` | 1, 2 | friction |
|
||||
| 4 | N+1 fan-out on `run` for cross-entity fact-gathering | 1, 2 | friction |
|
||||
| 5 | No retry cap — agent retries identical failing `run` 10–20× | 1 | friction |
|
||||
| 6 | Goal pivots not closed (`set_goal` called twice without closing prior) | 2 | friction |
|
||||
| 7 | Long-running commands hit HTTP timeout instead of returning PENDING | 3 | cosmetic |
|
||||
| 8 | Agent over-scopes migration plans before checking intent | 2 | friction |
|
||||
| 9 | Reusable operational gotchas (knfsd lock, OIDC race) captured as investigations, not runbooks | 1, 3 | friction |
|
||||
|
||||
**What consistently works well**
|
||||
- Plan lifecycle: `set_goal` → `propose_plan` → `update_plan_step` →
|
||||
`complete_task` is now followed in all three sessions.
|
||||
- Knowledge writeback: `upsert_knowledge`, `update_entity_attributes`,
|
||||
`create_relationship` are used in every session. The graph is kept
|
||||
current.
|
||||
- Root-cause analysis quality is high once the agent digs in (NFS
|
||||
all_squash + root dir perms → knfsd fchownat hang; double NFS hop;
|
||||
OIDC race condition). The problem is getting the agent to dig in
|
||||
*before* the 20th retry.
|
||||
|
||||
**What consistently breaks**
|
||||
- **Hung commands get retried instead of investigated.** Session 1's
|
||||
`chown` was blocked by knfsd for 30+ minutes while the agent retried
|
||||
with different routing/wrapping. Session 3's `apt upgrade` timed out
|
||||
and the agent correctly polled — but that's the exception, not the
|
||||
rule. The default behavior is "retry the same thing differently."
|
||||
- Approval window lifetime vs. agent retry loops — when execution times
|
||||
out, the assent window lapses and the operator has to re-approve even
|
||||
though the *intent* was never withdrawn.
|
||||
- Reusable operational fixes (unexport → mutate → re-export for NFS
|
||||
dirs; `docker restart netbird-mgmt` after stack upgrade) get recorded
|
||||
as `investigation` entities. They should be `runbook` entities so the
|
||||
agent finds them via `get_entity_knowledge` next time and applies the
|
||||
procedure instead of rediscovering it.
|
||||
|
||||
---
|
||||
|
||||
## Improvement plan
|
||||
|
||||
### P0 — Friction (was blocker; downgraded after session 1 resolved)
|
||||
|
||||
1. **Retry cap + "investigate before retry" rule.** In
|
||||
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
|
||||
hash has failed 3× in the session, refuse to issue it again. Force
|
||||
the agent to either change approach (e.g. `strace`, `ps aux | grep`,
|
||||
`lsof` to see *why*) or surface the blocker to the operator. This
|
||||
single change would have turned session 1 from 58 `run` calls into
|
||||
~8 and produced the knfsd finding on the first failure instead of
|
||||
the 20th.
|
||||
2. **SOUL.md guidance: a hung command is not a failed command.** When a
|
||||
`run` times out, the agent's first instinct should be to inspect the
|
||||
target (`ps aux | grep <cmd>`, `strace -f -p <pid>`, `lsof <path>`)
|
||||
— not to retry the same command with different routing/wrapping. The
|
||||
current default wasted 20 calls in session 1.
|
||||
|
||||
### P1 — Friction
|
||||
|
||||
3. **Capture operational gotchas as `runbook` entities, not just
|
||||
`investigation`.** Two candidates from these sessions:
|
||||
- **"Mutating an actively-exported NFS directory hangs at
|
||||
`fchownat()`"** — procedure: `killall -9 chgrp chown` →
|
||||
`exportfs -u <client>:<path>` → `chown`/`chmod` → `exportfs -a`.
|
||||
Linked to `host:strong`, `lxc:nfs-export`.
|
||||
- **"netbird-mgmt crash-loops after stack upgrade"** — procedure:
|
||||
wait ~30s for traefik/authentik to come up, then
|
||||
`docker restart netbird-mgmt`. Linked to `host:netbird-vps`.
|
||||
Today both are `investigation` entries; the agent records them but
|
||||
won't proactively apply them next time.
|
||||
4. **Auto-close prior `set_goal` when a new one is set.** Mark the
|
||||
previous goal `superseded` and emit a synthetic `complete_task`
|
||||
summary so the UI doesn't show an orphaned active goal. (Session 2
|
||||
had this.)
|
||||
5. **Bulk inspection tool.** Add an MCP tool like
|
||||
`inspect_path(path, targets[])` that runs `mount | grep`, `df`,
|
||||
`ls -la`, and `stat` against a list of entity slugs in one call.
|
||||
Sessions 1 and 2 each spent ~15 `run` calls gathering identical
|
||||
facts across hosts/LXCs.
|
||||
6. **`vm:` target support in `run`.** Accept `vm:<slug>` as a target
|
||||
and route via `qm guest exec` on the host that owns the VM.
|
||||
Eliminates the nested-quoting SSH-hop pattern that broke once in
|
||||
session 2 and required manual SSH-hop workarounds in session 1.
|
||||
7. **Approval window robustness.** When an execution times out, extend
|
||||
the assent window for the same plan step automatically — the
|
||||
operator already approved it; we shouldn't make them re-approve
|
||||
because *our* command hung. Affects sessions 1 and 2.
|
||||
8. **SOUL.md guidance: ask-before-migrating.** When a user request is
|
||||
ambiguous between "fix in place" and "migrate," the agent should
|
||||
ask one clarifying question before producing a multi-step migration
|
||||
plan. Session 2 would have saved ~4 turns.
|
||||
|
||||
### P2 — Cosmetic
|
||||
|
||||
9. **Long-running command detection.** Maintain a small regex list
|
||||
(`apt (upgrade|install)`, `pct migrate`, `rclone (sync|copy)`,
|
||||
`dd if=`, `docker compose pull`) for commands that are known to
|
||||
exceed 30s. Return `PENDING` immediately with an `execution_id`
|
||||
instead of blocking at the gateway. Session 3 already uses the
|
||||
poll pattern — make it the default.
|
||||
10. **Multi-goal `complete_task` summaries.** When a session has more
|
||||
than one `set_goal`, the final `complete_task` summary should
|
||||
reference the arc of the whole session, not just the last goal.
|
||||
|
||||
---
|
||||
|
||||
## Revised note on the original P0
|
||||
|
||||
The original P0 ("Diagnose `host:strong` config_mutation timeouts —
|
||||
suspect SSH latency / mesh routing, raise timeout") was **wrong**. The
|
||||
timeouts were not a gateway or network issue — the commands were
|
||||
genuinely hanging at the kernel level because `knfsd` holds a lock on
|
||||
actively-exported directories. Raising the HTTP timeout would not have
|
||||
helped; the `chown` would simply hang longer. The real fix is (a) the
|
||||
retry-cap/investigate-before-retry rule (P0.1 above) and (b) the
|
||||
unexport → mutate → re-export runbook (P1.3).
|
||||
|
||||
---
|
||||
|
||||
## Deferred
|
||||
|
||||
**P1.7 — Approval window auto-extends on execution timeout.** The assent
|
||||
window lives in `autonomy_settings` and is read by `classifyAndGate`
|
||||
(`internal/mcp/server.go:607`); timeout detection lives in `sshExec`
|
||||
(`internal/mcp/server.go:332`). Wiring them requires the SSH-execution
|
||||
path to signal back into the approval-state machine across the nomos ↔
|
||||
api process boundary, and a future implementation needs to distinguish
|
||||
"command genuinely hung" (knfsd case — don't extend, the command is
|
||||
stuck) from "command is long-running" (apt upgrade — extend). Without
|
||||
that distinction, auto-extending on every timeout would mask real hang
|
||||
symptoms — exactly the misdiagnosis session 1 made. **The retry cap
|
||||
(P0.1) addresses the same symptom at lower cost**: after 3 failures
|
||||
the agent is forced to investigate or surface, which removes the
|
||||
cascading retry storm that made the assent expiry visible in the first
|
||||
place. Revisit if future sessions show the operator re-approving a
|
||||
plan they never withdrew in intent (not just retrying a hung command).
|
||||
|
||||
**P2.9 — Long-running command PENDING detection.** A regex list of
|
||||
known-long commands (`apt (upgrade|install)`, `pct migrate`, `rclone
|
||||
(sync|copy)`, `dd if=`, `docker compose pull`) so `run` returns
|
||||
`PENDING` immediately with an `execution_id` instead of blocking at
|
||||
the HTTP gateway for 30s and timing out. **Session 3 already proved
|
||||
the current poll pattern works:** the `apt upgrade` timed out at the
|
||||
gateway, the agent called `get_execution_status`, then ran a
|
||||
verification `run` (`apt list --upgradable | wc -l`, `uname -r`,
|
||||
`docker ps`) — clean 92→0 packages result. The agent did the right
|
||||
thing without any new machinery, and the retry cap (P0.1) protects
|
||||
against the failure mode of this path (blind retry on timeout).
|
||||
Implementing PENDING detection well requires a classifier extension
|
||||
(`internal/policy`) plus a new return shape from `classifyAndGate`
|
||||
that the agent loop has to learn to handle (poll instead of retry) —
|
||||
a real protocol change, not a small fix. Worth doing if the
|
||||
poll-after-timeout pattern proves fragile over the next few sessions;
|
||||
not worth doing speculatively right now.
|
||||
|
||||
---
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
# Re-pull any session for follow-up
|
||||
curl -s http://localhost:8092/sessions/1e9c7691-5815-48d1-acb4-91a6a39691c9 | jq .
|
||||
curl -s http://localhost:8092/sessions/55927f0a-597e-4561-aaef-077623051432 | jq .
|
||||
curl -s http://localhost:8092/sessions/2926de4e-0b73-4c3d-a2cd-ee9a42089b46 | jq .
|
||||
|
||||
# Confirm host:strong mutation timeout reproduces
|
||||
curl -s http://localhost:8092/sessions | jq -r '.sessions[].id' | head -1 # latest session id
|
||||
```
|
||||
|
||||
## Related files
|
||||
|
||||
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
|
||||
- `cmd/nomos/store.go` — `set_goal` / `complete_task` persistence
|
||||
- `internal/mcp/server.go` — `run` tool, timeout handling, `get_execution_status`
|
||||
- `internal/httpapi/server.go` — HTTP gateway timeout for mutations
|
||||
- `nomos/SOUL.md` — agent persona, ask-before-migrate guidance candidate
|
||||
- `plans/2026-07-14-activity-gaps.md` — prior session review (same patterns recurring)
|
||||
376
plans/2026-07-20-desktop-mascot.md
Normal file
376
plans/2026-07-20-desktop-mascot.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 2026-07-20 — Desktop mascot ("Cluck")
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Why
|
||||
|
||||
The web control room is an OS-style desktop shell (icons, floating
|
||||
windows, taskbar) but has no ambient, always-visible signal of what the
|
||||
system is doing — you have to open a window to see a chat streaming, a
|
||||
knowledge-graph write, or a critical signal land. The user asked for a
|
||||
pixel-art chicken mascot that roams the desktop, is draggable and
|
||||
interactable (Sims-style radial right-click menu with nested actions), and
|
||||
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
|
||||
visibly reacts to real app activity. This plan is scaffolding: every piece
|
||||
(sprites, autonomous behaviors, menu actions, environment reactions) is a
|
||||
data-driven registry so each can be extended independently later without
|
||||
touching the engine code.
|
||||
|
||||
The MBSE subsystem model for this feature (Mission, Requirements,
|
||||
Structural/Behavioral/Interfaces views, Verification) lives at
|
||||
[docs/mascot/README.md](../docs/mascot/README.md) — read it first for the
|
||||
full rationale and diagrams; this document is the concrete file-by-file
|
||||
implementation plan derived from it.
|
||||
|
||||
**Design decisions already made with the user:**
|
||||
- Renders **above windows** (desktop-pet style) — mascot layer `z-45`,
|
||||
radial menu `z-[60]` (must beat the desktop's own right-click menu,
|
||||
which is `z-50`).
|
||||
- Art is **code-drawn pixel art** — string pixel-grids + a palette map in
|
||||
TypeScript, rendered to a small canvas, no binary sprite assets.
|
||||
- Movement is **gravity + ground** — walks along the desktop surface's
|
||||
bottom edge (= the taskbar's top edge), flutter-falls when dropped
|
||||
mid-air.
|
||||
|
||||
## Verified codebase facts this plan builds on
|
||||
|
||||
- `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div
|
||||
(`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon
|
||||
layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every
|
||||
wrapper is `pointer-events-none` with interactive children re-enabling
|
||||
`pointer-events-auto`. The desktop's own right-click menu is `fixed
|
||||
z-50`, dismissed via `<svelte:window onclick={closeMenu}>` + Escape.
|
||||
Bare-surface clicks are gated with `e.currentTarget === e.target`.
|
||||
- Drag pattern to copy: `desktop-shell/DesktopIcon.svelte` —
|
||||
`pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement
|
||||
threshold distinguishes a click from a drag, move/up listeners attached
|
||||
to the element itself (not `window`), position blended via `$derived`
|
||||
between rest and drag-in-progress values.
|
||||
- Game loop convention: `GraphBackground.svelte` drives its canvas with
|
||||
`setTimeout(() => draw(performance.now()), 33)` (~30fps), **not**
|
||||
`requestAnimationFrame` — the code comment there explains some embedding
|
||||
contexts report `document.hidden=true` and suspend rAF, which would
|
||||
freeze the animation; `setTimeout` keeps ticking. Follow this for the
|
||||
mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab
|
||||
doesn't produce a physics-breaking huge step on resume.
|
||||
- Persistence convention: hyphenated `oikos-*` localStorage keys
|
||||
(`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout
|
||||
uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300,
|
||||
autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`;
|
||||
never write on every animation frame, only on discrete state
|
||||
transitions (behavior change, drag end, stage change, rename).
|
||||
- Runes idiom for cross-component client state: a `.svelte.ts` module with
|
||||
module-level `$state` plus exported getter/mutator functions —
|
||||
`web/src/lib/stores/theme.svelte.ts` is the canonical example
|
||||
(`let current: Theme = $state(initialTheme)`, `getTheme()`,
|
||||
`setTheme()`, `toggleTheme()`).
|
||||
- Awareness sources, all plain Svelte stores already in the codebase:
|
||||
- `web/src/lib/stores/events.ts` — `liveEvents: Writable<OikosEvent[]>`
|
||||
(newest-first, capped at 200), fed by a ref-counted SSE subscription
|
||||
`subscribeEvents()`. `OikosEvent.type` families: `approval.*`,
|
||||
`signal.*`, `execution.*`, `health.changed`; `severity: 'info' |
|
||||
'warning' | 'critical'`.
|
||||
- `web/src/lib/stores/chat.ts` — `streaming: Writable<boolean>`.
|
||||
- `web/src/lib/stores/activity.ts` — `activityLog` is a **derived**
|
||||
store recomputed wholesale from `messages`/`planSteps`/`currentTask`
|
||||
on every emission, **not an append-only log** — detecting a "new"
|
||||
entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s
|
||||
against the previous emission, not just reacting to the store firing.
|
||||
- `web/src/lib/stores/context.ts` — `summary: Writable<DashboardSummary
|
||||
| null>`, `openSignalCount(summary)`.
|
||||
- No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code
|
||||
exists anywhere in the repo today — this is greenfield within the
|
||||
established canvas-loop convention above.
|
||||
|
||||
## File layout
|
||||
|
||||
All new, under `web/src/lib/mascot/`:
|
||||
|
||||
```
|
||||
types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction
|
||||
palette.ts Record<char, cssColor>; '.' = transparent
|
||||
sprites.ts SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + resolveAnim() fallback
|
||||
render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter
|
||||
state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema
|
||||
behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt)
|
||||
stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted
|
||||
actions.ts MASCOT_ACTIONS radial tree + registerMascotAction()
|
||||
Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu
|
||||
MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble
|
||||
RadialMenu.svelte round nested menu, fixed z-[60]
|
||||
NameDialog.svelte naming prompt (hatch + rename)
|
||||
```
|
||||
|
||||
**Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and
|
||||
render `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
|
||||
/>`, so its `absolute inset-0` shares the surface's coordinate space and
|
||||
its ground line lands exactly at the surface's bottom edge (the taskbar's
|
||||
top edge).
|
||||
|
||||
## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`)
|
||||
|
||||
Frames are human-editable string pixel-grids indexing a palette, e.g.:
|
||||
|
||||
```ts
|
||||
export type PixelGrid = string[] // rows of same-length strings, one char per pixel
|
||||
export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean }
|
||||
export type AnimName =
|
||||
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
|
||||
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
|
||||
| 'dragged' | 'fall-flutter' | 'land'
|
||||
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
|
||||
```
|
||||
|
||||
- Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a
|
||||
fixed 20×20 logical canvas so feet land on the ground line consistently
|
||||
across stages.
|
||||
- `SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>>` is
|
||||
the registry; `resolveAnim(stage, name)` falls back to that stage's
|
||||
`idle` and finally a 1-frame placeholder, so a missing animation never
|
||||
crashes the renderer.
|
||||
- Canvas is sized to the logical grid; screen scale is pure CSS (`width:
|
||||
20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled =
|
||||
false` set once. Horizontal facing flip via `ctx.translate(w,0);
|
||||
ctx.scale(-1,1)` — no mirrored frame data needed.
|
||||
- Initial animation set (2–4 frames each): egg-idle/egg-wiggle/egg-crack/
|
||||
hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land;
|
||||
react-think/react-eureka/react-alarm/react-happy.
|
||||
- Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if
|
||||
`loop`.
|
||||
|
||||
## Behavior engine (`behavior.ts`)
|
||||
|
||||
```ts
|
||||
export interface MascotRuntime {
|
||||
x: number; y: number // sprite bottom-center, surface coords
|
||||
vx: number; vy: number
|
||||
facing: 1 | -1
|
||||
behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react'
|
||||
behaviorUntil: number
|
||||
anim: AnimName
|
||||
animStart: number
|
||||
reactAnim: AnimName | null
|
||||
bounds: { w: number; h: number }
|
||||
}
|
||||
|
||||
export interface BehaviorDef {
|
||||
id: BehaviorId
|
||||
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
|
||||
enter?: (rt: MascotRuntime) => void
|
||||
tick: (rt: MascotRuntime, dt: number, now: number) => void
|
||||
next: (rt: MascotRuntime, now: number) => BehaviorId | null
|
||||
weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked
|
||||
minMs: number; maxMs: number
|
||||
}
|
||||
export const BEHAVIORS: Record<BehaviorId, BehaviorDef>
|
||||
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
|
||||
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
|
||||
```
|
||||
|
||||
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
|
||||
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
|
||||
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
|
||||
on reaching ground, snap `y`, brief `land`, then `idle`.
|
||||
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
|
||||
surface margins.
|
||||
- **Idle selection**: when `now > behaviorUntil` and the current
|
||||
behavior's `next()` returns null, roll a weighted random pick over
|
||||
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
|
||||
wander 4, peck 2, sleep 1.
|
||||
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
|
||||
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||||
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||||
`react`.
|
||||
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
|
||||
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
|
||||
egg can be picked up and moved).
|
||||
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||||
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||||
|
||||
## Tamagotchi model (`state.svelte.ts`)
|
||||
|
||||
```ts
|
||||
export type MascotStage = 'egg' | 'chick' | 'adult'
|
||||
export interface MascotModel {
|
||||
version: 1
|
||||
stage: MascotStage
|
||||
name: string | null
|
||||
hatchProgress: number // 0..1, egg stage only
|
||||
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||||
xp: number // chick -> adult growth hook
|
||||
hatchedAt: number | null
|
||||
lastPos: { x: number } | null
|
||||
lastSeen: number // for capped offline egg-incubation progress
|
||||
}
|
||||
export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly)
|
||||
export const ADULT_XP = 200
|
||||
export function grantXp(n: number): void
|
||||
export function feed(): void
|
||||
export function pet(): void
|
||||
export function setName(name: string): void
|
||||
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||||
export function advanceStageIfReady(): void
|
||||
```
|
||||
|
||||
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
|
||||
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
|
||||
MascotModel` is a stub switch on `version` for future schema changes —
|
||||
v1 has no migrations to perform, the stub just documents where they go.
|
||||
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
|
||||
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||||
rename. `lastPos.x` is written only on behavior transitions and
|
||||
drag-end, never per frame.
|
||||
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||||
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||||
pass could listen to the `storage` event if it becomes a real problem.
|
||||
|
||||
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
|
||||
|
||||
```ts
|
||||
export interface RadialAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component // lucide, same convention as the desktop menu
|
||||
visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched
|
||||
children?: RadialAction[]
|
||||
action?: (ctx: MascotActionCtx) => void // leaf only
|
||||
}
|
||||
export const MASCOT_ACTIONS: RadialAction[]
|
||||
export function registerMascotAction(a: RadialAction, parentId?: string): void
|
||||
```
|
||||
|
||||
v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake],
|
||||
**Identity** [Rename], **Debug** [Force hatch/stage, Reset].
|
||||
|
||||
- Rendered by `MascotLayer.svelte` as `fixed`, positioned at the
|
||||
chicken's screen center, **`z-[60]`** (must beat the desktop context
|
||||
menu's `z-50`, comfortably above `WindowLayer`'s `z-40`).
|
||||
- Layout: items on a circle (radius ≈ 70px) via polar `transform`s around
|
||||
the menu's anchor point. Open animation: buttons start scale-0 at
|
||||
center and transition to their polar position with `transform 120ms
|
||||
cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no
|
||||
keyframes, reads as snappy/springy per the "snappy" requirement.
|
||||
- **Nesting**: selecting a node with `children` swaps the ring's contents
|
||||
to those children plus a center "back" button; track the breadcrumb as
|
||||
a local `$state<RadialAction[][]>` stack.
|
||||
- Dismissal mirrors the desktop menu's existing pattern:
|
||||
`<svelte:window onclick={close}>`, Escape pops one level then closes on
|
||||
the next press; the menu's own clicks `stopPropagation()`. Clamp the
|
||||
ring's screen position so it never renders off-viewport (relevant near
|
||||
screen edges/corners).
|
||||
- Opened from `Mascot.svelte`'s `oncontextmenu`:
|
||||
`e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to
|
||||
open at the sprite's center (the surface's own `onSurfaceContextMenu`
|
||||
already gates on `currentTarget === target`, so this is defensive, not
|
||||
strictly required — but keep it for clarity).
|
||||
|
||||
## Stimulus / reaction system (`stimuli.ts`)
|
||||
|
||||
```ts
|
||||
export interface ReactionDef {
|
||||
id: string
|
||||
anim: AnimName
|
||||
priority: number
|
||||
cooldownMs: number
|
||||
durationMs: number
|
||||
interruptsSleep?: boolean
|
||||
effect?: () => void // e.g. grantXp(5) on eureka
|
||||
}
|
||||
export const REACTIONS: Record<string, ReactionDef>
|
||||
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
|
||||
```
|
||||
|
||||
Initial wiring:
|
||||
|
||||
| Source | Trigger | Reaction |
|
||||
|---|---|---|
|
||||
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
|
||||
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
|
||||
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
|
||||
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
|
||||
|
||||
- `attachStimuli` calls `subscribeEvents()` itself and folds its
|
||||
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||||
stream open (ref-counted alongside any page that also subscribes) only
|
||||
while mounted.
|
||||
- On first emission of `liveEvents`, just record the head event id — do
|
||||
not replay history as reactions on mount.
|
||||
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||||
`priority >= currentReactPriority` (or current behavior isn't
|
||||
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
|
||||
durationMs })`. `dragged` always wins over any reaction; `sleep` is
|
||||
broken only when `interruptsSleep` is true.
|
||||
|
||||
## Implementation order (sized for one PR each)
|
||||
|
||||
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
|
||||
`render.ts` — pure data/functions, no UI yet.
|
||||
2. `state.svelte.ts` model + debounced persistence — verify by hand via
|
||||
devtools console before wiring any UI.
|
||||
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
|
||||
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
|
||||
milestone** — an egg sits on the ground and can be dragged.
|
||||
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
|
||||
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
|
||||
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
|
||||
dismissal).
|
||||
6. `stimuli.ts` + the four reaction animations + the wiring table above.
|
||||
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
|
||||
squash frame on `land`).
|
||||
8. A short "how to add an animation / behavior / action / reaction" doc
|
||||
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
|
||||
`stimuli.ts` respectively (this plan's registry tables above become
|
||||
those comments, condensed).
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Run `npm run dev` in `web/`, then in the browser:
|
||||
|
||||
- Egg renders on the ground at the surface bottom, wiggles occasionally,
|
||||
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
|
||||
confirm it is *not* being written on every frame while merely idling or
|
||||
walking, only on discrete transitions).
|
||||
- Dragging the egg up and releasing triggers a flutter-fall back down with
|
||||
no tunneling below the taskbar; dragging past the surface's left/right
|
||||
edges clamps rather than escaping the viewport.
|
||||
- The debug "force hatch" action transitions egg → chick, opens the name
|
||||
dialog, and the chosen name persists across a reload.
|
||||
- The chick wanders and flips its sprite at the surface edges, pecks, and
|
||||
sleeps on its own; a plain click (below the 5px drag threshold) triggers
|
||||
a pet/hop reaction and grants a little xp.
|
||||
- Right-clicking the chicken opens the radial menu centered on it — and
|
||||
right-clicking bare desktop elsewhere still opens the *original* desktop
|
||||
menu, unaffected. The nested Feed submenu opens; Escape pops one level
|
||||
then closes on the next press; clicking outside the menu closes it;
|
||||
the menu stays fully on-screen when the chicken is near a corner.
|
||||
- With a maximized window open, the chicken visibly walks above it; window
|
||||
drag/resize/close still work normally when the chicken merely passes
|
||||
under the cursor (not when it's directly over a button being clicked —
|
||||
known, accepted overlap per the "renders above windows" decision).
|
||||
- Sending a chat message and watching it stream triggers the `thinking`
|
||||
animation for the duration; simulate (or trigger for real) a
|
||||
knowledge-graph write and confirm `eureka` fires once and respects its
|
||||
cooldown on a second write; simulate a critical signal and confirm
|
||||
`alarmed` fires even while the chicken is asleep.
|
||||
- Resizing the browser viewport re-grounds the chicken and keeps it
|
||||
within the new bounds.
|
||||
- Both the Terracotta and Carbon themes keep the pixel palette legible.
|
||||
- `npm run build` passes with no new errors or warnings beyond the
|
||||
pre-existing baseline.
|
||||
|
||||
## Risks
|
||||
|
||||
- Z-index ordering is easy to get subtly wrong: radial menu must be
|
||||
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
|
||||
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
|
||||
- The mascot rendering above windows means it can occlude/steal clicks on
|
||||
window chrome directly beneath it — accepted per the "above windows"
|
||||
decision; mitigate by keeping the pointer hitbox tight to the canvas
|
||||
element only (no oversized invisible padding).
|
||||
- `activityLog` is a derived store recomputed wholesale on every
|
||||
emission, not an append-only log — any "new entry" detection must diff
|
||||
entry ids between emissions, never assume the store only ever grows by
|
||||
appending.
|
||||
- `setTimeout`-driven loops can receive large `dt` spikes after tab
|
||||
throttling/backgrounding resumes — clamp `dt` before feeding it into
|
||||
physics or lifecycle ticking.
|
||||
@@ -14,10 +14,11 @@ 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-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-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed |
|
||||
| 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 |
|
||||
| 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 |
|
||||
| 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) |
|
||||
| 2026-07-14 | [Activity gaps](2026-07-14-activity-gaps.md) | In Progress |
|
||||
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
|
||||
| 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-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started |
|
||||
|
||||
## Done
|
||||
|
||||
@@ -49,6 +50,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-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-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
|
||||
|
||||
|
||||
@@ -6713,3 +6713,143 @@ runbooks:
|
||||
tags:
|
||||
- skill
|
||||
- runbook
|
||||
- slug: nfs-exported-dir-mutation-hang
|
||||
name: Mutating an actively-exported NFS directory hangs at fchownat
|
||||
risk_class: config_mutation
|
||||
entity_type: host
|
||||
procedure: {}
|
||||
content: |
|
||||
---
|
||||
name: nfs-exported-dir-mutation-hang
|
||||
risk_class: config_mutation
|
||||
inputs: [target_host, exported_path, mutation_command]
|
||||
verification: "stat -c '%a %U:%G' <exported_path> on the target host"
|
||||
docs_update_checklist: [investigation_entry]
|
||||
---
|
||||
|
||||
# Mutating an actively-exported NFS directory hangs at fchownat
|
||||
|
||||
Symptom: a `chown` / `chgrp` / `chmod` against a directory that is
|
||||
actively exported via `nfs-kernel-server` (knfsd) hangs indefinitely — the
|
||||
command appears to run but never returns. SSH/gateway time out waiting for it.
|
||||
`ps aux | grep chown` shows the process in interruptible sleep (D state);
|
||||
repeated retries pile up zombies (25+ observed in one session).
|
||||
|
||||
Root cause: knfsd holds a kernel lock on the directory while it's exported.
|
||||
`fchownat()` blocks waiting for the lock. This is NOT a gateway or SSH issue —
|
||||
raising the timeout just makes the hang longer.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Clear the zombies** from prior failed attempts:
|
||||
```
|
||||
killall -9 chgrp chown chmod 2>/dev/null
|
||||
```
|
||||
2. **Temporarily unexport** the path for each client/network that has it exported:
|
||||
```
|
||||
exportfs -u <client>:<path> # e.g. exportfs -u 192.168.8.0/24:/mnt/media_local
|
||||
```
|
||||
3. **Apply the mutation** (now that knfsd has released the lock):
|
||||
```
|
||||
chown :<gid> <path> && chmod <mode> <path>
|
||||
```
|
||||
4. **Re-export** (restore the exports):
|
||||
```
|
||||
exportfs -a
|
||||
```
|
||||
5. **Verify** from an NFS client that the new permissions are visible and operations work end-to-end:
|
||||
```
|
||||
stat -c '%a %U:%G' /media/<mountpoint> # on a client
|
||||
touch /media/<mountpoint>/.test && mv /media/<mountpoint>/.test /media/<mountpoint>/.moved && rm /media/<mountpoint>/.moved
|
||||
```
|
||||
|
||||
## Detection signature (for agents)
|
||||
|
||||
A `run` call against a host that contains `chown|chgrp|chmod` of a path
|
||||
exported by `nfs-kernel-server` AND the call times out → assume this runbook.
|
||||
Don't retry the same command; run `strace -f <cmd>` (it will block at
|
||||
`fchownat`) to confirm, then apply the procedure above.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `exportfs -u` may emit a format-mismatch warning if the export was defined
|
||||
via `/etc/exports` with a different option string than `exportfs -v`
|
||||
reports. The unexport still succeeds; verify with `exportfs -v` afterward
|
||||
that the path is gone, then re-add with `exportfs -a`.
|
||||
- This applies to ANY mutating op on the exported dir (chown, chmod, rename,
|
||||
rmdir of the root). Subdirectory mutations are fine as long as they don't
|
||||
touch the exported root itself.
|
||||
|
||||
Recorded after session 1e9c7691 (2026-07-18) — 20+ retries of a
|
||||
`chown :10000 /mnt/media_local` that hung for 30+ minutes before this
|
||||
procedure was identified.
|
||||
tags:
|
||||
- runbook
|
||||
- nfs
|
||||
- knfsd
|
||||
- gotcha
|
||||
- slug: netbird-mgmt-oidc-race-after-upgrade
|
||||
name: netbird-mgmt crash-loops after stack upgrade (OIDC race)
|
||||
risk_class: reversible_low
|
||||
entity_type: host
|
||||
procedure: {}
|
||||
content: |
|
||||
---
|
||||
name: netbird-mgmt-oidc-race-after-upgrade
|
||||
risk_class: reversible_low
|
||||
inputs: []
|
||||
verification: "docker ps --filter name=netbird-mgmt --format '{{.Status}}' shows Up"
|
||||
docs_update_checklist: [investigation_entry]
|
||||
---
|
||||
|
||||
# netbird-mgmt crash-loops after stack upgrade (OIDC race)
|
||||
|
||||
Symptom: after a full-stack restart on `host:netbird-vps` (e.g. following an
|
||||
apt upgrade that touched Docker, traefik, authentik, or the netbird
|
||||
packages), `netbird-mgmt` enters a crash loop. `docker logs netbird-mgmt
|
||||
--tail 30` shows repeated failed attempts to fetch OIDC config from
|
||||
`auth.hubris.network` (connection refused / i/o timeout).
|
||||
|
||||
Root cause: startup ordering race. `netbird-mgmt` tries to fetch its OIDC
|
||||
configuration from `auth.hubris.network` before traefik and authentik are
|
||||
ready to serve. Connection refused → mgmt exits → docker restarts it →
|
||||
same failure.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Confirm the race (not a real config breakage):
|
||||
```
|
||||
docker logs netbird-mgmt --tail 30 2>&1 | grep -E 'auth.hubris.network|OIDC|connection refused'
|
||||
curl -fsS -o /dev/null -w '%{http_code}' https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration
|
||||
```
|
||||
If the curl now returns 200, the race has already self-resolved — just restart mgmt.
|
||||
2. Wait ~30s for traefik + authentik to finish coming up.
|
||||
3. Restart just the management container:
|
||||
```
|
||||
docker restart netbird-mgmt
|
||||
```
|
||||
4. Verify:
|
||||
```
|
||||
docker ps --filter name=netbird-mgmt --format '{{.Names}} {{.Status}}'
|
||||
docker logs netbird-mgmt --tail 10 2>&1 # should show clean startup, no OIDC errors
|
||||
```
|
||||
5. Check the rest of the stack is healthy too:
|
||||
```
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}'
|
||||
```
|
||||
|
||||
## Detection signature (for agents)
|
||||
|
||||
After a `run` that upgraded anything Docker/traefik/authentik/netbird on
|
||||
`host:netbird-vps`, run `docker ps` and `docker logs netbird-mgmt --tail 30`.
|
||||
If mgmt is Restarting + logs mention auth.hubris.network OIDC fetch failure,
|
||||
apply this procedure before declaring the upgrade complete.
|
||||
|
||||
Recorded after session 2926de4e (2026-07-15) — 92-package apt upgrade on
|
||||
netbird-vps; mgmt crash-loop caught and fixed with `docker restart
|
||||
netbird-mgmt` after ~30s.
|
||||
tags:
|
||||
- runbook
|
||||
- netbird
|
||||
- docker
|
||||
- gotcha
|
||||
|
||||
4
web/.prettierignore
Normal file
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
build/
|
||||
package-lock.json
|
||||
9
web/.prettierrc.json
Normal file
9
web/.prettierrc.json
Normal 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
39
web/eslint.config.js
Normal 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: '^_' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
4030
web/package-lock.json
generated
4030
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,29 +6,46 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"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-check": "^4.0.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0"
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@surdeddd/wmkit": "^0.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"marked": "^18.0.5",
|
||||
"svelte-splitpanes": "^8.0.12",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uplot": "^1.6.32"
|
||||
}
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Chat from './pages/Chat.svelte'
|
||||
import Overview from './pages/Overview.svelte'
|
||||
import KnowledgeBase from './pages/KnowledgeBase.svelte'
|
||||
import Ops from './pages/Ops.svelte'
|
||||
import Signals from './pages/Signals.svelte'
|
||||
import EntityDetail from './pages/EntityDetail.svelte'
|
||||
import Knowledge from './pages/Knowledge.svelte'
|
||||
import Learning from './pages/Learning.svelte'
|
||||
import Config from './pages/Config.svelte'
|
||||
import { newChat } from '$lib/stores/chat'
|
||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
import Desktop from '$lib/components/desktop-shell/Desktop.svelte'
|
||||
import { subscribeContext } from '$lib/stores/context'
|
||||
import { openAppWindow, openEntityWindow } from '$lib/stores/windows'
|
||||
import { isConfigured } from '$lib/config'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import { onMount } from 'svelte'
|
||||
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { VERSION } from '$lib/version'
|
||||
import { Toaster } from '$lib/components/ui/sonner'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
|
||||
|
||||
let page = $state('overview')
|
||||
let routeParam = $state('')
|
||||
let drawerOpen = $state(false)
|
||||
let configured = $state(isConfigured())
|
||||
|
||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||
const openSignals = $derived(openSignalCount($summary))
|
||||
// Old hash routes (#/kb, #/entity/<slug>, ...) from the sidebar-shell era —
|
||||
// translated into opening the equivalent window once, then cleared, so
|
||||
// links/bookmarks from before the desktop redesign keep working without
|
||||
// reintroducing a router.
|
||||
const LEGACY_APP_ROUTES: Record<string, string> = {
|
||||
overview: 'tasks',
|
||||
chat: 'tasks',
|
||||
kb: 'kb',
|
||||
entities: 'kb',
|
||||
graph: 'kb',
|
||||
ops: 'ops',
|
||||
signals: 'signals',
|
||||
knowledge: 'knowledge',
|
||||
learning: 'learning'
|
||||
}
|
||||
|
||||
function resolveLegacyHash() {
|
||||
const path = location.hash.slice(2)
|
||||
if (!path) return
|
||||
const [head, ...rest] = path.split('/')
|
||||
if (head === 'entity' && rest.length) {
|
||||
openEntityWindow(rest.join('/'))
|
||||
} else if (LEGACY_APP_ROUTES[head]) {
|
||||
openAppWindow(LEGACY_APP_ROUTES[head])
|
||||
}
|
||||
history.replaceState(null, '', location.pathname + location.search)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (await processPendingCallback()) {
|
||||
@@ -47,21 +44,7 @@
|
||||
} else if (!configured) {
|
||||
if (await initOIDC()) configured = true
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const path = location.hash.slice(2) || 'overview'
|
||||
const [head, ...rest] = path.split('/')
|
||||
// Entities + Graph were merged into Knowledge Base — keep old links working.
|
||||
if (head === 'entities' || head === 'graph') {
|
||||
location.hash = '#/kb'
|
||||
return
|
||||
}
|
||||
page = head || 'overview'
|
||||
routeParam = rest.join('/')
|
||||
}
|
||||
sync()
|
||||
window.addEventListener('hashchange', sync)
|
||||
return () => window.removeEventListener('hashchange', sync)
|
||||
resolveLegacyHash()
|
||||
})
|
||||
|
||||
// Context (dashboard summary + approvals poll) and the SSE stream both
|
||||
@@ -70,23 +53,6 @@
|
||||
if (!configured) return
|
||||
return subscribeContext()
|
||||
})
|
||||
|
||||
function navigate(p: string) {
|
||||
location.hash = '#/' + p
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
toggleTheme()
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
|
||||
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if !configured}
|
||||
@@ -95,158 +61,6 @@
|
||||
onCancel={isConfigured() ? () => (configured = true) : undefined}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||
<Sidebar.Root collapsible="icon" variant="inset">
|
||||
<Sidebar.Header>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
onclick={() => navigate('overview')}
|
||||
tooltipContent={`Oikos ${VERSION}`}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" aria-hidden="true" role="img">
|
||||
<title>Oikos</title>
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
<div class="group-data-[collapsible=icon]:hidden px-2.5 pb-1">
|
||||
<span class="text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||
onclick={() => { newChat(); navigate('chat') }}
|
||||
tooltipContent="New task"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<PlusIcon />
|
||||
<span>New task</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
|
||||
onclick={() => navigate(item.id)}
|
||||
tooltipContent={item.label}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{#if item.badge?.()}
|
||||
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (drawerOpen = true)}
|
||||
title="Chat over the current page without navigating away"
|
||||
>
|
||||
<PanelRightIcon />
|
||||
<span>Chat drawer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={cycleTheme}
|
||||
title="Cycle theme"
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>{THEME_LABELS[getTheme()]}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (configured = false)}
|
||||
title="Server connection settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
<span>Connection</span>
|
||||
</Button>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
||||
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
{#if page === 'chat'}
|
||||
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
|
||||
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
|
||||
{truncateMiddle(goalText, 100)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
|
||||
{/if}
|
||||
</header>
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
<Overview />
|
||||
{:else if page === 'kb'}
|
||||
<KnowledgeBase />
|
||||
{:else if page === 'entity' && routeParam}
|
||||
<EntityDetail slug={routeParam} />
|
||||
{:else if page === 'ops'}
|
||||
<Ops />
|
||||
{:else if page === 'signals'}
|
||||
<Signals />
|
||||
{:else if page === 'knowledge'}
|
||||
<Knowledge />
|
||||
{:else if page === 'learning'}
|
||||
<Learning />
|
||||
{:else}
|
||||
<Chat />
|
||||
{/if}
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<Sheet.Root bind:open={drawerOpen}>
|
||||
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Nomos chat</Sheet.Title>
|
||||
<Sheet.Description>Persistent chat drawer</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full flex-col">
|
||||
<Chat showRail={false} />
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<Toaster />
|
||||
<Desktop />
|
||||
{/if}
|
||||
|
||||
@@ -242,6 +242,45 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own
|
||||
card/border/ring tokens instead of an imported wmkit theme, so windows
|
||||
follow the terracotta/dark theme toggle for free. */
|
||||
[data-wm-desktop] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-wm-window] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px oklch(0 0 0 / 0.18);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-wm-window][data-wm-focused] {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 12px 32px oklch(0 0 0 / 0.28);
|
||||
}
|
||||
|
||||
[data-wm-window][data-wm-dragging],
|
||||
[data-wm-window][data-wm-resizing] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-wm-window][data-wm-stage='minimized'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-wm-resize] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
@@ -250,3 +289,32 @@ a {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* svelte-splitpanes theming (TaskContextPanel, SessionChatWindow rail) —
|
||||
mapped onto the app's border/primary tokens instead of the library's
|
||||
default-theme, so splitters follow the terracotta/dark theme toggle. */
|
||||
.splitpanes.oikos-theme .splitpanes__pane {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.splitpanes.oikos-theme .splitpanes__splitter {
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.splitpanes.oikos-theme .splitpanes__splitter:hover,
|
||||
.splitpanes.oikos-theme .splitpanes__splitter.splitpanes__splitter__active {
|
||||
background-color: color-mix(in oklab, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.oikos-theme.splitpanes--horizontal > .splitpanes__splitter {
|
||||
height: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.oikos-theme.splitpanes--vertical > .splitpanes__splitter {
|
||||
width: 6px;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchWithAuth } from './config'
|
||||
import type { ChatEvent, MessageContent } from './types'
|
||||
|
||||
export type { ChatEvent }
|
||||
|
||||
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
||||
// origin (relative vs. configured apiUrl) fresh on every call via
|
||||
@@ -29,7 +32,7 @@ export interface Message {
|
||||
id: string
|
||||
session_id: string
|
||||
role: string
|
||||
content: any
|
||||
content: MessageContent | string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -47,6 +50,21 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
// null distinguishes "session doesn't exist" (404 — the session was deleted,
|
||||
// or a persisted/deep-linked window id was never valid) from a transient
|
||||
// fetch failure, which should keep returning []/retrying rather than
|
||||
// permanently flip a window into a "not found" state. Only the initial
|
||||
// per-window load (chat.ts's loadSessionChat) needs this distinction — the
|
||||
// polling loops keep using fetchMessages, where swallowing a blip into []
|
||||
// and trying again next tick is the right behavior.
|
||||
export async function fetchMessagesOrNotFound(sessionId: string): Promise<Message[] | null> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
return res.ok
|
||||
@@ -102,13 +120,6 @@ export async function answerQuestion(sessionId: string, questionId: string, answ
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string
|
||||
data: any
|
||||
session_id?: string
|
||||
iteration?: number
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
@@ -217,6 +228,24 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// The entities endpoint caps at 200/page — the Knowledge Base wants the
|
||||
// whole set (it filters by type/search client-side now instead of scoping
|
||||
// the fetch server-side), so page through via cursor until exhausted.
|
||||
export async function fetchAllEntities(): Promise<Entity[]> {
|
||||
const all: Entity[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: '200' })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
||||
if (!res.ok) break
|
||||
const data = await res.json()
|
||||
all.push(...(data.items ?? []))
|
||||
cursor = data.next_cursor ?? undefined
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
||||
|
||||
export interface EntityType {
|
||||
@@ -228,13 +257,33 @@ export interface EntityType {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type RelationshipCardinality = 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'
|
||||
|
||||
export interface RelationshipTypeDef {
|
||||
name: string
|
||||
inverse?: string | null
|
||||
source_type: string
|
||||
target_type: string
|
||||
cardinality: RelationshipCardinality
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface Ontology {
|
||||
entityTypes: EntityType[]
|
||||
relationshipTypes: RelationshipTypeDef[]
|
||||
}
|
||||
|
||||
export async function fetchOntology(): Promise<Ontology> {
|
||||
const res = await fetchWithAuth(`${API}/ontology`)
|
||||
if (!res.ok) return { entityTypes: [], relationshipTypes: [] }
|
||||
const data = await res.json()
|
||||
return { entityTypes: data.entity_types ?? [], relationshipTypes: data.relationship_types ?? [] }
|
||||
}
|
||||
|
||||
// The graph endpoint has no layer param, so callers build a type→layer map from
|
||||
// this to scope the graph client-side (the entities table filters server-side).
|
||||
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
||||
const res = await fetchWithAuth(`${API}/ontology`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.entity_types ?? []
|
||||
return (await fetchOntology()).entityTypes
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
@@ -351,21 +400,6 @@ export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
||||
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 {
|
||||
verb: string
|
||||
first_success: string | null
|
||||
@@ -493,6 +527,18 @@ export interface Relationship {
|
||||
valid_to?: string | null
|
||||
}
|
||||
|
||||
// Direct relationships of an entity, both directions — unlike fetchGraph's
|
||||
// blast_radius (which only walks outgoing edges, so it can never surface an
|
||||
// edge some other entity points at this one unless that entity is also
|
||||
// reachable going forward from here), this hits a dedicated endpoint that
|
||||
// matches on source_id OR target_id directly.
|
||||
export async function fetchEntityRelations(id: string): Promise<Relationship[]> {
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'
|
||||
|
||||
export interface GraphView {
|
||||
|
||||
61
web/src/lib/apps.test.ts
Normal file
61
web/src/lib/apps.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// apps.ts wires in every page component for real use, but that drags a
|
||||
// heavy transitive graph into a unit test for no benefit here (and one of
|
||||
// those pages imports svelte-sonner, which fails to resolve under vitest's
|
||||
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
|
||||
// care about the registry's own shape (ids, sizes, window-id helpers), so
|
||||
// stub the component imports out rather than pull all of that in.
|
||||
vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
|
||||
|
||||
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
|
||||
|
||||
describe('APPS registry', () => {
|
||||
it('has unique, non-empty ids', () => {
|
||||
const ids = APPS.map((a) => a.id)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ids) expect(id).not.toBe('')
|
||||
})
|
||||
|
||||
it('gives every app a positive default size', () => {
|
||||
for (const app of APPS) {
|
||||
expect(app.width).toBeGreaterThan(0)
|
||||
expect(app.height).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('is indexed by id in appById', () => {
|
||||
for (const app of APPS) {
|
||||
expect(appById.get(app.id)).toBe(app)
|
||||
}
|
||||
expect(appById.size).toBe(APPS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appWindowId / appIdFromWindowId', () => {
|
||||
it('round-trips an app id through its window id', () => {
|
||||
for (const app of APPS) {
|
||||
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for ids that are not app windows', () => {
|
||||
expect(appIdFromWindowId('session:abc-123')).toBeNull()
|
||||
expect(appIdFromWindowId('host:strong')).toBeNull()
|
||||
expect(appIdFromWindowId('new-task')).toBeNull()
|
||||
})
|
||||
|
||||
it('namespaces window ids so they cannot collide with entity slugs', () => {
|
||||
// Entity slugs are bare `type:identifier` strings (see windows.ts's
|
||||
// openEntityWindow) — app window ids must never look like one.
|
||||
for (const app of APPS) {
|
||||
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
130
web/src/lib/apps.ts
Normal file
130
web/src/lib/apps.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// The desktop's app registry — single source of truth for what shows up as
|
||||
// a desktop icon and what opens in its window. Adding a new app is one entry
|
||||
// here; nothing else needs to change (Desktop.svelte renders icons from
|
||||
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
|
||||
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
|
||||
// App.svelte's hardcoded navItems array + if/else page branch, which required
|
||||
// touching three places (nav list, header title, main content branch) to add
|
||||
// one page.
|
||||
import type { Component } from 'svelte'
|
||||
import type { DashboardSummary } from '$lib/api'
|
||||
import { openSignalCount } from '$lib/stores/context'
|
||||
import Overview from '../pages/Overview.svelte'
|
||||
import KnowledgeBase from '../pages/KnowledgeBase.svelte'
|
||||
import Ops from '../pages/Ops.svelte'
|
||||
import Signals from '../pages/Signals.svelte'
|
||||
import Knowledge from '../pages/Knowledge.svelte'
|
||||
import Learning from '../pages/Learning.svelte'
|
||||
import Settings from '../pages/Settings.svelte'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
|
||||
export interface AppDef {
|
||||
id: string
|
||||
title: string
|
||||
icon: Component
|
||||
component: Component
|
||||
width: number
|
||||
height: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
// Pure function over the shared dashboard summary — used for both the
|
||||
// desktop icon's badge and the taskbar button's badge, so a new app that
|
||||
// wants one just supplies this instead of each surface reimplementing it.
|
||||
badge?: (summary: DashboardSummary | null) => number
|
||||
}
|
||||
|
||||
export const APPS: AppDef[] = [
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
icon: ListTodoIcon,
|
||||
component: Overview,
|
||||
width: 960,
|
||||
height: 680,
|
||||
minWidth: 480,
|
||||
minHeight: 420
|
||||
},
|
||||
{
|
||||
id: 'kb',
|
||||
title: 'Knowledge Base',
|
||||
icon: DatabaseIcon,
|
||||
component: KnowledgeBase,
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 520,
|
||||
minHeight: 420
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
title: 'Operations',
|
||||
icon: ShieldCheckIcon,
|
||||
component: Ops,
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => s?.approvals_pending ?? 0
|
||||
},
|
||||
{
|
||||
id: 'signals',
|
||||
title: 'Signals',
|
||||
icon: SirenIcon,
|
||||
component: Signals,
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => openSignalCount(s)
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
title: 'Knowledge',
|
||||
icon: SearchIcon,
|
||||
component: Knowledge,
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
},
|
||||
{
|
||||
id: 'learning',
|
||||
title: 'Learning',
|
||||
icon: TrendingUpIcon,
|
||||
component: Learning,
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
icon: SettingsIcon,
|
||||
component: Settings,
|
||||
width: 640,
|
||||
height: 480,
|
||||
minWidth: 480,
|
||||
minHeight: 360
|
||||
}
|
||||
]
|
||||
|
||||
export const appById = new Map(APPS.map((a) => [a.id, a]))
|
||||
|
||||
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
|
||||
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
|
||||
// for task chat windows (see windows.ts), anything else is an entity slug.
|
||||
const APP_PREFIX = 'app:'
|
||||
|
||||
export function appWindowId(id: string): string {
|
||||
return `${APP_PREFIX}${id}`
|
||||
}
|
||||
|
||||
export function appIdFromWindowId(windowId: string): string | null {
|
||||
return windowId.startsWith(APP_PREFIX) ? windowId.slice(APP_PREFIX.length) : null
|
||||
}
|
||||
@@ -3,32 +3,26 @@
|
||||
// which lumps very different things (an LXC and a DNS record and a storage
|
||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||
// `domain` field instead, which already draws these lines; this just
|
||||
// groups the 9 domains into 6 browsing-sized buckets.
|
||||
import type { EntityFilters } from './api'
|
||||
|
||||
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
|
||||
|
||||
export const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'identity', label: 'Identity' },
|
||||
{ id: 'knowledge', label: 'Knowledge' }
|
||||
]
|
||||
// groups the domains into browsing-sized buckets. The Knowledge Base shows
|
||||
// every entity at once now (filtered by the type multiselect, not by a
|
||||
// fetch-time category), but "fleet" still names the default type selection.
|
||||
export type Category = 'network' | 'fleet' | 'identity' | 'knowledge'
|
||||
|
||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||
// domain-registration are network-adjacent); `physical` folds into Fleet
|
||||
// (ups/sensor/site support compute, browsing them separately fragments
|
||||
// "what's running where"). `meta` (the abstract root "entity" type) and
|
||||
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
|
||||
// domain-registration are network-adjacent); `physical`, `software`, and
|
||||
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
|
||||
// nest under the compute entity that provides them, and pools/volumes/
|
||||
// datasets nest under their compute entity or pool, all via EntityTable's
|
||||
// treegrid) — browsing them separately fragments "what's running where".
|
||||
// `meta` (the abstract root "entity" type) and `cognition` (see
|
||||
// KNOWLEDGE_TYPES below) are handled outside this map.
|
||||
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||
network: 'network',
|
||||
external: 'network',
|
||||
compute: 'fleet',
|
||||
physical: 'fleet',
|
||||
software: 'services',
|
||||
storage: 'storage',
|
||||
software: 'fleet',
|
||||
storage: 'fleet',
|
||||
identity: 'identity'
|
||||
}
|
||||
|
||||
@@ -48,15 +42,3 @@ export function typeToCategory(type: string, domain: string): Category | undefin
|
||||
if (domain === 'cognition') return undefined
|
||||
return DOMAIN_TO_CATEGORY[domain]
|
||||
}
|
||||
|
||||
// Filter sets to fetch and merge for a category's table view. Most
|
||||
// categories are one or two `domain` values; Knowledge is a handful of
|
||||
// specific `type`s carved out of the (otherwise excluded) cognition domain.
|
||||
export function filtersForCategory(category: Category): EntityFilters[] {
|
||||
if (category === 'knowledge') {
|
||||
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
|
||||
}
|
||||
return Object.entries(DOMAIN_TO_CATEGORY)
|
||||
.filter(([, c]) => c === category)
|
||||
.map(([domain]) => ({ domain }))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
@@ -11,6 +11,9 @@
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
||||
let { entries }: { entries: ActivityEntry[] } = $props()
|
||||
|
||||
let expanded = $state(new Set<string>())
|
||||
|
||||
function toggle(id: string) {
|
||||
@@ -56,7 +59,7 @@
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $activityLog.length === 0}
|
||||
{#if entries.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
|
||||
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
|
||||
@@ -78,8 +81,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each $activityLog as entry, i (entry.id)}
|
||||
{@const isLast = i === $activityLog.length - 1}
|
||||
{#each entries as entry, i (entry.id)}
|
||||
{@const isLast = i === entries.length - 1}
|
||||
{@const icon = typeIcon(entry.type)}
|
||||
{@const isOpen = expanded.has(entry.id)}
|
||||
{@const time = formatTime(entry.timestamp)}
|
||||
@@ -100,7 +103,8 @@
|
||||
{:else if entry.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5" />
|
||||
{:else if icon}
|
||||
<svelte:component this={icon} class="size-3" />
|
||||
{@const IconComp = icon}
|
||||
<IconComp class="size-3" />
|
||||
{:else}
|
||||
<CircleDotIcon class="size-3" />
|
||||
{/if}
|
||||
|
||||
436
web/src/lib/components/ChatThread.svelte
Normal file
436
web/src/lib/components/ChatThread.svelte
Normal file
@@ -0,0 +1,436 @@
|
||||
<script lang="ts">
|
||||
// Pure prop-driven transcript + input — no store imports. Both the main
|
||||
// Chat page (singleton "current session" stores) and a floating task
|
||||
// window (its own per-session store bundle from chat.ts's chatFor) render
|
||||
// through this, so the message-bubble/markdown styling lives in one place
|
||||
// instead of being copy-pasted between the two.
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { ChatMessage } from '$lib/stores/chat'
|
||||
|
||||
let {
|
||||
messages,
|
||||
streaming,
|
||||
connectionState,
|
||||
error = null,
|
||||
chatErrors = [],
|
||||
onSend,
|
||||
onCancel,
|
||||
onReconnect,
|
||||
onDismissError,
|
||||
suggestions = []
|
||||
}: {
|
||||
messages: ChatMessage[]
|
||||
streaming: boolean
|
||||
connectionState: 'connected' | 'disconnected' | 'reconnecting'
|
||||
error?: string | null
|
||||
chatErrors?: { id: string; message: string; action?: string }[]
|
||||
onSend: (text: string) => void
|
||||
onCancel: () => void
|
||||
onReconnect: () => void
|
||||
onDismissError: (id: string) => void
|
||||
suggestions?: string[]
|
||||
} = $props()
|
||||
|
||||
let input = $state('')
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
// Resizable input area — drag the splitter above it to grow the textarea,
|
||||
// capped so it can't swallow the whole thread. Both the minimum and the
|
||||
// default are exactly one line: measured from the textarea's own
|
||||
// line-height/padding/border rather than hardcoded, so it stays correct if
|
||||
// that styling ever changes.
|
||||
let threadHeight = $state(0)
|
||||
let textareaRef = $state<HTMLTextAreaElement | null>(null)
|
||||
let inputWrapperRef = $state<HTMLDivElement | null>(null)
|
||||
let oneLinePx = $state(64)
|
||||
$effect(() => {
|
||||
if (!textareaRef || !inputWrapperRef) return
|
||||
const taCs = getComputedStyle(textareaRef)
|
||||
const lineHeight = parseFloat(taCs.lineHeight)
|
||||
if (!Number.isFinite(lineHeight)) return
|
||||
const taBoxY =
|
||||
parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth)
|
||||
// The wrapper's own padding/border (space around the textarea, not part
|
||||
// of it) also has to fit inside the minimum, or the textarea gets
|
||||
// squeezed below one line once the pane is dragged down to it.
|
||||
const wrapperCs = getComputedStyle(inputWrapperRef)
|
||||
const wrapperBoxY =
|
||||
parseFloat(wrapperCs.paddingTop) +
|
||||
parseFloat(wrapperCs.paddingBottom) +
|
||||
parseFloat(wrapperCs.borderTopWidth) +
|
||||
parseFloat(wrapperCs.borderBottomWidth)
|
||||
oneLinePx = lineHeight + taBoxY + wrapperBoxY
|
||||
})
|
||||
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
|
||||
|
||||
let inputSize = $state(12)
|
||||
let inputSizeDefaulted = false
|
||||
$effect(() => {
|
||||
if (!inputSizeDefaulted && threadHeight > 0) {
|
||||
inputSize = inputMinSize
|
||||
inputSizeDefaulted = true
|
||||
}
|
||||
})
|
||||
|
||||
function isNearBottom(): boolean {
|
||||
if (!container) return true
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
return scrollHeight - scrollTop - clientHeight < 80
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrolledUp = !isNearBottom()
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
|
||||
$effect(() => {
|
||||
void messages
|
||||
if (streaming || !scrolledUp) {
|
||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
}
|
||||
})
|
||||
|
||||
function render(text: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
input = ''
|
||||
scrolledUp = false
|
||||
onSend(text)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
function ask(q: string) {
|
||||
if (streaming) return
|
||||
onSend(q)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-col items-center gap-6 pt-24 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<AgentIndicator
|
||||
active={streaming || $activityLog.some((e) => e.status === 'running')}
|
||||
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||||
{error}
|
||||
/>
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}>Reconnect</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => onDismissError(err.id)}>{err.action}</Button>
|
||||
{/if}
|
||||
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Pane>
|
||||
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
|
||||
<form
|
||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:ref={textareaRef}
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
onclick={onCancel}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<SquareIcon class="size-3.5" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
<CornerDownLeftIcon class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Art Nouveau chat styling ── */
|
||||
|
||||
/* Assistant message wrapper */
|
||||
.assistant-msg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* User message — soft terracotta bubble, gentle lift */
|
||||
.user-msg {
|
||||
box-shadow: 0 1px 8px -4px var(--primary);
|
||||
}
|
||||
|
||||
/* Prose overrides */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.prose-chat :global(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose-chat :global(li::marker) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(pre)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
font-size: 1.03em;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(> h1:first-child),
|
||||
.prose-chat :global(> h2:first-child),
|
||||
.prose-chat :global(> h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose-chat :global(h1)::after,
|
||||
.prose-chat :global(h2)::after,
|
||||
.prose-chat :global(h3)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 2px;
|
||||
margin-top: 4px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(to right, var(--primary), transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(th) {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(blockquote)::before {
|
||||
content: '“';
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
top: -0.35rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary);
|
||||
opacity: 0.6;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prose-chat :global(hr) {
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 0.75rem 0;
|
||||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||||
}
|
||||
|
||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||
terracotta accent stay meaningful (code, headings, links). */
|
||||
.prose-chat :global(strong) {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-chat :global(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Input area ornament */
|
||||
.input-ornament::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
@@ -15,11 +15,14 @@
|
||||
children: Snippet
|
||||
} = $props()
|
||||
|
||||
let open = $state(defaultOpen)
|
||||
let open = $state(false)
|
||||
$effect(() => {
|
||||
open = defaultOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50">
|
||||
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
@@ -27,7 +30,7 @@
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<div class="border-t px-2.5 py-2">
|
||||
<div class="border-t px-2 py-1.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
fetchEntity,
|
||||
fetchGraph,
|
||||
fetchEntityRelations,
|
||||
fetchMetrics,
|
||||
fetchEntityEvents,
|
||||
fetchEntitySignals,
|
||||
@@ -58,6 +58,15 @@
|
||||
let actingSignal = $state<string | null>(null)
|
||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
||||
|
||||
// fetchEntityRelations already scopes to edges incident to this entity
|
||||
// (source OR target = entity, both directions), so a plain split by which
|
||||
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
||||
// sneaking into either group.
|
||||
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
|
||||
const incomingRelations = $derived(
|
||||
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
||||
)
|
||||
|
||||
async function load(s: string) {
|
||||
loading = true
|
||||
entity = await fetchEntity(s)
|
||||
@@ -65,8 +74,8 @@
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
|
||||
fetchGraph({ root: entity.id, depth: 1 }),
|
||||
const [rel, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
|
||||
fetchEntityRelations(entity.id),
|
||||
fetchMetrics(entity.id),
|
||||
fetchEntityEvents(entity.id),
|
||||
fetchEntitySignals(entity.id),
|
||||
@@ -77,7 +86,7 @@
|
||||
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
||||
fetchAudit({ entity_id: entity.id, limit: 50 })
|
||||
])
|
||||
relations = graphView?.edges ?? []
|
||||
relations = rel
|
||||
metrics = m
|
||||
events = ev
|
||||
signals = sig
|
||||
@@ -235,7 +244,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
|
||||
<div class="flex h-full flex-col gap-1.5 overflow-y-auto p-2">
|
||||
{#if loading}
|
||||
<Skeleton class="h-6 w-48" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
@@ -378,26 +387,49 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet relationsContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
{:else}
|
||||
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet relationRow(rel: Relationship)}
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{/each}
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet relationsContent()}
|
||||
{#if outgoingRelations.length === 0 && incomingRelations.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if outgoingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each outgoingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if incomingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each incomingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet metricsContent()}
|
||||
{#if metrics.length}
|
||||
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
@@ -547,7 +579,7 @@
|
||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
|
||||
{ key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent },
|
||||
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { typeToCategory, type Category } from '$lib/categories'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allNodeTypes: string[]
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
@@ -16,7 +14,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
category,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
@@ -24,11 +21,12 @@
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
activeNodeTypes = $bindable(new Set<string>()),
|
||||
// Owned by the parent (shared with the entity table's type filter) —
|
||||
// this graph only reads it to decide what's in focus, never writes it.
|
||||
activeNodeTypes,
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
category: Category
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
@@ -39,7 +37,7 @@
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes?: Set<string>
|
||||
activeNodeTypes: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
@@ -59,19 +57,20 @@
|
||||
type: string
|
||||
}
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — see
|
||||
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
|
||||
// (also covers the per-relationship-type arrow markers below, which were
|
||||
// keyed only by type name and would collide the same way across two
|
||||
// mounted EntityGraph instances).
|
||||
const uid = crypto.randomUUID().slice(0, 8)
|
||||
const dotGridId = `dot-grid-${uid}`
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → browsing category, so the graph can be scoped client-side (the
|
||||
// graph endpoint itself has no category/domain param). Value is undefined
|
||||
// for types deliberately excluded from every category (e.g. execution/
|
||||
// check/task — see categories.ts); the key is still present so inCategory
|
||||
// can tell "excluded on purpose" apart from "not in the ontology at all."
|
||||
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
@@ -101,7 +100,7 @@
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
||||
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
@@ -111,46 +110,7 @@
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
// Node belongs to the active category? Types the ontology never returned
|
||||
// at all fall back to visible (so a missing entry never blanks the
|
||||
// graph); types the ontology returned but categories.ts deliberately
|
||||
// excludes (key present, value undefined) do not.
|
||||
function inCategory(type: string): boolean {
|
||||
if (!typeCategory.has(type)) return true
|
||||
return typeCategory.get(type) === category
|
||||
}
|
||||
|
||||
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
|
||||
// default init spreads those via a spiral centered on the ORIGIN — not
|
||||
// (width/2, height/2) — while the x/y centering forces below are
|
||||
// deliberately weak (0.04, so they don't fight the link/collide layout).
|
||||
// Together that meant the cluster could settle noticeably off-origin
|
||||
// instead of centered. Fixed by explicitly fitting the viewport to the
|
||||
// node bounding box once the simulation settles, rather than relying on
|
||||
// the force balance to land on center by itself.
|
||||
function fitToView() {
|
||||
const placed = nodes.filter((n) => n.x != null && n.y != null)
|
||||
if (!placed.length) return
|
||||
const xs = placed.map((n) => n.x as number)
|
||||
const ys = placed.map((n) => n.y as number)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const pad = 70
|
||||
const bw = Math.max(maxX - minX, 1)
|
||||
const bh = Math.max(maxY - minY, 1)
|
||||
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
|
||||
const cx = (minX + maxX) / 2
|
||||
const cy = (minY + maxY) / 2
|
||||
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
|
||||
}
|
||||
|
||||
// fit=false for passive background reloads (live entity/relationship
|
||||
// events) — those shouldn't yank the view out from under someone
|
||||
// actively panning/zooming. Fresh loads (mount, root/depth change,
|
||||
// reset, re-root) default to fit=true.
|
||||
async function load(fit = true) {
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
@@ -176,8 +136,8 @@
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active category.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
// Edge-type toggles default to everything present — node-type toggles
|
||||
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
@@ -193,17 +153,9 @@
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
.on('end', () => {
|
||||
if (fit) fitToView()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||
// Re-derive the active node types now that category membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
@@ -214,17 +166,11 @@
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the category perspective changes, reset the node-type toggles to it.
|
||||
$effect(() => {
|
||||
category
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load(false)
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -266,14 +212,11 @@
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active category.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allNodeTypes,
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
@@ -288,21 +231,21 @@
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = in the active category AND its node-type toggle is on — these
|
||||
// are what the category tab is "about."
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||
// visible — same control the entity table filters its rows by.
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross category lines (a service sits on
|
||||
// a network, uses storage, runs on an lxc — different categories under
|
||||
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
||||
// left focus nodes looking like disconnected dots. Rooted views (the user
|
||||
// is exploring out from one entity) pull in 1-hop neighbors of any
|
||||
// category, dimmed, so the edges — and what they connect to — stay
|
||||
// visible. Unscoped "browse the whole category" views (no root) skip
|
||||
// this: with ~50 focus nodes that touch nearly everything, 1-hop
|
||||
// expansion floods in most of the graph (measured: 417 of 479 total
|
||||
// entities for an unrooted Fleet view) — worse than the isolated-dot
|
||||
// problem it was meant to fix. There, same-category-only edges stay.
|
||||
// Real infra relationships mostly cross type lines (a service sits on a
|
||||
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||
// other end isn't in the active type set left focus nodes looking like
|
||||
// disconnected dots. Rooted views (the user is exploring out from one
|
||||
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||
// edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
@@ -452,7 +395,7 @@
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#each allRelTypes as type}
|
||||
@@ -461,7 +404,7 @@
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
@@ -472,25 +415,31 @@
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const curve = Math.min(len * 0.15, 40)}
|
||||
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
|
||||
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
|
||||
{@const cdx = t.x - cx}
|
||||
{@const cdy = t.y - cy}
|
||||
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (dx / len) * tr}
|
||||
{@const ey = t.y - (dy / len) * tr}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={ex}
|
||||
y2={ey}
|
||||
{@const ex = t.x - (cdx / clen) * tr}
|
||||
{@const ey = t.y - (cdy / clen) * tr}
|
||||
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
|
||||
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
|
||||
<path
|
||||
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||
fill="none"
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
</path>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={(s.x + ex) / 2}
|
||||
y={(s.y + ey) / 2 - 4}
|
||||
x={mx}
|
||||
y={my - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
|
||||
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
|
||||
|
||||
// Lets a relation click inside the sheet drill into that entity in place,
|
||||
// without closing/reopening. Resets to the externally-requested slug
|
||||
// whenever the caller opens the sheet on a different entity.
|
||||
let currentSlug = $state(slug)
|
||||
$effect(() => {
|
||||
currentSlug = slug
|
||||
})
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Description>Entity detail panel</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
{#if currentSlug}
|
||||
{#key currentSlug}
|
||||
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
|
||||
{/key}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -7,22 +7,44 @@
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
entities,
|
||||
loading,
|
||||
selectedSlug = null,
|
||||
onSelect
|
||||
onSelect,
|
||||
childToParent = null
|
||||
}: {
|
||||
entities: Entity[]
|
||||
loading: boolean
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
// child entity slug -> parent entity slug, derived from the ontology
|
||||
// graph (arbitrary relationship types, not a fixed list — see
|
||||
// KnowledgeBase.svelte). When set, rows nest under their parent —
|
||||
// possibly several levels deep (host -> lxc -> service) — instead of
|
||||
// rendering flat. Since the parent for a given child can come from
|
||||
// whichever relationship happened to be processed last, a cycle across
|
||||
// relationship types isn't structurally impossible; `row` tracks the
|
||||
// ancestor chain and drops a child that would re-enter it, rather than
|
||||
// recursing forever.
|
||||
childToParent?: Map<string, string> | null
|
||||
} = $props()
|
||||
|
||||
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
||||
let sortKey = $state<SortKey>('slug')
|
||||
let sortDir = $state<'asc' | 'desc'>('asc')
|
||||
let collapsedNodes = $state<Set<string>>(new Set())
|
||||
|
||||
function toggleNode(slug: string, e: Event) {
|
||||
e.stopPropagation()
|
||||
const next = new Set(collapsedNodes)
|
||||
if (next.has(slug)) next.delete(slug)
|
||||
else next.add(slug)
|
||||
collapsedNodes = next
|
||||
}
|
||||
|
||||
function sortBy(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
@@ -52,6 +74,36 @@
|
||||
return sorted
|
||||
})
|
||||
|
||||
// ─── treegrid grouping: nest entities under their parent (per
|
||||
// childToParent — host->lxc via `hosts`, lxc/vm/host->service via
|
||||
// `provides`, chained to whatever depth the relationships form). An entity
|
||||
// whose parent got filtered out of `entities` (e.g. by the type dropdown)
|
||||
// has no parent row to nest under, so it falls back to rendering top-level
|
||||
// rather than disappearing.
|
||||
const childrenByParent = $derived.by(() => {
|
||||
const map = new Map<string, Entity[]>()
|
||||
if (!childToParent) return map
|
||||
const visibleSlugs = new Set(entities.map((e) => e.slug))
|
||||
for (const e of sortedEntities) {
|
||||
const parentSlug = childToParent.get(e.slug)
|
||||
if (parentSlug && visibleSlugs.has(parentSlug)) {
|
||||
if (!map.has(parentSlug)) map.set(parentSlug, [])
|
||||
map.get(parentSlug)!.push(e)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const nestedSlugs = $derived.by(() => {
|
||||
const set = new Set<string>()
|
||||
for (const children of childrenByParent.values()) for (const c of children) set.add(c.slug)
|
||||
return set
|
||||
})
|
||||
|
||||
const topLevelEntities = $derived.by(() =>
|
||||
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
|
||||
)
|
||||
|
||||
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (!state) return 'outline'
|
||||
if (state === 'active' || state === 'healthy') return 'default'
|
||||
@@ -124,8 +176,70 @@
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="row"
|
||||
aria-level={level}
|
||||
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
||||
<span class="inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||
{#if children.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded text-muted-foreground hover:text-foreground"
|
||||
onclick={(e) => toggleNode(entity.slug, e)}
|
||||
aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`}
|
||||
>
|
||||
{#if collapsedNodes.has(entity.slug)}
|
||||
<ChevronRightIcon class="size-3.5" />
|
||||
{:else}
|
||||
<ChevronDownIcon class="size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{entity.slug}
|
||||
{#if children.length > 0}
|
||||
<span class="text-muted-foreground">({children.length})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
<Table.Cell>{entity.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.state}
|
||||
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
||||
{#each children as child (child.id)}
|
||||
{@render row(child, level + 1, ancestorsWithSelf)}
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{@render sortHead('slug', 'Slug')}
|
||||
@@ -136,35 +250,8 @@
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sortedEntities as entity (entity.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
<Table.Cell>{entity.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.state}
|
||||
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{#each topLevelEntities as entity (entity.id)}
|
||||
{@render row(entity, 1, new Set())}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
||||
|
||||
@@ -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}
|
||||
@@ -198,8 +198,14 @@
|
||||
const z = (s.z + tg.z) / 2
|
||||
const a = project(s.x, s.y!, z)
|
||||
const b = project(tg.x, tg.y!, z)
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.max(Math.hypot(dx, dy), 1)
|
||||
const curve = Math.min(len * 0.15, 40)
|
||||
const mx = (a.x + b.x) / 2 - (dy / len) * curve
|
||||
const my = (a.y + b.y) / 2 + (dx / len) * curve
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user