Compare commits
12 Commits
55781984c7
...
69964abe2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 69964abe2e | |||
| 6806fac5fd | |||
| 7dc1c1ae39 | |||
| 8709e01dcb | |||
| c96c795126 | |||
| 463bdacf5c | |||
| fb39a48bef | |||
| a2410cf9c2 | |||
| d2950dd09d | |||
| 0a3654b08f | |||
| c3973e7ac9 | |||
| e3a0326c78 |
@@ -13,13 +13,13 @@ service itself.
|
|||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
The homelab-context repo at `/opt/homelab-context/` is the single source of
|
The homelab-context repo at `/opt/homelab/` is the single source of
|
||||||
truth for:
|
truth for:
|
||||||
- Fleet topology (`inventory.yaml`)
|
- Fleet topology (`inventory.yaml`)
|
||||||
- Agent behaviour and conventions
|
- Agent behaviour and conventions
|
||||||
- Everything in this file
|
- Everything in this file
|
||||||
|
|
||||||
When in doubt, check `/opt/homelab-context/` first, or query the Oikos API/MCP
|
When in doubt, check `/opt/homelab/` first, or query the Oikos API/MCP
|
||||||
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
|
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
|
||||||
authoritative at runtime.
|
authoritative at runtime.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Oikos — the operating model
|
# Oikos — the operating model
|
||||||
|
|
||||||
Oikos (Greek: *household*) is the agent operating system layered on this
|
Oikos (Greek: *household*) is the agent operating system layered on this
|
||||||
repo. It is not new infrastructure: `inventory.yaml` is the kernel data
|
repo. It is not new infrastructure: `seeds/inventory.yaml` is the kernel data
|
||||||
structure, the `homelab` CLI and MCP server are the syscall surface, and
|
structure, the Oikos REST API and MCP server are the syscall surface, and
|
||||||
this page defines the rules everything above them follows.
|
this page defines the rules everything above them follows.
|
||||||
|
|
||||||
Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions:
|
Read this after [AGENTS.md](../AGENTS.md). Machine-readable companions:
|
||||||
@@ -100,16 +100,16 @@ via the API's `/api/v1/graph` endpoint, and the Mermaid export at
|
|||||||
|
|
||||||
The Oikos runtime was rewritten from Python to Go over 6 phases and is deployed
|
The Oikos runtime was rewritten from Python to Go over 6 phases and is deployed
|
||||||
in Docker on mac-mini. See
|
in Docker on mac-mini. See
|
||||||
[plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](../plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md)
|
[plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](../plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md)
|
||||||
for the full plan. The Python codebase has been removed; all functionality runs
|
for the full plan. The Python codebase has been removed; all functionality runs
|
||||||
in the Go binary.
|
in the Go binary.
|
||||||
|
|
||||||
**Phase 1 — Ontology + DB (DONE):**
|
**Phase 1 — Ontology + DB (DONE):**
|
||||||
- `migrations/` (001–011): TimescaleDB hypertables, entity_status, CAGGs,
|
- `migrations/` (001–020, forward-only): TimescaleDB hypertables, entity_status, CAGGs,
|
||||||
retention policies, knowledge entities with FTS. Forward-only, idempotent.
|
retention policies, knowledge entities with FTS. Idempotent.
|
||||||
- `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap +
|
- `seeds/{ontology,inventory,policy,knowledge}.yaml`: DB-native bootstrap +
|
||||||
DR export. Knowledge seed contains 36 documents, 6 investigations, and 12
|
DR export. Knowledge seed contents are not hardcoded here — count them
|
||||||
runbooks.
|
from the seed or query the DB.
|
||||||
- `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship
|
- `blast_radius()` SQL CTE, type hierarchy, abstract types, relationship
|
||||||
validation.
|
validation.
|
||||||
- Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`,
|
- Go packages: `internal/db/`, `internal/ontology/`, `internal/domain/`,
|
||||||
@@ -139,8 +139,8 @@ in the Go binary.
|
|||||||
|
|
||||||
**Phase 4 — Agent / Nomos (DONE):**
|
**Phase 4 — Agent / Nomos (DONE):**
|
||||||
- Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode
|
- Standalone Nomos MCP client binary (`cmd/nomos`) with gateway mode
|
||||||
(:8092). Structured queries + natural-language routing to 15 MCP tools.
|
(:8092). Structured queries + natural-language routing to the MCP tool
|
||||||
Agent activity logging on every tool call. No SSH keys.
|
list (see AGENTS.md §3). Agent activity logging on every tool call. No SSH keys.
|
||||||
- `nomos/` directory with config, SOUL.md, homelab-ops skill.
|
- `nomos/` directory with config, SOUL.md, homelab-ops skill.
|
||||||
- Nomos Docker service in `docker-compose.yml` (profile: full).
|
- Nomos Docker service in `docker-compose.yml` (profile: full).
|
||||||
- Go packages: `cmd/nomos/`, `compose/nomos/`.
|
- Go packages: `cmd/nomos/`, `compose/nomos/`.
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ current phase status). To add a new capability:
|
|||||||
## SQL conventions
|
## SQL conventions
|
||||||
|
|
||||||
- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec`
|
- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec`
|
||||||
annotations for sqlc
|
annotations for sqlc. Generated code in `internal/db/sqlcgen/` — never
|
||||||
|
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
|
||||||
|
- **sqlc is the default** for all DB access. Raw `pool.Query/Exec` with inline
|
||||||
|
SQL is a documented carve-out for cases sqlc can't express: `LISTEN`/`NOTIFY`,
|
||||||
|
dynamic WHERE-clause builders, `blast_radius()` (opaque return type), and
|
||||||
|
`COPY`. All other DB access should go through sqlc queries.
|
||||||
- Use `pgx/v5` driver. UUIDs use `pgtype.UUID`, timestamps use `time.Time`
|
- Use `pgx/v5` driver. UUIDs use `pgtype.UUID`, timestamps use `time.Time`
|
||||||
- CTEs for graph traversals (blast radius, dependency chains)
|
- CTEs for graph traversals (blast radius, dependency chains)
|
||||||
- CAGGs and retention policies for TimescaleDB hypertables
|
- CAGGs and retention policies for TimescaleDB hypertables
|
||||||
@@ -108,6 +113,23 @@ current phase status). To add a new capability:
|
|||||||
implement it in `internal/httpapi/impl.go`
|
implement it in `internal/httpapi/impl.go`
|
||||||
- Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format
|
- Problem+JSON errors via `internal/httpapi/problem.go` — RFC 9457 format
|
||||||
- Cursor pagination, If-Match/ETag, idempotency keys, SSE streaming
|
- Cursor pagination, If-Match/ETag, idempotency keys, SSE streaming
|
||||||
|
- **Non-OpenAPI routes carve-out:** ~10 routes are registered manually on
|
||||||
|
the chi router in `internal/httpapi/server.go` rather than generated from
|
||||||
|
`openapi.yaml`. These fall into three categories:
|
||||||
|
1. **Auth/infra** (`/healthz`, `/api/v1/auth/oidc-*`, `/oidc-callback`) —
|
||||||
|
must bypass the auth middleware or aren't JSON API endpoints.
|
||||||
|
2. **SSE override** (`/api/v1/events/stream`) — in the spec but
|
||||||
|
re-registered manually because the strict handler can't `Flush()` per
|
||||||
|
event.
|
||||||
|
3. **Ad-hoc aggregations** (`/api/v1/knowledge/recent`,
|
||||||
|
`/api/v1/knowledge/content/{id}`, `/api/v1/activity/recent`,
|
||||||
|
`/api/v1/activity/session/{id}`, `/api/v1/learning/timeline`,
|
||||||
|
`/api/v1/learning/trend`) — return derived/aggregate shapes that don't
|
||||||
|
map cleanly to a schema type. If one of these stabilizes, promote it
|
||||||
|
to `openapi.yaml` with a proper schema and migrate the `serve*`
|
||||||
|
function to a strict handler.
|
||||||
|
The full list with reasons is in the "Non-OpenAPI routes" comment block
|
||||||
|
at the top of `NewHandler` in `server.go`.
|
||||||
|
|
||||||
## Testing philosophy
|
## Testing philosophy
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,93 @@
|
|||||||
# Knowledge domain — schema
|
# Knowledge domain — schema
|
||||||
|
|
||||||
The knowledge domain is the durable, authoritative current-state documentation of the homelab: one
|
The knowledge domain is the durable, authoritative current-state documentation of the homelab:
|
||||||
page per node and per cross-cutting system, synthesized from live state and evidence. It answers
|
narrative for every node and cross-cutting system, synthesized from live state and evidence. It
|
||||||
"what exists and how does it work right now."
|
answers "what exists and how does it work right now."
|
||||||
|
|
||||||
It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the
|
It follows the [LLM Wiki layer model](../../shared/llm-wiki.md) and the
|
||||||
[writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md)
|
[writing-style](../../shared/writing-style.md) and [page-templates](../../shared/page-templates.md)
|
||||||
rules.
|
rules.
|
||||||
|
|
||||||
## The narrative / substrate split
|
## Source of truth — the database
|
||||||
|
|
||||||
The knowledge wiki is **narrative**. It sits alongside a **machine-readable substrate** that it
|
Per ADR 0003, the Postgres database is the single source of truth for all structured data **and**
|
||||||
describes but never contains. The split is load-bearing: several programs read the substrate at
|
narrative knowledge. The narrative/substrate split of the Python era is gone: the DB holds both the
|
||||||
fixed paths, so the wiki reorganization never moves it.
|
structured graph (entities, relationships, status, metrics) and the narrative layer (documents,
|
||||||
|
investigations, runbooks) in the `knowledge_entities` table.
|
||||||
|
|
||||||
| Layer | Location | Consumed by |
|
| Concern | Where it lives | How it gets there |
|
||||||
|-------|----------|-------------|
|
|---------|----------------|-------------------|
|
||||||
| Substrate — source of truth | `inventory.yaml` (root) | MCP server, `homelab` CLI, `oikos/` scheduler/drift/relations/gen-topology |
|
| Knowledge content — documents, investigations, runbooks | `knowledge_entities` table (rows linked to `entities` via `documents` / `about` edges) | Seeded from `seeds/knowledge.yaml` at deploy; mutated at runtime via the API |
|
||||||
| Substrate — generated host records | `inventory.yaml` (root) | Go `internal/mcp/` server, `bin/homelab`; the single source of truth |
|
| Seed manifest (bootstrap + DR) | `seeds/knowledge.yaml` | Hand-edited or regenerated; ingested idempotently (content-hashed via `seed_versions`) |
|
||||||
| Substrate — kernel + context cards | `oikos/` (code, `oikos/cards/`, `oikos/state.json`) | MCP `explain`, scheduler |
|
| Structured graph — hosts, services, entity types, relationships | `entities`, `relationships`, `entity_types` tables | Seeded from `seeds/{ontology,inventory}.yaml`; mutated via API/MCP |
|
||||||
| Narrative — synthesized wiki | `archive/knowledge/{hosts,containers,vms,infrastructure}/` | humans, agents via MCP `get_page` / `search_docs` |
|
| Archived narrative wiki (read-only history) | `archive/knowledge/` | Frozen 2026-07-07 when the DB became source of truth |
|
||||||
| Evidence — immutable sources | `knowledge/sources/` (references + investigations) | synthesis into wiki pages |
|
|
||||||
|
|
||||||
## Wiki pages
|
### Seed ingest
|
||||||
|
|
||||||
- **Node pages** (`archive/knowledge/containers/<id>-<name>.md`, `.../vms/<id>-<name>.md`,
|
`seeds/knowledge.yaml` has three top-level lists — `documents`, `investigations`, `runbooks` — each
|
||||||
`.../hosts/<name>.md`) follow the container/host template in
|
entry carrying `slug`, `title`, `content` (markdown), and tags. `internal/knowledge/seed.go`
|
||||||
[page-templates.md](../../shared/page-templates.md): opening definition, `## At a glance`,
|
ingests each entry by:
|
||||||
`## Role`, service/port map, storage, auto-deploy, `## Related`, `## Changelog`.
|
|
||||||
- **Cross-cutting pages** (`archive/knowledge/infrastructure/<topic>.md`) follow the cross-cutting
|
1. `getOrCreateEntity` — ensures the slug exists in `entities` (type `document` / `investigation` /
|
||||||
template: `## Why`, `## Components`, `## How to apply`, `## Gotchas`, `## Related`, `## Changelog`.
|
`runbook`).
|
||||||
- Each `inventory.yaml` host entry carries a `doc_page:` field pointing at its narrative page.
|
2. `upsertKnowledgeEntity` — writes the markdown body into `knowledge_entities`, keyed by
|
||||||
Changing where a page lives means updating that field (read by `bin/homelab`).
|
`content_hash` so re-ingest is a no-op when nothing changed.
|
||||||
|
3. `createEdge` — links the knowledge entity to its subject(s) via `documents` (for `document`) or
|
||||||
|
`about` (for `investigation`) edges. Runbooks bind to an `entity_type` via `applies_to_type`
|
||||||
|
rather than to a single entity.
|
||||||
|
|
||||||
|
### Runtime mutation
|
||||||
|
|
||||||
|
Agents register or update knowledge through the API, not by editing the seed:
|
||||||
|
|
||||||
|
- `POST /api/v1/knowledge/{entity_slug}` — upsert a document/investigation on an entity
|
||||||
|
(`upsert_knowledge` MCP tool).
|
||||||
|
- `update_entity_attributes` — merge a discovered fact (IP, version, port) into an entity.
|
||||||
|
- `create_relationship` — record a discovered edge (`depends-on`, `hosts`, `routes-to`).
|
||||||
|
|
||||||
|
> **Export gap.** `oikos export` regenerates `seeds/{ontology,inventory,policy}.yaml` from the DB
|
||||||
|
> for version control, but **not** `seeds/knowledge.yaml`. Knowledge added via the API today lives
|
||||||
|
> only in the DB until someone hand-edits the seed. Tracked as a follow-up.
|
||||||
|
|
||||||
|
## Knowledge kinds
|
||||||
|
|
||||||
|
- **Documents** (`document` entities, linked via `documents` edges) — node and cross-cutting
|
||||||
|
narrative pages. Carry `at_glance` (structured attributes) and a parsed `changelog`. Follow the
|
||||||
|
container / cross-cutting templates in [page-templates.md](../../shared/page-templates.md).
|
||||||
|
- **Investigations** (`investigation` entities, linked via `about` edges) — incident evidence,
|
||||||
|
written once at incident time. Sections: `## Summary`, `## Timeline`, `## Root cause`,
|
||||||
|
`## Mitigations applied`, `## Open questions`.
|
||||||
|
- **Runbooks** (`runbook` entities, bound by `applies_to_type`) — repeatable procedures. Carry
|
||||||
|
`risk_class` and a JSON-schema-validated `procedure`. **Runbooks also live as `SKILL.md` files
|
||||||
|
under `.agents/skills/<name>/`** — the DB row is the policy/lifecycle framing, the SKILL.md is
|
||||||
|
the executable procedure the agent loads. See
|
||||||
|
[the operations schema](../operations/schema.md).
|
||||||
|
|
||||||
## The two logs
|
## The two logs
|
||||||
|
|
||||||
- The per-page **`## Changelog`** records infrastructure changes and is machine-parsed
|
- The per-document **`## Changelog`** records infrastructure changes to that node. Keep the
|
||||||
(`get_changelog`, the Oikos ledger). Keep the `### YYYY-MM-DD — title` shape.
|
`### YYYY-MM-DD — title` shape so the parsed `changelog` field stays structured.
|
||||||
- **`knowledge/log.md`** is append-only and records *documentation-maintenance* operations only
|
- **`archive/knowledge/log.md`** is the append-only record of *documentation-maintenance*
|
||||||
(restructures, source ingests, lint sweeps): `## [YYYY-MM-DD] <op> | <summary>`. It never
|
operations on the legacy wiki (restructures, source ingests, lint sweeps):
|
||||||
duplicates the Oikos change ledger (`oikos/ledger.py`).
|
`## [YYYY-MM-DD] <op> | <summary>`. It is frozen with the rest of `archive/knowledge/`; new
|
||||||
|
doc-maintenance operations are recorded in the DB audit trail instead.
|
||||||
|
|
||||||
|
## Querying knowledge
|
||||||
|
|
||||||
|
Use MCP, not grep:
|
||||||
|
|
||||||
|
- `search_knowledge(query)` — ILIKE search over documents, investigations, and runbooks in
|
||||||
|
`knowledge_entities`.
|
||||||
|
- `get_entity_knowledge(entity_slug)` — every document, investigation, and runbook linked to one
|
||||||
|
entity, in one call.
|
||||||
|
- `get_entity(slug)` / `get_relations(entity)` — the structured graph around an entity.
|
||||||
|
|
||||||
|
Grep the clone only when MCP is unreachable, and prefer `archive/knowledge/` for historical
|
||||||
|
narrative (it is not updated when the DB changes).
|
||||||
|
|
||||||
## Same-session update rule
|
## Same-session update rule
|
||||||
|
|
||||||
A change to a node updates every page that references it in the same session — the node page, the
|
A change to a node updates the DB in the same session — the entity's attributes, the relationships
|
||||||
section `README.md` table, the root `README.md`, the Caddy/DNS/ingress pages, the host page, and
|
that reference it, and any document whose `at_glance` or changelog should reflect the new state. See
|
||||||
`inventory.yaml`. See [page-templates.md](../../shared/page-templates.md#same-session-update-rule).
|
[page-templates.md](../../shared/page-templates.md#same-session-update-rule) for the legacy wiki
|
||||||
|
equivalent (now scoped to `archive/knowledge/` history).
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ follows [writing-style](../../shared/writing-style.md); runbooks and plans use t
|
|||||||
exception.
|
exception.
|
||||||
|
|
||||||
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
|
Where each kind lives: runbooks are skills under [`.agents/skills/`](../../skills/); operator
|
||||||
reference (command cheatsheet, enrollment, Hermes agent) lives in
|
reference (command cheatsheet, enrollment, Nomos agent) lives in
|
||||||
[`.agents/operations/`](../../operations/); investigations are sources under
|
[`.agents/operations/`](../../operations/); investigations are `investigation` entities in the DB
|
||||||
`knowledge/sources/investigations/`; plans stay in the repo-root `plans/` folder (below).
|
(historically `archive/knowledge/sources/investigations/`); plans stay in the repo-root `plans/`
|
||||||
|
folder (below).
|
||||||
|
|
||||||
## Plans always live in `plans/`
|
## Plans always live in `plans/`
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ message.** An agent drafting a plan:
|
|||||||
3. On completion, moves it to `plans/done/` and updates the index status.
|
3. On completion, moves it to `plans/done/` and updates the index status.
|
||||||
|
|
||||||
This is the single source for homelab design intent; keeping it in-repo means the plan is
|
This is the single source for homelab design intent; keeping it in-repo means the plan is
|
||||||
versioned, reviewable, and reachable by MCP `get_page`/`search_docs` like any other doc.
|
versioned, reviewable, and reachable by MCP `search_knowledge` like any other doc.
|
||||||
|
|
||||||
## Runbooks
|
## Runbooks
|
||||||
|
|
||||||
@@ -44,12 +45,14 @@ transition: "<from> -> <to>" # only for lifecycle runbooks
|
|||||||
|
|
||||||
## Investigations
|
## Investigations
|
||||||
|
|
||||||
Incident records live in `knowledge/sources/investigations/YYYY-MM-DD-slug.md` and are **evidence sources** — written
|
Incident records are `investigation` entities in the DB, linked to the entities they implicate via
|
||||||
once at incident time, then linked from the changelogs of the nodes they implicate. Sections:
|
`about` edges. They are **evidence sources** — written once at incident time, then back-linked from
|
||||||
`## Summary`, `## Timeline`, `## Root cause`, `## Mitigations applied`, `## Open questions`. Resolved
|
the changelogs of the nodes they implicate. Sections: `## Summary`, `## Timeline`, `## Root cause`,
|
||||||
incidents move to `knowledge/sources/investigations/archive/`.
|
`## Mitigations applied`, `## Open questions`. The legacy file-based investigations live at
|
||||||
|
`archive/knowledge/sources/investigations/` (frozen 2026-07-07); new investigations go in the DB.
|
||||||
|
|
||||||
## The operations log
|
## The operations log
|
||||||
|
|
||||||
`plans/log.md` and `knowledge/log.md` are append-only records of documentation operations on
|
`plans/log.md` is the append-only record of documentation operations on plans
|
||||||
those areas (`## [YYYY-MM-DD] <op> | <summary>`), distinct from the Oikos change ledger.
|
(`## [YYYY-MM-DD] <op> | <summary>`), distinct from the DB audit trail. The legacy
|
||||||
|
`archive/knowledge/log.md` is frozen with the rest of the archived wiki.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
|
|||||||
- `ras-mc-ctl --errors` — full event log
|
- `ras-mc-ctl --errors` — full event log
|
||||||
- `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power`
|
- `cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference` — should be `balance_power`
|
||||||
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
|
- `cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor` — should be `powersave`
|
||||||
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../archive/knowledge/investigations/archive/2026-04-21-hubris-crash-loop.md))
|
- `ls /sys/fs/pstore/ /var/lib/systemd/pstore/` — panic traces from a previous crash (empty for pure hardware hangs — see [investigation](../../archive/knowledge/investigations/2026-04-21-hubris-crash-loop.md))
|
||||||
|
|
||||||
## Fleet apt operations
|
## Fleet apt operations
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ the dpkg-interrupted recovery procedure specifically.
|
|||||||
|
|
||||||
See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
|
See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
|
||||||
section used to document is retired; the actual current interface is the
|
section used to document is retired; the actual current interface is the
|
||||||
33 MCP tools cataloged in [AGENTS.md](../../AGENTS.md#3-the-mcp-server) plus
|
MCP tool catalog in [AGENTS.md §3](../../AGENTS.md#3-the-mcp-server) plus
|
||||||
the REST API. Closest current equivalents for what used to live here:
|
the REST API. Closest current equivalents for what used to live here:
|
||||||
|
|
||||||
| Old `homelab` command | Current equivalent |
|
| Old `homelab` command | Current equivalent |
|
||||||
@@ -82,7 +82,7 @@ the REST API. Closest current equivalents for what used to live here:
|
|||||||
|
|
||||||
There is no separately-deployed "Oikos Console" anymore — the control-room
|
There is no separately-deployed "Oikos Console" anymore — the control-room
|
||||||
SPA (`web/`) is the operator dashboard, served standalone (see
|
SPA (`web/`) is the operator dashboard, served standalone (see
|
||||||
[plans/2026-07-12-wails-desktop-app.md](../../plans/2026-07-12-wails-desktop-app.md)).
|
[plans/done/2026-07-12-wails-desktop-app.md](../../plans/done/2026-07-12-wails-desktop-app.md)).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
||||||
|
|||||||
@@ -1,40 +1,49 @@
|
|||||||
# LLM Wiki — the documentation contract
|
# LLM Wiki — the documentation contract
|
||||||
|
|
||||||
How the narrative documentation in this repo is organized. The pattern is borrowed from the
|
How documentation in this repo is organized. The pattern is the `sources / wiki / index / log`
|
||||||
`sources / wiki / index / log` model: a durable synthesized layer (`archive/knowledge/`) built on top
|
model: a durable synthesized layer built on top of immutable evidence, with pure-listing indexes and
|
||||||
of immutable evidence (`knowledge/sources/`, incident records), with pure-listing indexes and an
|
an append-only operations log.
|
||||||
append-only operations log.
|
|
||||||
|
|
||||||
This contract governs the **narrative layer only**. The machine-readable substrate — `inventory.yaml`,
|
This contract governs the **narrative layer only**. The machine-readable source of truth — the
|
||||||
`secrets/`, `scripts/`, `bin/` — is not part of the wiki and never
|
Postgres database, bootstrapped from `seeds/` — is not part of the wiki and never moves under it.
|
||||||
moves under it. See [the knowledge schema](../domains/knowledge/schema.md) for the split.
|
See [the knowledge schema](../domains/knowledge/schema.md) for the split, and ADR 0003 for the
|
||||||
|
DB-native model.
|
||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
|
|
||||||
- **Sources** are immutable raw material: incident records (`knowledge/sources/investigations/`), external reference
|
- **Source of truth** is the Postgres database. Structured data (entities, relationships, status,
|
||||||
docs (`knowledge/sources/references/`), and the live system itself (`pct config`, `docker inspect`).
|
metrics) and narrative knowledge (documents, investigations, runbooks) both live there, in the
|
||||||
Read them; do not rewrite them into other sources.
|
`entities` / `relationships` / `knowledge_entities` tables. It is bootstrapped at deploy time from
|
||||||
- **Wiki** (`archive/knowledge/`) is the synthesized, authoritative current-state layer: one page per
|
`seeds/{ontology,inventory,policy,knowledge}.yaml` (idempotent, content-hashed via
|
||||||
node (`containers/`, `vms/`, host narratives) and per cross-cutting system (`infrastructure/`). A
|
`seed_versions`) and mutated at runtime via the API/MCP. `oikos export` regenerates
|
||||||
reader understands the topic from the wiki page without reading the sources.
|
`seeds/{ontology,inventory,policy}.yaml` for version control.
|
||||||
|
- **Sources** are immutable raw material: incident records (now `investigation` entities in the DB,
|
||||||
|
historically `archive/knowledge/sources/investigations/`), external reference docs, and the live
|
||||||
|
system itself (`pct config`, `docker inspect`). Read them; do not rewrite them into other sources.
|
||||||
|
- **Wiki** — the synthesized, authoritative current-state layer. Today this is the set of
|
||||||
|
`document` entities in the DB (one per node and per cross-cutting system), queried via MCP
|
||||||
|
`search_knowledge` / `get_entity_knowledge`. The legacy file-based wiki is frozen at
|
||||||
|
`archive/knowledge/{hosts,containers,vms,infrastructure}/` for historical reference only.
|
||||||
- **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a
|
- **Index** (`index.md` / folder `README.md`) is a pure listing — every page in scope with a
|
||||||
one-line summary, and nothing else. Anything the section wants to say up front goes into a page
|
one-line summary, and nothing else. Anything the section wants to say up front goes into a page
|
||||||
the index lists, not into the index.
|
the index lists, not into the index.
|
||||||
- **Log** (`log.md`) is append-only, recording *doc-maintenance operations* (restructures, source
|
- **Log** is append-only, recording *doc-maintenance operations* (restructures, source ingests,
|
||||||
ingests, lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`.
|
lint sweeps) in single-line format: `## [YYYY-MM-DD] <op> | <summary>`. The active log is the DB
|
||||||
|
audit trail; `archive/knowledge/log.md` is the frozen legacy equivalent.
|
||||||
|
|
||||||
## Two logs, kept distinct
|
## Two logs, kept distinct
|
||||||
|
|
||||||
- **`## Changelog`** on each node/topic page records *infrastructure* changes to that node. It is
|
- **`## Changelog`** on each node/topic document records *infrastructure* changes to that node. It
|
||||||
machine-parsed (`get_changelog`, the Oikos ledger) — keep the `### YYYY-MM-DD — title` shape.
|
is stored as a structured field on the `document` entity — keep the `### YYYY-MM-DD — title`
|
||||||
- **`log.md`** per area records *documentation* operations only. It never duplicates the Oikos
|
shape so it parses cleanly.
|
||||||
change ledger (`oikos/ledger.py`), which stays authoritative for infra changes with
|
- **Doc-maintenance logs** record *documentation* operations only. They never duplicate the
|
||||||
who/what/risk/approval/verification.
|
infrastructure changelog, which stays authoritative for infra changes with
|
||||||
|
who/what/risk/approval/verification (now the DB audit trail, formerly `oikos/ledger.py`).
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Wiki pages stay short and focused. A page past ~300 lines splits.
|
- Wiki pages stay short and focused. A page past ~300 lines splits.
|
||||||
- Pages stay flat under `wiki/<section>/` until there are enough to warrant a sub-group.
|
- Pages stay flat under their section until there are enough to warrant a sub-group.
|
||||||
- Every page follows [writing-style.md](writing-style.md).
|
- Every page follows [writing-style.md](writing-style.md).
|
||||||
- Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`),
|
- Plans and design docs always live in the repo `plans/` folder (`plans/YYYY-MM-DD-slug.md`),
|
||||||
listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat
|
listed in `plans/index.md`, moved to `plans/done/` on completion — never a scratch path or a chat
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ in [writing-style.md](writing-style.md); the layer model (sources / wiki / index
|
|||||||
**Foundational / entry-point files:** ALL-CAPS
|
**Foundational / entry-point files:** ALL-CAPS
|
||||||
|
|
||||||
- **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans.
|
- **Root level:** `AGENTS.md`, `README.md` — discovery paths for agents and humans.
|
||||||
- **Agent instruction** (under `.agents/`): `OIKOS.md`, `HERMES.md` — foundational docs agents read before acting.
|
- **Agent instruction** (under `.agents/`): `OIKOS.md`, `NOMOS.md` — foundational docs agents read before acting.
|
||||||
- **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY).
|
- **Reference docs:** `GLOSSARY.md` — lookup reference (like classic repo conventions: LICENSE, CHANGELOG, GLOSSARY).
|
||||||
|
|
||||||
**Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed
|
**Content / narrative pages:** lowercase-with-dashes, date-prefixed as needed
|
||||||
|
|
||||||
- **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from `inventory.yaml`.
|
- **Container pages:** `<id>-<name>.md` (e.g. `101-jellyfin.md`, `132-rclone.md`). The `<id>` is the LXC/VM ordinal from the entity's attributes in the DB (seeded via `seeds/inventory.yaml`).
|
||||||
- **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node.
|
- **Infrastructure / cross-cutting pages:** `<topic>.md` (e.g. `dns.md`, `auto-deploy.md`, `mesh.md`). Describes a system, not a specific node.
|
||||||
- **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase.
|
- **Plans / investigations:** `YYYY-MM-DD-<slug>.md` (e.g. `2026-07-05-oikos-prometheus-lxc.md`). Date-sorted; slug is lowercase.
|
||||||
- **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist.
|
- **Section indices:** `README.md` (lowercase, conventional). Prefer in folders; `index.md` only if both intro prose and listing coexist.
|
||||||
@@ -118,7 +118,7 @@ What it looks like after.
|
|||||||
Changelog entries to write, index status to update.
|
Changelog entries to write, index status to update.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Investigation (`knowledge/sources/investigations/YYYY-MM-DD-slug.md`)
|
### Investigation (`investigation` entity in the DB; historically `archive/knowledge/sources/investigations/YYYY-MM-DD-slug.md`)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# YYYY-MM-DD — <title>
|
# YYYY-MM-DD — <title>
|
||||||
@@ -152,16 +152,18 @@ Changelog entries to write, index status to update.
|
|||||||
## Same-session update rule
|
## Same-session update rule
|
||||||
|
|
||||||
When you make a change to a node — migrate an LXC, update an IP, change a
|
When you make a change to a node — migrate an LXC, update an IP, change a
|
||||||
mount, deploy a new service — **update every relevant doc page in the same
|
mount, deploy a new service — **update the DB and every relevant doc page in
|
||||||
session.** A change that touches a container page must also update:
|
the same session.** A change that touches a container must also update:
|
||||||
|
|
||||||
- The `containers/index.md` table (IPs, host, mounts, status)
|
- The `entities` / `relationships` rows for the node (via the API/MCP) —
|
||||||
|
this is the source of truth
|
||||||
|
- The `document` entity's `at_glance` and `## Changelog` for the container
|
||||||
|
- The `containers/index.md` table in the archived wiki (IPs, host, mounts,
|
||||||
|
status) — historical reference, update for consistency where still consulted
|
||||||
- The `README.md` table (if the change affects listed columns)
|
- The `README.md` table (if the change affects listed columns)
|
||||||
- The Caddy page site list (if the change affects `*.hubris.network` routing)
|
- The Caddy page site list (if the change affects `*.hubris.network` routing)
|
||||||
- The DNS / ingress infrastructure pages (if the change affects routing)
|
- The DNS / ingress infrastructure pages (if the change affects routing)
|
||||||
- The `hosts/{hubris,strong}.md` host page (if container count changes)
|
- The `hosts/{hubris,strong}.md` host page (if container count changes)
|
||||||
- The `inventory.yaml` host entry (single source of truth)
|
|
||||||
- The `infrastructure/topology.md` (generated from inventory, but regen if needed)
|
|
||||||
|
|
||||||
The pattern of updating only one page and leaving stale references on others
|
The pattern of updating only one page and leaving stale references on others
|
||||||
is a bug. If you're doing a multi-step migration, document the intermediate
|
is a bug. If you're doing a multi-step migration, document the intermediate
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Every doc-level page follows the same shape so a reader scans it in one pass.
|
|||||||
1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`.
|
1. **One H1 = the page title.** Node pages use `# <id> — \`<name>\``; topic pages use `# <Topic>`.
|
||||||
2. **Opening definition.** First paragraph, 1–3 sentences, says what the thing is. No motivation, no marketing, no setup.
|
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).
|
3. **Body sections** in the natural order for the topic. Reuse the section templates in [page-templates.md](page-templates.md).
|
||||||
4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is machine-parsed (Go MCP `get_changelog` in `internal/mcp/server.go`); keep the `### YYYY-MM-DD — title` shape.
|
4. **`## Changelog`** at the bottom of every node/topic page — reverse-chronological, append-only. This section is stored as a structured field on the `document` entity in the DB; keep the `### YYYY-MM-DD — title` shape so it parses cleanly.
|
||||||
5. **Related links** only at the bottom, only when a reference cannot be woven inline.
|
5. **Related links** only at the bottom, only when a reference cannot be woven inline.
|
||||||
|
|
||||||
## Section indexes (folder READMEs)
|
## Section indexes (folder READMEs)
|
||||||
@@ -53,12 +53,12 @@ duplicated prose, no narrative between the intro and the table.
|
|||||||
- Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists.
|
- Prefer **tables** for enumerable items with internal structure (service/port maps, field lists, status grids). Reserve bullets for short non-structured lists.
|
||||||
- Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation.
|
- Use the **bold-leading-phrase pattern** for structured points: `**Read-only by construction.** The MCP server never mutates state.` — a bold noun phrase, a period, then the explanation.
|
||||||
- When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph.
|
- When enumerating across services or nodes, give each its own `###` sub-section or a table row, not one run-on paragraph.
|
||||||
- Use backticks for code, paths, hostnames, and file names (`inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology.
|
- Use backticks for code, paths, hostnames, and file names (`seeds/inventory.yaml`, `192.168.8.77`, `pct config`); italics for first-mention terminology.
|
||||||
- Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote.
|
- Use `>` blockquotes for caveats and gaps that interrupt the main flow: `> **Outstanding gap.** DNS-vs-inventory drift check not yet wired.` One thought per blockquote.
|
||||||
|
|
||||||
## Diagrams
|
## Diagrams
|
||||||
|
|
||||||
- Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` is generated by `oikos/gen-topology.py` — do not hand-edit it. (Go DB-native topology generation planned.)
|
- Mermaid is the default for topology and flow diagrams. `infrastructure/topology.md` in the archived wiki was generated by the retired `oikos/gen-topology.py`; the DB-native equivalent is a future task — do not hand-edit the archived file expecting it to regenerate.
|
||||||
- ASCII box diagrams are fine for small shape diagrams; keep them to one screen.
|
- ASCII box diagrams are fine for small shape diagrams; keep them to one screen.
|
||||||
|
|
||||||
## Sourcing and cross-references
|
## Sourcing and cross-references
|
||||||
|
|||||||
@@ -70,3 +70,30 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: docker build (verify image builds; no push)
|
- name: docker build (verify image builds; no push)
|
||||||
run: docker build -f compose/oikos/Dockerfile -t oikos:ci .
|
run: docker build -f compose/oikos/Dockerfile -t oikos:ci .
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: svelte-check (advisory — baseline not yet clean)
|
||||||
|
run: npm run check
|
||||||
|
continue-on-error: true
|
||||||
|
- name: eslint (advisory — baseline not yet clean)
|
||||||
|
run: npm run lint
|
||||||
|
continue-on-error: true
|
||||||
|
- name: prettier format check (advisory — baseline not yet clean)
|
||||||
|
run: npm run format:check
|
||||||
|
continue-on-error: true
|
||||||
|
- name: test
|
||||||
|
run: npm run test
|
||||||
|
- name: build
|
||||||
|
run: npm run build
|
||||||
|
|||||||
41
.golangci.yml
Normal file
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
|
# AGENTS.md — orientation for any agent on a homelab client
|
||||||
|
|
||||||
You are running on a machine that is part of the **hubris** homelab. The full
|
You are running on a machine that is part of the **hubris** homelab. The full
|
||||||
context is in this checkout at `/opt/homelab-context/`. This file is the entry
|
context is in this checkout at `/opt/homelab/`. This file is the entry
|
||||||
point. Read it once at start, then keep working.
|
point. Read it once at start, then keep working.
|
||||||
|
|
||||||
- **New client?** Read [CLIENTS.md](CLIENTS.md) first.
|
- **New client?** Read [CLIENTS.md](CLIENTS.md) first.
|
||||||
@@ -30,7 +30,7 @@ is archived at `archive/knowledge/` for historical reference.
|
|||||||
|
|
||||||
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
|
Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
|
||||||
|
|
||||||
/opt/homelab-context/inventory.yaml
|
/opt/homelab/inventory.yaml
|
||||||
|
|
||||||
That file tells you your role, your peers, what's mounted, and what services
|
That file tells you your role, your peers, what's mounted, and what services
|
||||||
you host. If it does not exist, this client was not enrolled — stop and tell
|
you host. If it does not exist, this client was not enrolled — stop and tell
|
||||||
@@ -39,12 +39,13 @@ the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
|
|||||||
|
|
||||||
## 2. The topology
|
## 2. The topology
|
||||||
|
|
||||||
- `/opt/homelab-context/inventory.yaml` — every host, LXC, VM, and workstation
|
- `/opt/homelab/inventory.yaml` — every host, LXC, VM, and workstation
|
||||||
with their mesh addresses, roles, and service mappings. This is the seed file;
|
with their mesh addresses, roles, and service mappings. This is the seed file;
|
||||||
at runtime the DB is authoritative (query via MCP `get_entity` or the REST API).
|
at runtime the DB is authoritative (query via MCP `get_entity` or the REST API).
|
||||||
- `/opt/homelab-context/seeds/knowledge.yaml` — full narrative knowledge: 36
|
- `/opt/homelab/seeds/knowledge.yaml` — full narrative knowledge
|
||||||
documents, 6 investigations, 12 runbooks. Ingested into the DB on deploy.
|
(documents, investigations, runbooks). Counts are not hardcoded here; count
|
||||||
- `/opt/homelab-context/.agents/operations/commands.md` — the operator's cheatsheet
|
them from the seed or query the DB. Ingested into the DB on deploy.
|
||||||
|
- `/opt/homelab/.agents/operations/commands.md` — the operator's cheatsheet
|
||||||
for pct, caddy, DNS, and the Oikos command surface.
|
for pct, caddy, DNS, and the Oikos command surface.
|
||||||
|
|
||||||
## 3. The MCP server
|
## 3. The MCP server
|
||||||
@@ -55,7 +56,8 @@ Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
|
|||||||
enrollment and `/healthz` (see "Authentication" below for where the token
|
enrollment and `/healthz` (see "Authentication" below for where the token
|
||||||
comes from).
|
comes from).
|
||||||
|
|
||||||
Available tools (33 total):
|
Available tools (the authoritative list — count them below if a number is
|
||||||
|
needed; do not hardcode the count elsewhere):
|
||||||
|
|
||||||
Context — observe + orient:
|
Context — observe + orient:
|
||||||
get_entity(slug), list_entities(type, limit, cursor),
|
get_entity(slug), list_entities(type, limit, cursor),
|
||||||
@@ -111,10 +113,9 @@ Available tools (33 total):
|
|||||||
operator approval, and destructive patterns (rm -rf, dd, mkfs,
|
operator approval, and destructive patterns (rm -rf, dd, mkfs,
|
||||||
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
|
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
|
||||||
need approval regardless of what you declare. This is the ONLY
|
need approval regardless of what you declare. This is the ONLY
|
||||||
mutation tool — `request_execution` was retired 2026-07-14.
|
mutation tool — `request_execution` was retired 2026-07-14; the
|
||||||
`run` — the general execution primitive. Run any shell
|
former enum actions (restart, systemctl, pct_exec, apt_upgrade,
|
||||||
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the
|
pct_create) are all expressed as `run(target, command)` now.
|
||||||
route for those specific actions; policy-gated the same way `run` is.
|
|
||||||
get_execution_status(execution_id) — poll progress
|
get_execution_status(execution_id) — poll progress
|
||||||
|
|
||||||
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
||||||
@@ -145,8 +146,8 @@ POST /api/v1/knowledge/{entity_slug}
|
|||||||
{"title": "...", "content": "...", "tags": ["..."]}
|
{"title": "...", "content": "...", "tags": ["..."]}
|
||||||
```
|
```
|
||||||
|
|
||||||
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive
|
The DB is the truth. The old wiki files are archived at `archive/knowledge/`
|
||||||
per the DB-as-source-of-truth plan.
|
(historical reference only — use MCP `search_knowledge` for live queries).
|
||||||
|
|
||||||
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
|
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
|
||||||
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`
|
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`
|
||||||
@@ -162,8 +163,8 @@ per the DB-as-source-of-truth plan.
|
|||||||
## 6. Acting on the homelab
|
## 6. Acting on the homelab
|
||||||
|
|
||||||
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
|
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
|
||||||
operator interface — it has 33 MCP tools for observe/orient/decide/act
|
operator interface — it routes to the MCP tool list in §3 for
|
||||||
(§3).
|
observe/orient/decide/act.
|
||||||
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
|
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
|
||||||
`run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
|
`run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute
|
||||||
immediately; `config_mutation` and `destructive` actions are queued for
|
immediately; `config_mutation` and `destructive` actions are queued for
|
||||||
@@ -178,7 +179,7 @@ per the DB-as-source-of-truth plan.
|
|||||||
|
|
||||||
## 7. Communication mode
|
## 7. Communication mode
|
||||||
|
|
||||||
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
|
Read and apply `/opt/homelab/.agents/shared/caveman.md` (if present). It defines the lab's
|
||||||
terse-communication standard — drop filler, keep substance, use fragments.
|
terse-communication standard — drop filler, keep substance, use fragments.
|
||||||
|
|
||||||
## 8. Auto-setup mechanism
|
## 8. Auto-setup mechanism
|
||||||
@@ -191,16 +192,15 @@ on every client after `git pull`. This is handled by `tools/post-pull.sh`
|
|||||||
Currently auto-setup:
|
Currently auto-setup:
|
||||||
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
|
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
|
||||||
health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
|
health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
|
||||||
`ssh-script` check kind depends on these actually being there — 20 are
|
`ssh-script` check kind depends on these actually being there (count is
|
||||||
live in the DB as of 2026-07-12.
|
whatever is currently seeded in the DB — do not hardcode it here).
|
||||||
|
|
||||||
To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
|
To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
|
||||||
commit and push. All enrolled clients pick it up within 5 minutes.
|
commit and push. All enrolled clients pick it up within 5 minutes.
|
||||||
|
|
||||||
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
|
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
|
||||||
wait for the 5-min timer. (This mechanism — and the server-side
|
wait for the 5-min timer. (The server-side `tools_changed` detection only
|
||||||
`tools_changed` detection behind it — only correctly recognized
|
correctly recognizes `setup-*.sh` scripts — earlier it silently matched
|
||||||
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
|
|
||||||
nothing, so nothing auto-ran on any client via this path.)
|
nothing, so nothing auto-ran on any client via this path.)
|
||||||
|
|
||||||
## 9. Versioning
|
## 9. Versioning
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hu
|
|||||||
- Checks Gitea releases every 6 hours
|
- Checks Gitea releases every 6 hours
|
||||||
- System tray → **Check for Updates** triggers an immediate check
|
- System tray → **Check for Updates** triggers an immediate check
|
||||||
- Download, extract, replace the app in `/Applications`, and relaunch
|
- Download, extract, replace the app in `/Applications`, and relaunch
|
||||||
- Versions are compared against the `version` const in `main.go`
|
- Versions are compared against the `version` var in `main.go`, injected from the repo `VERSION` file at link time (`make desktop` passes `-ldflags "-X main.version=$(cat VERSION)"`)
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
|
|||||||
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
|
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install
|
||||||
|
|
||||||
BINARY := oikos
|
BINARY := bin/oikos
|
||||||
GO ?= go
|
GO ?= go
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
mkdir -p bin
|
||||||
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||||
|
|
||||||
webhook:
|
webhook:
|
||||||
@@ -19,9 +20,18 @@ test-db:
|
|||||||
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
|
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
|
||||||
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/
|
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/
|
||||||
|
|
||||||
lint:
|
lint: vet golangci govulncheck
|
||||||
|
|
||||||
|
vet:
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
|
|
||||||
|
golangci:
|
||||||
|
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run --config .golangci.yml || echo "golangci-lint not installed — see https://golangci-lint.run/usage/install/"
|
||||||
|
|
||||||
|
govulncheck:
|
||||||
|
@command -v govulncheck >/dev/null 2>&1 && govulncheck ./... || echo "govulncheck not installed — run: go install golang.org/x/vuln/cmd/govulncheck@latest"
|
||||||
|
|
||||||
|
.PHONY: lint vet golangci govulncheck
|
||||||
|
|
||||||
generate:
|
generate:
|
||||||
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
|
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
|
||||||
@@ -56,7 +66,7 @@ desktop: ui ## Build the Wails desktop app for the current platform
|
|||||||
rm -rf cmd/desktop/frontend/dist
|
rm -rf cmd/desktop/frontend/dist
|
||||||
mkdir -p cmd/desktop/frontend/dist
|
mkdir -p cmd/desktop/frontend/dist
|
||||||
cp -r web/dist/* cmd/desktop/frontend/dist/
|
cp -r web/dist/* cmd/desktop/frontend/dist/
|
||||||
cd cmd/desktop && CGO_ENABLED=1 go build -o build/bin/Oikos .
|
cd cmd/desktop && CGO_ENABLED=1 go build -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos .
|
||||||
|
|
||||||
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
||||||
@case $$(uname -s) in \
|
@case $$(uname -s) in \
|
||||||
@@ -67,7 +77,7 @@ desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar
|
|||||||
mkdir -p "$$APP/Contents/Resources"; \
|
mkdir -p "$$APP/Contents/Resources"; \
|
||||||
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
||||||
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
|
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
|
||||||
sed 's/$$(VERSION)/0.1.0/' cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
|
sed "s/\$$(VERSION)/$$(cat VERSION)/" cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
|
||||||
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
|
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
|
||||||
Linux) \
|
Linux) \
|
||||||
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
|
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
|
||||||
@@ -81,6 +91,7 @@ install: desktop-package ## Install to /Applications
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
rm -rf bin
|
||||||
rm -rf cmd/desktop/build
|
rm -rf cmd/desktop/build
|
||||||
rm -rf cmd/desktop/frontend/dist
|
rm -rf cmd/desktop/frontend/dist
|
||||||
$(GO) clean -testcache
|
$(GO) clean -testcache
|
||||||
|
|||||||
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 |
|
| Component | Port | Role |
|
||||||
|-----------|------|------|
|
|-----------|------|------|
|
||||||
| `oikos api` | 8090 | REST API + MCP server (15 tools) |
|
| `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) |
|
||||||
| `oikos scheduler` | — | Probe runner, signal lifecycle, metrics |
|
| `oikos scheduler` | — | Probe runner, signal lifecycle, metrics |
|
||||||
| `oikos notifier` | — | Approval tokens, Matrix alerts |
|
| `oikos notifier` | — | Approval tokens, Matrix alerts |
|
||||||
| `nomos serve` | 8092 | MCP client gateway, query routing |
|
| `nomos serve` | 8092 | MCP client gateway, query routing |
|
||||||
@@ -112,8 +112,8 @@ oikos secret migrate # SOPS → Infisical
|
|||||||
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
|
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
|
||||||
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
|
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
|
||||||
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
|
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
|
||||||
output). A native desktop wrapper is planned — see
|
output). A native desktop wrapper exists at `cmd/desktop/` — see
|
||||||
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md).
|
[plans/done/2026-07-12-wails-desktop-app.md](plans/done/2026-07-12-wails-desktop-app.md).
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -121,9 +121,10 @@ output). A native desktop wrapper is planned — see
|
|||||||
cmd/oikos/ Go entry point — single binary
|
cmd/oikos/ Go entry point — single binary
|
||||||
cmd/nomos/ Nomos MCP client gateway
|
cmd/nomos/ Nomos MCP client gateway
|
||||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||||
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
|
cmd/desktop/ Wails desktop wrapper around the SPA
|
||||||
notifier, policy, secrets, db, config, ontology, domain,
|
internal/ Go packages (actuator, checkdefaults, config, db, domain,
|
||||||
knowledge)
|
httpapi, knowledge, learning, mcp, notifier, observability,
|
||||||
|
ontology, policy, safego, scheduler, secrets)
|
||||||
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
||||||
api/openapi.yaml API contract (OpenAPI 3.1)
|
api/openapi.yaml API contract (OpenAPI 3.1)
|
||||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||||
|
|||||||
@@ -36,13 +36,17 @@ var iconPNG []byte
|
|||||||
const (
|
const (
|
||||||
keyringService = "com.hubris.oikos-desktop"
|
keyringService = "com.hubris.oikos-desktop"
|
||||||
keyringUser = "oikos"
|
keyringUser = "oikos"
|
||||||
version = "0.1.0"
|
|
||||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
||||||
pollInterval = 30 * time.Second
|
pollInterval = 30 * time.Second
|
||||||
updateInterval = 6 * time.Hour
|
updateInterval = 6 * time.Hour
|
||||||
oidcCallbackPort = 18901
|
oidcCallbackPort = 18901
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
|
||||||
|
// (Makefile desktop target). The default keeps a non-empty fallback for
|
||||||
|
// `go build ./cmd/desktop` without ldflags.
|
||||||
|
var version = "0.1.0-dev"
|
||||||
|
|
||||||
type OikosConfig struct {
|
type OikosConfig struct {
|
||||||
ApiUrl string `json:"apiUrl"`
|
ApiUrl string `json:"apiUrl"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
|
|||||||
16
docs/index.md
Normal file
16
docs/index.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Docs
|
||||||
|
|
||||||
|
Long-form reference material for the Oikos platform. Operational state and
|
||||||
|
topology live in the DB (seeded from `seeds/`); these docs cover decisions,
|
||||||
|
procedures, and the system model.
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| ---- | -------- |
|
||||||
|
| [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) |
|
||||||
|
| [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform |
|
||||||
|
| [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) |
|
||||||
|
|
||||||
|
For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model
|
||||||
|
see [.agents/OIKOS.md](../.agents/OIKOS.md); for development see
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md). Design plans live in
|
||||||
|
[plans/](../plans/), not here.
|
||||||
18
docs/operations/README.md
Normal file
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
|
Extra map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveHost(attrs map[string]any) string {
|
func resolveHost(attrs map[string]any) string {
|
||||||
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
@@ -57,8 +57,8 @@ func resolveSSHPort(attrs map[string]any) int {
|
|||||||
return 22
|
return 22
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
func forEntityType(entityType string, attrs map[string]any) []CheckDef {
|
||||||
host := ResolveHost(attrs)
|
host := resolveHost(attrs)
|
||||||
user := resolveSSHUser(attrs)
|
user := resolveSSHUser(attrs)
|
||||||
port := resolveSSHPort(attrs)
|
port := resolveSSHPort(attrs)
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ShortSlug(slug string) string {
|
func shortSlug(slug string) string {
|
||||||
const n = 8
|
const n = 8
|
||||||
if len(slug) > n {
|
if len(slug) > n {
|
||||||
return slug[len(slug)-n:]
|
return slug[len(slug)-n:]
|
||||||
@@ -130,7 +130,7 @@ func ShortSlug(slug string) string {
|
|||||||
return slug
|
return slug
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultInterval(kind string) int32 {
|
func defaultInterval(kind string) int32 {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "ping":
|
case "ping":
|
||||||
return 30
|
return 30
|
||||||
@@ -156,7 +156,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
|
|||||||
attrs = map[string]any{}
|
attrs = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
defs := ForEntityType(entityType, attrs)
|
defs := forEntityType(entityType, attrs)
|
||||||
if len(defs) == 0 {
|
if len(defs) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
checkID = uuid.New()
|
checkID = uuid.New()
|
||||||
}
|
}
|
||||||
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, ShortSlug(slug), i)
|
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, shortSlug(slug), i)
|
||||||
|
|
||||||
_, _ = tx.Exec(ctx,
|
_, _ = tx.Exec(ctx,
|
||||||
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
@@ -199,6 +199,6 @@ func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType
|
|||||||
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, 30, true)
|
VALUES ($1, $2, $3, $4, $5, 30, true)
|
||||||
ON CONFLICT (entity_id) DO NOTHING`,
|
ON CONFLICT (entity_id) DO NOTHING`,
|
||||||
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind))
|
checkID, entityID, def.Kind, configJSON, defaultInterval(def.Kind))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ WHERE e.type IN (SELECT name FROM tt)
|
|||||||
ORDER BY e.slug
|
ORDER BY e.slug
|
||||||
LIMIT sqlc.arg('lim');
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
-- name: ListEntitiesCapped :many
|
|
||||||
SELECT e.* FROM entities e ORDER BY e.slug LIMIT $1;
|
|
||||||
|
|
||||||
-- name: InsertEntity :one
|
-- name: InsertEntity :one
|
||||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
|
|||||||
ORDER BY se.slug
|
ORDER BY se.slug
|
||||||
LIMIT sqlc.arg('lim');
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
-- name: ListEntityStatus :many
|
|
||||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
|
||||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
|
||||||
ORDER BY e.slug;
|
|
||||||
|
|
||||||
-- name: GetIdempotentResponse :one
|
-- name: GetIdempotentResponse :one
|
||||||
SELECT response_code, response_body, request_hash FROM idempotency_keys
|
SELECT response_code, response_body, request_hash FROM idempotency_keys
|
||||||
WHERE actor = $1 AND key = $2;
|
WHERE actor = $1 AND key = $2;
|
||||||
@@ -89,9 +84,6 @@ DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateSignalState :exec
|
|
||||||
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1;
|
|
||||||
|
|
||||||
-- name: GetOpenSignalsForAutoAct :many
|
-- name: GetOpenSignalsForAutoAct :many
|
||||||
-- Signals with auto-act classifications that haven't been executed yet
|
-- Signals with auto-act classifications that haven't been executed yet
|
||||||
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
|
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
|
||||||
@@ -106,12 +98,6 @@ WHERE c.route = 'auto-act'
|
|||||||
ORDER BY s.last_seen_at ASC
|
ORDER BY s.last_seen_at ASC
|
||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
|
|
||||||
-- name: InsertClassification :exec
|
|
||||||
INSERT INTO classifications (entity_id, signal_entity_id, target_entity_id, action,
|
|
||||||
recommended_action, risk_class, route, blast_radius, pattern_confidence,
|
|
||||||
skill_id, autonomy_check, reasoning, correlation_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);
|
|
||||||
|
|
||||||
-- name: ListClassifications :many
|
-- name: ListClassifications :many
|
||||||
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
|
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
|
||||||
c.recommended_action, c.risk_class, c.route, c.blast_radius,
|
c.recommended_action, c.risk_class, c.route, c.blast_radius,
|
||||||
@@ -153,11 +139,6 @@ WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
|
|||||||
ORDER BY te.slug
|
ORDER BY te.slug
|
||||||
LIMIT sqlc.arg('lim');
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
-- name: InsertFeedback :exec
|
|
||||||
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson,
|
|
||||||
unexpected_side_effects, tags)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7);
|
|
||||||
|
|
||||||
-- name: GetFeedbackAfterWatermark :many
|
-- name: GetFeedbackAfterWatermark :many
|
||||||
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
||||||
f.unexpected_side_effects, f.tags, f.created_at,
|
f.unexpected_side_effects, f.tags, f.created_at,
|
||||||
@@ -201,11 +182,6 @@ SELECT * FROM skills
|
|||||||
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||||
ORDER BY name, version DESC;
|
ORDER BY name, version DESC;
|
||||||
|
|
||||||
-- name: InsertSkill :exec
|
|
||||||
INSERT INTO skills (entity_id, version, name, procedure, applies_type, action,
|
|
||||||
pattern_ids, status, changed_by, change_reason)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
|
||||||
|
|
||||||
-- name: UpdateSkillStatus :exec
|
-- name: UpdateSkillStatus :exec
|
||||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ WHERE r.valid_to IS NULL
|
|||||||
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
|
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
|
||||||
ORDER BY r.type, se.slug, te.slug;
|
ORDER BY r.type, se.slug, te.slug;
|
||||||
|
|
||||||
-- name: UpsertCurrentRelationship :exec
|
|
||||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
|
||||||
VALUES ($1, $2, $3, $4, now(), NULL)
|
|
||||||
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
|
||||||
DO UPDATE SET attributes = EXCLUDED.attributes;
|
|
||||||
|
|
||||||
-- name: EndCurrentRelationship :execrows
|
-- name: EndCurrentRelationship :execrows
|
||||||
UPDATE relationships SET valid_to = now()
|
UPDATE relationships SET valid_to = now()
|
||||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||||
|
|||||||
@@ -177,43 +177,6 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listEntitiesCapped = `-- name: ListEntitiesCapped :many
|
|
||||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e ORDER BY e.slug LIMIT $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) ListEntitiesCapped(ctx context.Context, limit int32) ([]Entity, error) {
|
|
||||||
rows, err := q.db.Query(ctx, listEntitiesCapped, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Entity
|
|
||||||
for rows.Next() {
|
|
||||||
var i Entity
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Slug,
|
|
||||||
&i.Type,
|
|
||||||
&i.Name,
|
|
||||||
&i.State,
|
|
||||||
&i.Attributes,
|
|
||||||
&i.MaintenanceUntil,
|
|
||||||
&i.Version,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.EnrolledAt,
|
|
||||||
&i.EnrolledBy,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateEntity = `-- name: UpdateEntity :one
|
const updateEntity = `-- name: UpdateEntity :one
|
||||||
UPDATE entities SET
|
UPDATE entities SET
|
||||||
name = COALESCE($1, name),
|
name = COALESCE($1, name),
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ type AgentSession struct {
|
|||||||
Actor string
|
Actor string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastActiveAt time.Time
|
LastActiveAt time.Time
|
||||||
|
Goal string
|
||||||
|
Status string
|
||||||
|
Outcome *string
|
||||||
|
Summary string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
CompletionNudges int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type Approval struct {
|
type Approval struct {
|
||||||
@@ -83,6 +89,7 @@ type AuditLog struct {
|
|||||||
Detail []byte
|
Detail []byte
|
||||||
SourceIp *string
|
SourceIp *string
|
||||||
CorrelationID *string
|
CorrelationID *string
|
||||||
|
SessionID *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
type AutonomySetting struct {
|
type AutonomySetting struct {
|
||||||
@@ -289,6 +296,13 @@ type MetricSample struct {
|
|||||||
Tags []byte
|
Tags []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NomosPlanExecution struct {
|
||||||
|
ExecutionID uuid.UUID
|
||||||
|
SessionID uuid.UUID
|
||||||
|
ContinuedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Pattern struct {
|
type Pattern struct {
|
||||||
EntityID uuid.UUID
|
EntityID uuid.UUID
|
||||||
AppliesType string
|
AppliesType string
|
||||||
@@ -351,6 +365,32 @@ type SeedVersion struct {
|
|||||||
AppliedAt time.Time
|
AppliedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionPlanStep struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
SessionID uuid.UUID
|
||||||
|
Seq int32
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Status string
|
||||||
|
ExecutionID *uuid.UUID
|
||||||
|
TargetSlug *string
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
Generation int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionQuestion struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
SessionID uuid.UUID
|
||||||
|
Prompt string
|
||||||
|
Context []byte
|
||||||
|
Status string
|
||||||
|
Answer *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
AnsweredAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Signal struct {
|
type Signal struct {
|
||||||
EntityID uuid.UUID
|
EntityID uuid.UUID
|
||||||
Kind string
|
Kind string
|
||||||
|
|||||||
@@ -416,48 +416,6 @@ func (q *Queries) InsertCheckDef(ctx context.Context, arg InsertCheckDefParams)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertClassification = `-- name: InsertClassification :exec
|
|
||||||
INSERT INTO classifications (entity_id, signal_entity_id, target_entity_id, action,
|
|
||||||
recommended_action, risk_class, route, blast_radius, pattern_confidence,
|
|
||||||
skill_id, autonomy_check, reasoning, correlation_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
||||||
`
|
|
||||||
|
|
||||||
type InsertClassificationParams struct {
|
|
||||||
EntityID uuid.UUID
|
|
||||||
SignalEntityID *uuid.UUID
|
|
||||||
TargetEntityID *uuid.UUID
|
|
||||||
Action string
|
|
||||||
RecommendedAction []byte
|
|
||||||
RiskClass string
|
|
||||||
Route string
|
|
||||||
BlastRadius []uuid.UUID
|
|
||||||
PatternConfidence *float32
|
|
||||||
SkillID *uuid.UUID
|
|
||||||
AutonomyCheck *string
|
|
||||||
Reasoning []byte
|
|
||||||
CorrelationID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) InsertClassification(ctx context.Context, arg InsertClassificationParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, insertClassification,
|
|
||||||
arg.EntityID,
|
|
||||||
arg.SignalEntityID,
|
|
||||||
arg.TargetEntityID,
|
|
||||||
arg.Action,
|
|
||||||
arg.RecommendedAction,
|
|
||||||
arg.RiskClass,
|
|
||||||
arg.Route,
|
|
||||||
arg.BlastRadius,
|
|
||||||
arg.PatternConfidence,
|
|
||||||
arg.SkillID,
|
|
||||||
arg.AutonomyCheck,
|
|
||||||
arg.Reasoning,
|
|
||||||
arg.CorrelationID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertEvent = `-- name: InsertEvent :one
|
const insertEvent = `-- name: InsertEvent :one
|
||||||
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
@@ -530,35 +488,6 @@ func (q *Queries) InsertExecution(ctx context.Context, arg InsertExecutionParams
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertFeedback = `-- name: InsertFeedback :exec
|
|
||||||
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson,
|
|
||||||
unexpected_side_effects, tags)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
`
|
|
||||||
|
|
||||||
type InsertFeedbackParams struct {
|
|
||||||
EntityID uuid.UUID
|
|
||||||
ExecutionID uuid.UUID
|
|
||||||
Outcome string
|
|
||||||
Observation *string
|
|
||||||
Lesson *string
|
|
||||||
UnexpectedSideEffects []string
|
|
||||||
Tags []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) InsertFeedback(ctx context.Context, arg InsertFeedbackParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, insertFeedback,
|
|
||||||
arg.EntityID,
|
|
||||||
arg.ExecutionID,
|
|
||||||
arg.Outcome,
|
|
||||||
arg.Observation,
|
|
||||||
arg.Lesson,
|
|
||||||
arg.UnexpectedSideEffects,
|
|
||||||
arg.Tags,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertMetricSample = `-- name: InsertMetricSample :exec
|
const insertMetricSample = `-- name: InsertMetricSample :exec
|
||||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||||
VALUES ($1, $2, $3, $4, now())
|
VALUES ($1, $2, $3, $4, now())
|
||||||
@@ -581,41 +510,6 @@ func (q *Queries) InsertMetricSample(ctx context.Context, arg InsertMetricSample
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertSkill = `-- name: InsertSkill :exec
|
|
||||||
INSERT INTO skills (entity_id, version, name, procedure, applies_type, action,
|
|
||||||
pattern_ids, status, changed_by, change_reason)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
`
|
|
||||||
|
|
||||||
type InsertSkillParams struct {
|
|
||||||
EntityID uuid.UUID
|
|
||||||
Version int32
|
|
||||||
Name string
|
|
||||||
Procedure []byte
|
|
||||||
AppliesType *string
|
|
||||||
Action string
|
|
||||||
PatternIds []uuid.UUID
|
|
||||||
Status string
|
|
||||||
ChangedBy *uuid.UUID
|
|
||||||
ChangeReason *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) InsertSkill(ctx context.Context, arg InsertSkillParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, insertSkill,
|
|
||||||
arg.EntityID,
|
|
||||||
arg.Version,
|
|
||||||
arg.Name,
|
|
||||||
arg.Procedure,
|
|
||||||
arg.AppliesType,
|
|
||||||
arg.Action,
|
|
||||||
arg.PatternIds,
|
|
||||||
arg.Status,
|
|
||||||
arg.ChangedBy,
|
|
||||||
arg.ChangeReason,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const listApprovalRules = `-- name: ListApprovalRules :many
|
const listApprovalRules = `-- name: ListApprovalRules :many
|
||||||
SELECT id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at FROM approval_rules ORDER BY entity_type, action
|
SELECT id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at FROM approval_rules ORDER BY entity_type, action
|
||||||
`
|
`
|
||||||
@@ -852,44 +746,6 @@ func (q *Queries) ListEnabledCheckDefs(ctx context.Context) ([]ListEnabledCheckD
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listEntityStatus = `-- name: ListEntityStatus :many
|
|
||||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
|
||||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
|
||||||
ORDER BY e.slug
|
|
||||||
`
|
|
||||||
|
|
||||||
type ListEntityStatusRow struct {
|
|
||||||
Slug string
|
|
||||||
Type string
|
|
||||||
Health string
|
|
||||||
LastCheckAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) ListEntityStatus(ctx context.Context) ([]ListEntityStatusRow, error) {
|
|
||||||
rows, err := q.db.Query(ctx, listEntityStatus)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []ListEntityStatusRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i ListEntityStatusRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.Slug,
|
|
||||||
&i.Type,
|
|
||||||
&i.Health,
|
|
||||||
&i.LastCheckAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const listEvents = `-- name: ListEvents :many
|
const listEvents = `-- name: ListEvents :many
|
||||||
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||||
FROM events
|
FROM events
|
||||||
@@ -1462,20 +1318,6 @@ func (q *Queries) UpdatePatternStatus(ctx context.Context, arg UpdatePatternStat
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSignalState = `-- name: UpdateSignalState :exec
|
|
||||||
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateSignalStateParams struct {
|
|
||||||
EntityID uuid.UUID
|
|
||||||
State string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateSignalState(ctx context.Context, arg UpdateSignalStateParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, updateSignalState, arg.EntityID, arg.State)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSkillStatus = `-- name: UpdateSkillStatus :exec
|
const updateSkillStatus = `-- name: UpdateSkillStatus :exec
|
||||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2
|
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -139,27 +139,3 @@ func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams)
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertCurrentRelationship = `-- name: UpsertCurrentRelationship :exec
|
|
||||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
|
||||||
VALUES ($1, $2, $3, $4, now(), NULL)
|
|
||||||
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
|
||||||
DO UPDATE SET attributes = EXCLUDED.attributes
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpsertCurrentRelationshipParams struct {
|
|
||||||
SourceID uuid.UUID
|
|
||||||
TargetID uuid.UUID
|
|
||||||
Type string
|
|
||||||
Attributes []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpsertCurrentRelationship(ctx context.Context, arg UpsertCurrentRelationshipParams) error {
|
|
||||||
_, err := q.db.Exec(ctx, upsertCurrentRelationship,
|
|
||||||
arg.SourceID,
|
|
||||||
arg.TargetID,
|
|
||||||
arg.Type,
|
|
||||||
arg.Attributes,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
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.
|
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
|
||||||
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||||
if id, err := uuid.Parse(idOrSlug); err == nil {
|
if id, err := uuid.Parse(idOrSlug); err == nil {
|
||||||
var found uuid.UUID
|
entity, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
||||||
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE id = $1", id).Scan(&found)
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
||||||
}
|
}
|
||||||
return found, err
|
return entity.ID, nil
|
||||||
}
|
}
|
||||||
var id uuid.UUID
|
entity, err := sqlcgen.New(s.pool).GetEntityBySlug(ctx, idOrSlug)
|
||||||
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
||||||
}
|
}
|
||||||
return id, err
|
return entity.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityCols requires the entities table to be aliased as `e`, with
|
// entityCols requires the entities table to be aliased as `e`, with
|
||||||
@@ -188,46 +186,37 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati
|
|||||||
if req.Params.Direction != nil {
|
if req.Params.Direction != nil {
|
||||||
dir = string(*req.Params.Direction)
|
dir = string(*req.Params.Direction)
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
relType := req.Params.RelType
|
||||||
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
|
rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
|
||||||
FROM relationships r
|
Direction: dir,
|
||||||
JOIN entities se ON se.id = r.source_id
|
ID: id,
|
||||||
JOIN entities te ON te.id = r.target_id
|
RelType: relType,
|
||||||
WHERE r.valid_to IS NULL
|
})
|
||||||
AND (($3 IN ('out','both') AND r.source_id = $1)
|
|
||||||
OR ($3 IN ('in','both') AND r.target_id = $1))
|
|
||||||
AND ($2::text IS NULL OR r.type = $2)
|
|
||||||
ORDER BY r.type, se.slug, te.slug`,
|
|
||||||
id, req.Params.RelType, dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items, err := scanRelationships(rows)
|
items := []gen.Relationship{}
|
||||||
if err != nil {
|
for _, r := range rows {
|
||||||
return nil, err
|
var attrs *map[string]any
|
||||||
|
if len(r.Attributes) > 0 {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
||||||
|
attrs = &m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validTo := r.ValidTo
|
||||||
|
items = append(items, gen.Relationship{
|
||||||
|
Source: r.SourceSlug,
|
||||||
|
Target: r.TargetSlug,
|
||||||
|
Type: r.Type,
|
||||||
|
Attributes: attrs,
|
||||||
|
ValidFrom: r.ValidFrom,
|
||||||
|
ValidTo: validTo,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanRelationships(rows pgx.Rows) ([]gen.Relationship, error) {
|
|
||||||
defer rows.Close()
|
|
||||||
items := []gen.Relationship{}
|
|
||||||
for rows.Next() {
|
|
||||||
var rel gen.Relationship
|
|
||||||
var attrsJSON []byte
|
|
||||||
if err := rows.Scan(&rel.Source, &rel.Target, &rel.Type,
|
|
||||||
&attrsJSON, &rel.ValidFrom, &rel.ValidTo); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var attrs map[string]any
|
|
||||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
|
||||||
rel.Attributes = &attrs
|
|
||||||
}
|
|
||||||
items = append(items, rel)
|
|
||||||
}
|
|
||||||
return items, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
||||||
id, err := s.resolveEntityID(ctx, req.Id)
|
id, err := s.resolveEntityID(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -336,21 +325,31 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
for i, n := range nodes {
|
for i, n := range nodes {
|
||||||
ids[i] = uuid.UUID(n.Id)
|
ids[i] = uuid.UUID(n.Id)
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
|
||||||
SELECT se.slug, te.slug, r.type, r.attributes, r.valid_from, r.valid_to
|
Ids: ids,
|
||||||
FROM relationships r
|
RelTypes: *req.Params.RelType,
|
||||||
JOIN entities se ON se.id = r.source_id
|
})
|
||||||
JOIN entities te ON te.id = r.target_id
|
|
||||||
WHERE r.valid_to IS NULL
|
|
||||||
AND r.source_id = ANY($1) AND r.target_id = ANY($1)
|
|
||||||
AND ($2::text[] IS NULL OR r.type = ANY($2))
|
|
||||||
ORDER BY r.type, se.slug, te.slug`, ids, req.Params.RelType)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
edges, err := scanRelationships(rows)
|
edges := []gen.Relationship{}
|
||||||
if err != nil {
|
for _, r := range edgeRows {
|
||||||
return nil, err
|
var attrs *map[string]any
|
||||||
|
if len(r.Attributes) > 0 {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
||||||
|
attrs = &m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validTo := r.ValidTo
|
||||||
|
edges = append(edges, gen.Relationship{
|
||||||
|
Source: r.SourceSlug,
|
||||||
|
Target: r.TargetSlug,
|
||||||
|
Type: r.Type,
|
||||||
|
Attributes: attrs,
|
||||||
|
ValidFrom: r.ValidFrom,
|
||||||
|
ValidTo: validTo,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
|
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
|
||||||
@@ -421,78 +420,70 @@ func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObje
|
|||||||
Lifecycles: []gen.LifecycleDef{},
|
Lifecycles: []gen.LifecycleDef{},
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
q := sqlcgen.New(s.pool)
|
||||||
SELECT name, parent_type, is_abstract, domain, layer, description,
|
|
||||||
lifecycle_id, attribute_schema, schema_version, status
|
etRows, err := q.ListEntityTypes(ctx)
|
||||||
FROM entity_types ORDER BY name`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for _, et := range etRows {
|
||||||
var et gen.EntityType
|
schemaVersion := int(et.SchemaVersion)
|
||||||
var schemaVersion int
|
var schema *map[string]any
|
||||||
var schemaJSON []byte
|
if len(et.AttributeSchema) > 0 {
|
||||||
if err := rows.Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain,
|
var s map[string]any
|
||||||
&et.Layer, &et.Description, &et.LifecycleId, &schemaJSON,
|
if json.Unmarshal(et.AttributeSchema, &s) == nil && s != nil {
|
||||||
&schemaVersion, &et.Status); err != nil {
|
schema = &s
|
||||||
rows.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
et.SchemaVersion = &schemaVersion
|
|
||||||
var schema map[string]any
|
|
||||||
if len(schemaJSON) > 0 && json.Unmarshal(schemaJSON, &schema) == nil && schema != nil {
|
|
||||||
et.AttributeSchema = &schema
|
|
||||||
}
|
}
|
||||||
resp.EntityTypes = append(resp.EntityTypes, et)
|
resp.EntityTypes = append(resp.EntityTypes, gen.EntityType{
|
||||||
}
|
Name: et.Name,
|
||||||
rows.Close()
|
ParentType: et.ParentType,
|
||||||
if rows.Err() != nil {
|
IsAbstract: et.IsAbstract,
|
||||||
return nil, rows.Err()
|
Domain: et.Domain,
|
||||||
|
Layer: gen.EntityTypeLayer(et.Layer),
|
||||||
|
Description: et.Description,
|
||||||
|
LifecycleId: et.LifecycleID,
|
||||||
|
SchemaVersion: &schemaVersion,
|
||||||
|
AttributeSchema: schema,
|
||||||
|
Status: gen.EntityTypeStatus(et.Status),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
rtRows, err := q.ListRelationshipTypes(ctx)
|
||||||
SELECT name, inverse, source_type, target_type, cardinality, description
|
|
||||||
FROM relationship_types ORDER BY name`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for _, rt := range rtRows {
|
||||||
var rt gen.RelationshipType
|
resp.RelationshipTypes = append(resp.RelationshipTypes, gen.RelationshipType{
|
||||||
if err := rows.Scan(&rt.Name, &rt.Inverse, &rt.SourceType, &rt.TargetType,
|
Name: rt.Name,
|
||||||
&rt.Cardinality, &rt.Description); err != nil {
|
Inverse: rt.Inverse,
|
||||||
rows.Close()
|
SourceType: rt.SourceType,
|
||||||
return nil, err
|
TargetType: rt.TargetType,
|
||||||
}
|
Cardinality: gen.RelationshipTypeCardinality(rt.Cardinality),
|
||||||
resp.RelationshipTypes = append(resp.RelationshipTypes, rt)
|
Description: rt.Description,
|
||||||
}
|
})
|
||||||
rows.Close()
|
|
||||||
if rows.Err() != nil {
|
|
||||||
return nil, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
lcRows, err := q.ListLifecycleDefs(ctx)
|
||||||
SELECT id, states, default_state, terminal_states, transitions
|
|
||||||
FROM lifecycle_defs ORDER BY id`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for _, lc := range lcRows {
|
||||||
var lc gen.LifecycleDef
|
terminal := lc.TerminalStates
|
||||||
var terminal []string
|
var transitions map[string]any
|
||||||
var transJSON []byte
|
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||||
if err := rows.Scan(&lc.Id, &lc.States, &lc.DefaultState, &terminal, &transJSON); err != nil {
|
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.ID, err)
|
||||||
rows.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
lc.TerminalStates = &terminal
|
resp.Lifecycles = append(resp.Lifecycles, gen.LifecycleDef{
|
||||||
if err := json.Unmarshal(transJSON, &lc.Transitions); err != nil {
|
Id: lc.ID,
|
||||||
rows.Close()
|
States: lc.States,
|
||||||
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.Id, err)
|
DefaultState: lc.DefaultState,
|
||||||
|
TerminalStates: &terminal,
|
||||||
|
Transitions: transitions,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
resp.Lifecycles = append(resp.Lifecycles, lc)
|
|
||||||
}
|
return resp, nil
|
||||||
rows.Close()
|
|
||||||
return resp, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Signals ──────────────────────────────────────────────────────────
|
// ─── Signals ──────────────────────────────────────────────────────────
|
||||||
@@ -1605,10 +1596,9 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "health-check-answering":
|
case "health-check-answering":
|
||||||
var health string
|
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||||
err := tx.QueryRow(ctx, "SELECT health FROM entity_status WHERE entity_id = $1", entityID).Scan(&health)
|
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||||
if err != nil || health == "unknown" || health == "down" {
|
return fmt.Errorf("health check not answering (status: %s)", st.Health)
|
||||||
return fmt.Errorf("health check not answering (status: %s)", health)
|
|
||||||
}
|
}
|
||||||
case "doc-page-complete":
|
case "doc-page-complete":
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
180
internal/httpapi/metrics.go
Normal file
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,
|
MaxAge: 86400,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// ─── Non-OpenAPI routes (carve-out) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// These routes are registered manually on the chi router rather than
|
||||||
|
// generated from api/openapi.yaml. Each has a structural reason it
|
||||||
|
// can't go through the strict-server codegen:
|
||||||
|
//
|
||||||
|
// /healthz — infra liveness probe, no auth, no /api/v1 prefix
|
||||||
|
// /api/v1/auth/oidc-* — auth flow, must run before auth middleware
|
||||||
|
// /oidc-callback — standalone HTML page, not a JSON API
|
||||||
|
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
|
||||||
|
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
|
||||||
|
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
|
||||||
|
// /api/v1/activity/recent — recency-ordered, not paginated
|
||||||
|
// /api/v1/activity/session/{id} — session-scoped aggregation
|
||||||
|
// /api/v1/learning/timeline — derived view, no backing schema type
|
||||||
|
// /api/v1/learning/trend — derived view, no backing schema type
|
||||||
|
//
|
||||||
|
// See .agents/dev/CONTRIBUTING.md §OpenAPI codegen for the policy.
|
||||||
|
|
||||||
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
|
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
|
||||||
@@ -173,23 +192,27 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
|
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
|
||||||
// Knowledge page's "what the system has learned" view. Registered after
|
// Knowledge page's "what the system has learned" view. Registered after
|
||||||
// HandlerWithOptions so it wins over any generated catch-all.
|
// HandlerWithOptions so it wins over any generated catch-all.
|
||||||
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
||||||
|
|
||||||
// Custom (non-OpenAPI) route: full markdown content for a knowledge
|
// Custom (non-OpenAPI) route: full markdown content for a knowledge
|
||||||
// entity (document/investigation/runbook) by its own id or slug — the
|
// entity (document/investigation/runbook) by its own id or slug — the
|
||||||
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
|
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
|
||||||
// different question (knowledge referencing this entity), not this one.
|
// different question (knowledge referencing this entity), not this one.
|
||||||
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
||||||
|
|
||||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||||
// unlike ListExecutions which sorts by target for pagination) and the
|
// unlike ListExecutions which sorts by target for pagination) and the
|
||||||
// per-session "what did this session do" digest.
|
// per-session "what did this session do" digest.
|
||||||
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||||
|
|
||||||
// Learning view: capability timeline + success trend, both derived from
|
// Learning view: capability timeline + success trend, both derived from
|
||||||
// executions (real, growing data) rather than the patterns/skills tables,
|
// executions (real, growing data) rather than the patterns/skills tables,
|
||||||
// which are correctly modeled but have no writers anywhere yet.
|
// which are correctly modeled but have no writers anywhere yet.
|
||||||
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
||||||
|
|
||||||
|
|||||||
196
internal/httpapi/skills.go
Normal file
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,
|
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||||
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
||||||
|
|
||||||
successCount := 0
|
successCount, failureCount := countOutcomes(items)
|
||||||
failureCount := 0
|
|
||||||
for _, f := range items {
|
|
||||||
switch f.Outcome {
|
|
||||||
case "success":
|
|
||||||
successCount++
|
|
||||||
case "failure", "unexpected":
|
|
||||||
failureCount++
|
|
||||||
case "partial":
|
|
||||||
successCount++ // partial counts as half-success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total := successCount + failureCount
|
total := successCount + failureCount
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute Wilson score lower bound
|
confidence := computeConfidence(successCount, failureCount)
|
||||||
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
|
|
||||||
// Cap by sample size: nothing looks confident before 5 samples
|
|
||||||
confidence = math.Min(confidence, float64(total)/5.0)
|
|
||||||
|
|
||||||
// Get or create pattern — first look up existing entity, then upsert.
|
// Get or create pattern — first look up existing entity, then upsert.
|
||||||
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
||||||
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined {
|
if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) {
|
||||||
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
|
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
|
||||||
EntityID: pat.EntityID,
|
EntityID: pat.EntityID,
|
||||||
Status: "validated",
|
Status: "validated",
|
||||||
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
|||||||
"confidence", confidence, "samples", total)
|
"confidence", confidence, "samples", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anomaly check: >10 identical outcomes within 1h
|
if shouldQuarantine(total) {
|
||||||
if total > 10 {
|
|
||||||
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
||||||
EntityID: pat.EntityID,
|
EntityID: pat.EntityID,
|
||||||
Quarantined: true,
|
Quarantined: true,
|
||||||
@@ -169,6 +154,45 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countOutcomes tallies feedback items into success and failure counts.
|
||||||
|
// "partial" counts as a half-success (increments success).
|
||||||
|
func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) {
|
||||||
|
for _, f := range items {
|
||||||
|
switch f.Outcome {
|
||||||
|
case "success":
|
||||||
|
success++
|
||||||
|
case "failure", "unexpected":
|
||||||
|
failure++
|
||||||
|
case "partial":
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success, failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeConfidence calculates the Wilson score lower bound, capped by
|
||||||
|
// sample size (nothing looks confident before 5 samples).
|
||||||
|
func computeConfidence(success, failure int) float64 {
|
||||||
|
total := success + failure
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
confidence := wilsonLowerBound(float64(success), float64(total), 0.95)
|
||||||
|
return math.Min(confidence, float64(total)/5.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldValidate returns true when a pattern has enough evidence and
|
||||||
|
// confidence to be promoted from "hypothesized" to "validated".
|
||||||
|
func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool {
|
||||||
|
return evidenceCount >= 5 && confidence >= 0.7 && !quarantined
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldQuarantine returns true when an anomaly burst is detected
|
||||||
|
// (>10 identical outcomes, indicating a runaway loop rather than organic feedback).
|
||||||
|
func shouldQuarantine(total int) bool {
|
||||||
|
return total > 10
|
||||||
|
}
|
||||||
|
|
||||||
// wilsonLowerBound computes the Wilson score interval lower bound.
|
// wilsonLowerBound computes the Wilson score interval lower bound.
|
||||||
// Conservative estimate of success rate for small sample sizes.
|
// Conservative estimate of success rate for small sample sizes.
|
||||||
func wilsonLowerBound(success, total, z float64) float64 {
|
func wilsonLowerBound(success, total, z float64) float64 {
|
||||||
|
|||||||
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{
|
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
})
|
})
|
||||||
|
for _, t := range allTools(pool, agentID) {
|
||||||
register := func(tool *mcp.Tool, handler toolHandler) {
|
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||||
s.AddTool(tool, withActivityLogging(pool, agentID, tool.Name, handler))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
|
||||||
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
idOrSlug, _ := args["slug_or_id"].(string)
|
|
||||||
return queryEntity(ctx, pool, idOrSlug), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"type", "string", "Filter by entity type"},
|
|
||||||
prop{"state", "string", "Filter by lifecycle state"},
|
|
||||||
prop{"q", "string", "Substring match on slug or name"},
|
|
||||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
limit := int(getFloat(args, "limit", 50))
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
|
||||||
FROM entities e
|
|
||||||
WHERE ($1::text IS NULL OR e.type = $1)
|
|
||||||
AND ($2::text IS NULL OR e.state = $2)
|
|
||||||
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
|
||||||
ORDER BY e.slug LIMIT $4`,
|
|
||||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
|
||||||
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["entity_id"].(string)
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT r.type, src.slug AS source, tgt.slug AS target
|
|
||||||
FROM relationships r
|
|
||||||
JOIN entities src ON src.id = r.source_id
|
|
||||||
JOIN entities tgt ON tgt.id = r.target_id
|
|
||||||
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
|
|
||||||
ORDER BY r.type`, slug), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"entity_id", "string", "Entity slug"},
|
|
||||||
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["entity_id"].(string)
|
|
||||||
depth := int(getFloat(args, "depth", 3))
|
|
||||||
return queryRows(ctx, pool,
|
|
||||||
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
|
|
||||||
slug, depth), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
|
||||||
InputSchema: objSchema(),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
|
||||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
|
||||||
WHERE e.type <> 'check'
|
|
||||||
ORDER BY e.slug`), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
|
||||||
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
|
|
||||||
FROM audit_log
|
|
||||||
WHERE ($1::text IS NULL OR entity_id::text = $1)
|
|
||||||
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
||||||
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
q := nStr(args["query"])
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT ke.title, e.slug,
|
|
||||||
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
|
||||||
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
|
||||||
FragmentDelimiter=" ... "') AS snippet,
|
|
||||||
ke.source, ke.tags
|
|
||||||
FROM knowledge_entities ke
|
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
|
||||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
|
||||||
ORDER BY rank DESC
|
|
||||||
LIMIT 20`, q), "knowledge_results"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
||||||
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["entity_slug"].(string)
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
|
||||||
FROM knowledge_entities ke
|
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
|
||||||
JOIN relationships r ON r.source_id = ke.entity_id
|
|
||||||
JOIN entities target ON target.id = r.target_id
|
|
||||||
WHERE target.slug = $1
|
|
||||||
AND r.valid_to IS NULL
|
|
||||||
AND r.type IN ('documents', 'about')
|
|
||||||
UNION
|
|
||||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
|
||||||
FROM knowledge_entities ke
|
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
|
||||||
JOIN relationships r ON r.source_id = ke.entity_id
|
|
||||||
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
|
|
||||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
|
||||||
WHERE r.valid_to IS NULL
|
|
||||||
AND r.type = 'procedure-for'
|
|
||||||
ORDER BY 1`, slug), "knowledge_results"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
|
||||||
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["slug"].(string)
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
|
||||||
FROM knowledge_entities ke
|
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
|
||||||
WHERE e.slug = $1`, slug), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
|
||||||
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
|
||||||
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
|
||||||
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
|
||||||
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
return upsertKnowledge(ctx, pool, args)
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
|
|
||||||
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["slug"].(string)
|
|
||||||
attrsStr, _ := args["attributes"].(string)
|
|
||||||
if slug == "" || attrsStr == "" {
|
|
||||||
return textResult("error: slug and attributes are required"), nil
|
|
||||||
}
|
|
||||||
var attrs map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
|
||||||
}
|
|
||||||
attrsJSON, _ := json.Marshal(attrs)
|
|
||||||
ct, err := pool.Exec(ctx, `
|
|
||||||
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
|
||||||
WHERE slug = $1`, slug, string(attrsJSON))
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
|
||||||
}
|
|
||||||
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"source", "string", "Source entity slug."},
|
|
||||||
prop{"target", "string", "Target entity slug."},
|
|
||||||
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
source, _ := args["source"].(string)
|
|
||||||
target, _ := args["target"].(string)
|
|
||||||
relType, _ := args["type"].(string)
|
|
||||||
if source == "" || target == "" || relType == "" {
|
|
||||||
return textResult("error: source, target, and type are required"), nil
|
|
||||||
}
|
|
||||||
var sourceID, targetID uuid.UUID
|
|
||||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
|
||||||
}
|
|
||||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
|
||||||
}
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
|
||||||
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM relationships
|
|
||||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
|
|
||||||
sourceID, targetID, relType)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
|
|
||||||
}
|
|
||||||
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
|
||||||
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
hours := int(getFloat(args, "hours", 24))
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT time_bucket('1 hour', ts) AS bucket,
|
|
||||||
entity_id::text, metric,
|
|
||||||
ROUND(avg(value)::numeric, 2) AS avg,
|
|
||||||
ROUND(min(value)::numeric, 2) AS min,
|
|
||||||
ROUND(max(value)::numeric, 2) AS max
|
|
||||||
FROM metric_samples
|
|
||||||
WHERE ts > now() - make_interval(hours => $1)
|
|
||||||
GROUP BY bucket, entity_id, metric
|
|
||||||
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Phase 4: new tools ──────────────────────────────────────────
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"entity_slug", "string", "Filter by target entity slug"},
|
|
||||||
prop{"state", "string", "Filter by signal state (raised, resolved)"},
|
|
||||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
limit := int(getFloat(args, "limit", 50))
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT s.entity_id::text, s.kind, s.severity, s.state,
|
|
||||||
s.occurrence_count, e.slug AS target_slug,
|
|
||||||
s.first_seen_at, s.last_seen_at
|
|
||||||
FROM signals s
|
|
||||||
LEFT JOIN entities e ON e.id = s.target_entity_id
|
|
||||||
WHERE ($1::text IS NULL OR e.slug = $1)
|
|
||||||
AND ($2::text IS NULL OR s.state = $2)
|
|
||||||
ORDER BY s.last_seen_at DESC LIMIT $3`,
|
|
||||||
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
|
|
||||||
prop{"entity_type", "string", "Filter by applies_type"},
|
|
||||||
prop{"action", "string", "Filter by action"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
|
|
||||||
p.confidence, p.evidence_count, p.success_count, p.failure_count,
|
|
||||||
p.status, p.quarantined, p.version, p.last_validated_at
|
|
||||||
FROM patterns p
|
|
||||||
WHERE ($1::text IS NULL OR p.status = $1)
|
|
||||||
AND ($2::text IS NULL OR p.applies_type = $2)
|
|
||||||
AND ($3::text IS NULL OR p.action = $3)
|
|
||||||
ORDER BY p.applies_type, p.action`,
|
|
||||||
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_skills", Description: "List available automation skills",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
|
|
||||||
s.applies_type, s.action, s.status, s.success_rate,
|
|
||||||
s.changed_by::text, s.change_reason, s.last_used_at
|
|
||||||
FROM skills s
|
|
||||||
WHERE ($1::text IS NULL OR s.status = $1)
|
|
||||||
ORDER BY s.name, s.version DESC`,
|
|
||||||
nStr(args["status"])), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
|
||||||
// All mutations now route through `run`. The handler functions
|
|
||||||
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
|
|
||||||
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
|
||||||
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong) or lxc:<slug> (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."},
|
|
||||||
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
|
||||||
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
|
|
||||||
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
targetSlug, _ := args["target"].(string)
|
|
||||||
command, _ := args["command"].(string)
|
|
||||||
purpose, _ := args["purpose"].(string)
|
|
||||||
declaredRisk, _ := args["declared_risk"].(string)
|
|
||||||
sessionID, _ := args["_session_id"].(string)
|
|
||||||
if targetSlug == "" || command == "" {
|
|
||||||
return textResult("error: target and command are required"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetID uuid.UUID
|
|
||||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
|
||||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"url", "string", "Absolute http(s) URL to fetch"},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
rawURL, _ := args["url"].(string)
|
|
||||||
return httpGet(ctx, rawURL), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
execID, _ := args["execution_id"].(string)
|
|
||||||
if execID == "" {
|
|
||||||
return textResult("execution_id required"), nil
|
|
||||||
}
|
|
||||||
eid, err := uuid.Parse(execID)
|
|
||||||
if err != nil {
|
|
||||||
// Try finding by exec slug prefix
|
|
||||||
var found uuid.UUID
|
|
||||||
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
|
||||||
if err2 != nil {
|
|
||||||
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
|
||||||
}
|
|
||||||
eid = found
|
|
||||||
}
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
|
||||||
e.result::text, e.duration_ms, e.started_at::text,
|
|
||||||
e.completed_at::text, e.correlation_id
|
|
||||||
FROM executions e
|
|
||||||
WHERE e.entity_id = $1`, eid), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"entity_id", "string", "Entity slug"},
|
|
||||||
prop{"days", "integer", "Look-back window in days (default 7)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["entity_id"].(string)
|
|
||||||
days := int(getFloat(args, "days", 7))
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT metric,
|
|
||||||
ROUND(avg(value)::numeric, 2) AS avg_val,
|
|
||||||
ROUND(stddev(value)::numeric, 2) AS std_val,
|
|
||||||
count(*) AS sample_count,
|
|
||||||
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
|
||||||
FROM metric_samples ms
|
|
||||||
JOIN entities e ON e.id = ms.entity_id
|
|
||||||
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
|
|
||||||
GROUP BY metric
|
|
||||||
ORDER BY metric`, slug, days), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"severity", "string", "Filter by severity (info, warn, error)"},
|
|
||||||
prop{"entity_slug", "string", "Filter by entity slug"},
|
|
||||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
limit := int(getFloat(args, "limit", 50))
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
|
|
||||||
ev.data::text AS message, ev.correlation_id
|
|
||||||
FROM events ev
|
|
||||||
LEFT JOIN entities e ON e.id = ev.entity_id
|
|
||||||
WHERE ($1::text IS NULL OR ev.severity = $1)
|
|
||||||
AND ($2::text IS NULL OR e.slug = $2)
|
|
||||||
ORDER BY ev.ts DESC LIMIT $3`,
|
|
||||||
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
limit := int(getFloat(args, "limit", 50))
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
|
||||||
entity_id::text, left(input_summary, 200) AS input_summary,
|
|
||||||
left(output_summary, 200) AS output_summary,
|
|
||||||
duration_ms, token_count, success, correlation_id
|
|
||||||
FROM agent_activity
|
|
||||||
WHERE agent_id = $1
|
|
||||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
|
|
||||||
),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
state, _ := argsMap(req)["state"].(string)
|
|
||||||
var statePtr *string
|
|
||||||
if state != "" {
|
|
||||||
statePtr = &state
|
|
||||||
}
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
|
||||||
e.attributes->>'lan_ip' AS lan_ip,
|
|
||||||
e.state,
|
|
||||||
st.health, st.last_check_at,
|
|
||||||
(SELECT MAX(k.created_at)
|
|
||||||
FROM relationships r
|
|
||||||
JOIN knowledge_entities k ON k.entity_id = r.source_id
|
|
||||||
WHERE r.target_id = e.id
|
|
||||||
AND r.type = 'about'
|
|
||||||
AND r.valid_to IS NULL
|
|
||||||
AND (k.tags @> ARRAY['audit']::text[]
|
|
||||||
OR k.tags @> ARRAY['update']::text[]
|
|
||||||
OR k.title ILIKE '%audit%'
|
|
||||||
OR k.title ILIKE '%update%')
|
|
||||||
) AS last_audited_at
|
|
||||||
FROM entities e
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.type = 'lxc'
|
|
||||||
AND ($1::text IS NULL OR e.state = $1)
|
|
||||||
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
|
|
||||||
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
|
||||||
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["service_slug"].(string)
|
|
||||||
if slug == "" {
|
|
||||||
return textResult("service_slug is required"), nil
|
|
||||||
}
|
|
||||||
rows, err := pool.Query(ctx, `
|
|
||||||
SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
|
|
||||||
FROM entity_status st
|
|
||||||
JOIN entities e ON e.id = st.entity_id
|
|
||||||
WHERE e.slug = $1`, slug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
if !rows.Next() {
|
|
||||||
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
|
||||||
}
|
|
||||||
var health, lastCheck, url string
|
|
||||||
rows.Scan(&health, &lastCheck, &url)
|
|
||||||
if url == "" {
|
|
||||||
url = "(no URL in entity attributes)"
|
|
||||||
}
|
|
||||||
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
|
||||||
prop{"lines", "integer", "Number of lines (default 50)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["service_slug"].(string)
|
|
||||||
n := int(getFloat(args, "lines", 50))
|
|
||||||
if slug == "" {
|
|
||||||
return textResult("service_slug is required"), nil
|
|
||||||
}
|
|
||||||
host, user, err := resolveHost(ctx, pool, slug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
|
||||||
}
|
|
||||||
svc := strings.TrimPrefix(slug, "lxc:")
|
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["service_slug"].(string)
|
|
||||||
if slug == "" {
|
|
||||||
return textResult("service_slug is required"), nil
|
|
||||||
}
|
|
||||||
host, user, err := resolveHost(ctx, pool, slug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
|
||||||
}
|
|
||||||
svc := strings.TrimPrefix(slug, "lxc:")
|
|
||||||
out, err := sshExec(ctx, host, user,
|
|
||||||
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["lxc_slug"].(string)
|
|
||||||
if slug == "" {
|
|
||||||
return textResult("lxc_slug is required"), nil
|
|
||||||
}
|
|
||||||
var pveID string
|
|
||||||
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
|
|
||||||
if err != nil || pveID == "" {
|
|
||||||
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
|
|
||||||
}
|
|
||||||
// Resolve the Proxmox host — find the host that runs this LXC
|
|
||||||
var hostID uuid.UUID
|
|
||||||
err = pool.QueryRow(ctx, `
|
|
||||||
SELECT t.id FROM entities t
|
|
||||||
JOIN relationships r ON r.source_id = t.id
|
|
||||||
JOIN entities s ON s.id = r.target_id
|
|
||||||
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
|
|
||||||
LIMIT 1`, slug).Scan(&hostID)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback: use the inventory host attribute if no relationship
|
|
||||||
var hostSlug string
|
|
||||||
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
|
|
||||||
if err != nil || hostSlug == "" {
|
|
||||||
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
|
||||||
}
|
|
||||||
var host, user string
|
|
||||||
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
|
||||||
}
|
|
||||||
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
|
||||||
if err2 != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
}
|
|
||||||
var hostSlug string
|
|
||||||
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
|
||||||
host, user, err := resolveHost(ctx, pool, hostSlug)
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
|
||||||
}
|
|
||||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
|
||||||
if err != nil {
|
|
||||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
|
||||||
}
|
|
||||||
return textResult(out), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
|
|
||||||
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
hostname, _ := args["hostname"].(string)
|
|
||||||
if hostname == "" {
|
|
||||||
return textResult("error: hostname required"), nil
|
|
||||||
}
|
|
||||||
slug := "ws:" + hostname
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, e.name, e.state,
|
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
|
||||||
e.attributes->>'mesh_ip' AS mesh_ip,
|
|
||||||
e.attributes->>'age_pubkey' AS age_pubkey,
|
|
||||||
e.enrolled_at
|
|
||||||
FROM entities e
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.slug = $1
|
|
||||||
ORDER BY e.slug`, slug), "entity_card"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
|
||||||
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["service_slug"].(string)
|
|
||||||
if slug == "" {
|
|
||||||
return textResult("error: service_slug required"), nil
|
|
||||||
}
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, e.name, e.state,
|
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
|
||||||
e.version, e.updated_at,
|
|
||||||
COALESCE(e.attributes::text, '{}') AS attrs
|
|
||||||
FROM entities e
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.slug = $1`, slug), "entity_card"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"service_slug", "string", "Entity slug"},
|
|
||||||
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["service_slug"].(string)
|
|
||||||
action, _ := args["action"].(string)
|
|
||||||
if slug == "" || action == "" {
|
|
||||||
return textResult("error: service_slug and action required"), nil
|
|
||||||
}
|
|
||||||
return queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, e.state,
|
|
||||||
CASE
|
|
||||||
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
|
|
||||||
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
|
|
||||||
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
|
|
||||||
ELSE 'read_only'
|
|
||||||
END AS risk_class,
|
|
||||||
CASE
|
|
||||||
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
|
|
||||||
WHEN $2 = 'config_mutation' THEN 'operator-approval'
|
|
||||||
ELSE 'operator-approval+confirmation'
|
|
||||||
END AS approval
|
|
||||||
FROM entities e WHERE e.slug = $1`, slug, action), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
|
|
||||||
InputSchema: objSchema(
|
|
||||||
prop{"entity_slug", "string", "Entity slug"},
|
|
||||||
prop{"limit", "integer", "Max entries (default 20)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
slug, _ := args["entity_slug"].(string)
|
|
||||||
limit := int(getFloat(args, "limit", 20))
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
|
||||||
al.action, al.method, al.path,
|
|
||||||
al.detail::text AS details
|
|
||||||
FROM audit_log al
|
|
||||||
JOIN entities e ON e.id = al.entity_id
|
|
||||||
WHERE e.slug = $1
|
|
||||||
ORDER BY al.ts DESC
|
|
||||||
LIMIT $2`, slug, limit), "change_log"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
|
||||||
InputSchema: objSchema(),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
return annotateJSONResult(queryRows(ctx, pool, `
|
|
||||||
SELECT e.slug, e.type, e.state,
|
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check
|
|
||||||
FROM entities e
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.state IS NOT NULL
|
|
||||||
OR st.health IS NOT NULL
|
|
||||||
ORDER BY st.health, e.slug
|
|
||||||
LIMIT 200
|
|
||||||
`), "fleet_snapshot"), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
|
||||||
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
|
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
||||||
args := argsMap(req)
|
|
||||||
pubkey, _ := args["caller_pubkey"].(string)
|
|
||||||
// Match entities where age_pubkey attribute contains the caller's key.
|
|
||||||
query := `
|
|
||||||
SELECT e.slug, e.type, e.name,
|
|
||||||
e.attributes->>'age_pubkey' AS age_pubkey
|
|
||||||
FROM entities e
|
|
||||||
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
|
|
||||||
var dbArgs []any
|
|
||||||
if pubkey != "" {
|
|
||||||
query += ` AND e.attributes->>'age_pubkey' = $1`
|
|
||||||
dbArgs = append(dbArgs, pubkey)
|
|
||||||
}
|
|
||||||
query += ` ORDER BY e.slug LIMIT 100`
|
|
||||||
return queryRows(ctx, pool, query, dbArgs...), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,12 +973,16 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
|
|||||||
// a fresh UUID here had no matching entities row, so the INSERT silently
|
// a fresh UUID here had no matching entities row, so the INSERT silently
|
||||||
// failed, orphaning the execution and never alerting the operator. One
|
// failed, orphaning the execution and never alerting the operator. One
|
||||||
// execution maps to at most one approval, so the 1:1 identity holds.
|
// execution maps to at most one approval, so the 1:1 identity holds.
|
||||||
if _, err := pool.Exec(ctx, `
|
if err := sqlcgen.New(pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
|
||||||
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
|
EntityID: execID,
|
||||||
kind, payload, status, expires_at, created_at)
|
SubjectEntityID: &targetID,
|
||||||
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
Action: action,
|
||||||
now() + interval '1 hour', now())`,
|
RiskClass: riskClass,
|
||||||
execID, targetID, action, riskClass, string(payload)); err != nil {
|
Kind: "execution",
|
||||||
|
Payload: payload,
|
||||||
|
TokenHash: nil,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}); err != nil {
|
||||||
slog.Error("createApproval: insert approval", "error", err, "execution", execID)
|
slog.Error("createApproval: insert approval", "error", err, "execution", execID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
724
internal/mcp/tools.go
Normal file
724
internal/mcp/tools.go
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolReg pairs a tool definition with its handler. allTools returns a slice
|
||||||
|
// of these; newServer iterates it and registers each one wrapped with
|
||||||
|
// withActivityLogging.
|
||||||
|
type toolReg struct {
|
||||||
|
tool *mcp.Tool
|
||||||
|
handler toolHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// allTools returns every MCP tool registration. Tool definitions, schemas,
|
||||||
|
// descriptions, and handler bodies are kept verbatim from the former inline
|
||||||
|
// newServer registrations.
|
||||||
|
func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
||||||
|
return []toolReg{
|
||||||
|
{tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||||
|
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
idOrSlug, _ := args["slug_or_id"].(string)
|
||||||
|
return queryEntity(ctx, pool, idOrSlug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"type", "string", "Filter by entity type"},
|
||||||
|
prop{"state", "string", "Filter by lifecycle state"},
|
||||||
|
prop{"q", "string", "Substring match on slug or name"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||||
|
FROM entities e
|
||||||
|
WHERE ($1::text IS NULL OR e.type = $1)
|
||||||
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
|
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||||
|
ORDER BY e.slug LIMIT $4`,
|
||||||
|
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT r.type, src.slug AS source, tgt.slug AS target
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities src ON src.id = r.source_id
|
||||||
|
JOIN entities tgt ON tgt.id = r.target_id
|
||||||
|
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
|
||||||
|
ORDER BY r.type`, slug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
|
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
depth := int(getFloat(args, "depth", 3))
|
||||||
|
return queryRows(ctx, pool,
|
||||||
|
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
|
||||||
|
slug, depth), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
||||||
|
InputSchema: objSchema(),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||||
|
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.type <> 'check'
|
||||||
|
ORDER BY e.slug`), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
|
||||||
|
FROM audit_log
|
||||||
|
WHERE ($1::text IS NULL OR entity_id::text = $1)
|
||||||
|
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
q := nStr(args["query"])
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, e.slug,
|
||||||
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||||
|
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
||||||
|
FragmentDelimiter=" ... "') AS snippet,
|
||||||
|
ke.source, ke.tags
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT 20`, q), "knowledge_results"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
|
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_slug"].(string)
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
JOIN relationships r ON r.source_id = ke.entity_id
|
||||||
|
JOIN entities target ON target.id = r.target_id
|
||||||
|
WHERE target.slug = $1
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND r.type IN ('documents', 'about')
|
||||||
|
UNION
|
||||||
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
JOIN relationships r ON r.source_id = ke.entity_id
|
||||||
|
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
|
||||||
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
|
WHERE r.valid_to IS NULL
|
||||||
|
AND r.type = 'procedure-for'
|
||||||
|
ORDER BY 1`, slug), "knowledge_results"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
||||||
|
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE e.slug = $1`, slug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||||
|
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||||
|
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
||||||
|
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
||||||
|
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return upsertKnowledge(ctx, pool, args)
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
|
||||||
|
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
attrsStr, _ := args["attributes"].(string)
|
||||||
|
if slug == "" || attrsStr == "" {
|
||||||
|
return textResult("error: slug and attributes are required"), nil
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||||
|
}
|
||||||
|
attrsJSON, _ := json.Marshal(attrs)
|
||||||
|
ct, err := pool.Exec(ctx, `
|
||||||
|
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
||||||
|
WHERE slug = $1`, slug, string(attrsJSON))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"source", "string", "Source entity slug."},
|
||||||
|
prop{"target", "string", "Target entity slug."},
|
||||||
|
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
source, _ := args["source"].(string)
|
||||||
|
target, _ := args["target"].(string)
|
||||||
|
relType, _ := args["type"].(string)
|
||||||
|
if source == "" || target == "" || relType == "" {
|
||||||
|
return textResult("error: source, target, and type are required"), nil
|
||||||
|
}
|
||||||
|
var sourceID, targetID uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||||
|
}
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
|
||||||
|
sourceID, targetID, relType)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||||
|
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
hours := int(getFloat(args, "hours", 24))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||||
|
entity_id::text, metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg,
|
||||||
|
ROUND(min(value)::numeric, 2) AS min,
|
||||||
|
ROUND(max(value)::numeric, 2) AS max
|
||||||
|
FROM metric_samples
|
||||||
|
WHERE ts > now() - make_interval(hours => $1)
|
||||||
|
GROUP BY bucket, entity_id, metric
|
||||||
|
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||||
|
prop{"state", "string", "Filter by signal state (raised, resolved)"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT s.entity_id::text, s.kind, s.severity, s.state,
|
||||||
|
s.occurrence_count, e.slug AS target_slug,
|
||||||
|
s.first_seen_at, s.last_seen_at
|
||||||
|
FROM signals s
|
||||||
|
LEFT JOIN entities e ON e.id = s.target_entity_id
|
||||||
|
WHERE ($1::text IS NULL OR e.slug = $1)
|
||||||
|
AND ($2::text IS NULL OR s.state = $2)
|
||||||
|
ORDER BY s.last_seen_at DESC LIMIT $3`,
|
||||||
|
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
|
||||||
|
prop{"entity_type", "string", "Filter by applies_type"},
|
||||||
|
prop{"action", "string", "Filter by action"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
|
||||||
|
p.confidence, p.evidence_count, p.success_count, p.failure_count,
|
||||||
|
p.status, p.quarantined, p.version, p.last_validated_at
|
||||||
|
FROM patterns p
|
||||||
|
WHERE ($1::text IS NULL OR p.status = $1)
|
||||||
|
AND ($2::text IS NULL OR p.applies_type = $2)
|
||||||
|
AND ($3::text IS NULL OR p.action = $3)
|
||||||
|
ORDER BY p.applies_type, p.action`,
|
||||||
|
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
|
||||||
|
s.applies_type, s.action, s.status, s.success_rate,
|
||||||
|
s.changed_by::text, s.change_reason, s.last_used_at
|
||||||
|
FROM skills s
|
||||||
|
WHERE ($1::text IS NULL OR s.status = $1)
|
||||||
|
ORDER BY s.name, s.version DESC`,
|
||||||
|
nStr(args["status"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||||
|
// All mutations now route through `run`. The handler functions
|
||||||
|
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
|
||||||
|
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
||||||
|
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong) or lxc:<slug> (e.g. lxc:caddy). LXC commands run via pct exec on its Proxmox host automatically."},
|
||||||
|
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
||||||
|
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
|
||||||
|
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
targetSlug, _ := args["target"].(string)
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
purpose, _ := args["purpose"].(string)
|
||||||
|
declaredRisk, _ := args["declared_risk"].(string)
|
||||||
|
sessionID, _ := args["_session_id"].(string)
|
||||||
|
if targetSlug == "" || command == "" {
|
||||||
|
return textResult("error: target and command are required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetID uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"url", "string", "Absolute http(s) URL to fetch"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
return httpGet(ctx, rawURL), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
execID, _ := args["execution_id"].(string)
|
||||||
|
if execID == "" {
|
||||||
|
return textResult("execution_id required"), nil
|
||||||
|
}
|
||||||
|
eid, err := uuid.Parse(execID)
|
||||||
|
if err != nil {
|
||||||
|
// Try finding by exec slug prefix
|
||||||
|
var found uuid.UUID
|
||||||
|
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||||
|
}
|
||||||
|
eid = found
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
||||||
|
e.result::text, e.duration_ms, e.started_at::text,
|
||||||
|
e.completed_at::text, e.correlation_id
|
||||||
|
FROM executions e
|
||||||
|
WHERE e.entity_id = $1`, eid), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
|
prop{"days", "integer", "Look-back window in days (default 7)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
days := int(getFloat(args, "days", 7))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||||
|
ROUND(stddev(value)::numeric, 2) AS std_val,
|
||||||
|
count(*) AS sample_count,
|
||||||
|
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
||||||
|
FROM metric_samples ms
|
||||||
|
JOIN entities e ON e.id = ms.entity_id
|
||||||
|
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
|
||||||
|
GROUP BY metric
|
||||||
|
ORDER BY metric`, slug, days), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"severity", "string", "Filter by severity (info, warn, error)"},
|
||||||
|
prop{"entity_slug", "string", "Filter by entity slug"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
|
||||||
|
ev.data::text AS message, ev.correlation_id
|
||||||
|
FROM events ev
|
||||||
|
LEFT JOIN entities e ON e.id = ev.entity_id
|
||||||
|
WHERE ($1::text IS NULL OR ev.severity = $1)
|
||||||
|
AND ($2::text IS NULL OR e.slug = $2)
|
||||||
|
ORDER BY ev.ts DESC LIMIT $3`,
|
||||||
|
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||||
|
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||||
|
left(output_summary, 200) AS output_summary,
|
||||||
|
duration_ms, token_count, success, correlation_id
|
||||||
|
FROM agent_activity
|
||||||
|
WHERE agent_id = $1
|
||||||
|
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
state, _ := argsMap(req)["state"].(string)
|
||||||
|
var statePtr *string
|
||||||
|
if state != "" {
|
||||||
|
statePtr = &state
|
||||||
|
}
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||||
|
e.attributes->>'lan_ip' AS lan_ip,
|
||||||
|
e.state,
|
||||||
|
st.health, st.last_check_at,
|
||||||
|
(SELECT MAX(k.created_at)
|
||||||
|
FROM relationships r
|
||||||
|
JOIN knowledge_entities k ON k.entity_id = r.source_id
|
||||||
|
WHERE r.target_id = e.id
|
||||||
|
AND r.type = 'about'
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND (k.tags @> ARRAY['audit']::text[]
|
||||||
|
OR k.tags @> ARRAY['update']::text[]
|
||||||
|
OR k.title ILIKE '%audit%'
|
||||||
|
OR k.title ILIKE '%update%')
|
||||||
|
) AS last_audited_at
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.type = 'lxc'
|
||||||
|
AND ($1::text IS NULL OR e.state = $1)
|
||||||
|
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
|
||||||
|
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||||
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
|
||||||
|
FROM entity_status st
|
||||||
|
JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.slug = $1`, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
||||||
|
}
|
||||||
|
var health, lastCheck, url string
|
||||||
|
rows.Scan(&health, &lastCheck, &url)
|
||||||
|
if url == "" {
|
||||||
|
url = "(no URL in entity attributes)"
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
||||||
|
prop{"lines", "integer", "Number of lines (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
n := int(getFloat(args, "lines", 50))
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
|
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
|
out, err := sshExec(ctx, host, user,
|
||||||
|
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["lxc_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("lxc_slug is required"), nil
|
||||||
|
}
|
||||||
|
var pveID string
|
||||||
|
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
|
||||||
|
if err != nil || pveID == "" {
|
||||||
|
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
|
||||||
|
}
|
||||||
|
// Resolve the Proxmox host — find the host that runs this LXC
|
||||||
|
var hostID uuid.UUID
|
||||||
|
err = pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id FROM entities t
|
||||||
|
JOIN relationships r ON r.source_id = t.id
|
||||||
|
JOIN entities s ON s.id = r.target_id
|
||||||
|
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||||
|
LIMIT 1`, slug).Scan(&hostID)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: use the inventory host attribute if no relationship
|
||||||
|
var hostSlug string
|
||||||
|
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
|
||||||
|
if err != nil || hostSlug == "" {
|
||||||
|
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
||||||
|
}
|
||||||
|
var host, user string
|
||||||
|
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||||
|
}
|
||||||
|
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}
|
||||||
|
var hostSlug string
|
||||||
|
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
||||||
|
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
|
||||||
|
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
hostname, _ := args["hostname"].(string)
|
||||||
|
if hostname == "" {
|
||||||
|
return textResult("error: hostname required"), nil
|
||||||
|
}
|
||||||
|
slug := "ws:" + hostname
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
|
e.attributes->>'mesh_ip' AS mesh_ip,
|
||||||
|
e.attributes->>'age_pubkey' AS age_pubkey,
|
||||||
|
e.enrolled_at
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.slug = $1
|
||||||
|
ORDER BY e.slug`, slug), "entity_card"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
||||||
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("error: service_slug required"), nil
|
||||||
|
}
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
|
e.version, e.updated_at,
|
||||||
|
COALESCE(e.attributes::text, '{}') AS attrs
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.slug = $1`, slug), "entity_card"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Entity slug"},
|
||||||
|
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if slug == "" || action == "" {
|
||||||
|
return textResult("error: service_slug and action required"), nil
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.state,
|
||||||
|
CASE
|
||||||
|
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
|
||||||
|
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
|
||||||
|
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
|
||||||
|
ELSE 'read_only'
|
||||||
|
END AS risk_class,
|
||||||
|
CASE
|
||||||
|
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
|
||||||
|
WHEN $2 = 'config_mutation' THEN 'operator-approval'
|
||||||
|
ELSE 'operator-approval+confirmation'
|
||||||
|
END AS approval
|
||||||
|
FROM entities e WHERE e.slug = $1`, slug, action), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_slug", "string", "Entity slug"},
|
||||||
|
prop{"limit", "integer", "Max entries (default 20)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_slug"].(string)
|
||||||
|
limit := int(getFloat(args, "limit", 20))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
||||||
|
al.action, al.method, al.path,
|
||||||
|
al.detail::text AS details
|
||||||
|
FROM audit_log al
|
||||||
|
JOIN entities e ON e.id = al.entity_id
|
||||||
|
WHERE e.slug = $1
|
||||||
|
ORDER BY al.ts DESC
|
||||||
|
LIMIT $2`, slug, limit), "change_log"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
||||||
|
InputSchema: objSchema(),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.state,
|
||||||
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
|
COALESCE(st.last_check_at::text, '') AS last_check
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.state IS NOT NULL
|
||||||
|
OR st.health IS NOT NULL
|
||||||
|
ORDER BY st.health, e.slug
|
||||||
|
LIMIT 200
|
||||||
|
`), "fleet_snapshot"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
||||||
|
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
pubkey, _ := args["caller_pubkey"].(string)
|
||||||
|
// Match entities where age_pubkey attribute contains the caller's key.
|
||||||
|
query := `
|
||||||
|
SELECT e.slug, e.type, e.name,
|
||||||
|
e.attributes->>'age_pubkey' AS age_pubkey
|
||||||
|
FROM entities e
|
||||||
|
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
|
||||||
|
var dbArgs []any
|
||||||
|
if pubkey != "" {
|
||||||
|
query += ` AND e.attributes->>'age_pubkey' = $1`
|
||||||
|
dbArgs = append(dbArgs, pubkey)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY e.slug LIMIT 100`
|
||||||
|
return queryRows(ctx, pool, query, dbArgs...), nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,23 +282,6 @@ func generateApprovalToken(approvalID uuid.UUID, secret string) string {
|
|||||||
return hex.EncodeToString(mac.Sum(nil))
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyApprovalToken checks a token against the stored hash.
|
|
||||||
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
|
|
||||||
var tokenHash *string
|
|
||||||
var status string
|
|
||||||
var expiresAt time.Time
|
|
||||||
err := pool.QueryRow(ctx,
|
|
||||||
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
|
|
||||||
approvalID).Scan(&tokenHash, &status, &expiresAt)
|
|
||||||
if err != nil || tokenHash == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if status != "pending" || expiresAt.Before(time.Now()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return *tokenHash == hashToken(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashToken(token string) string {
|
func hashToken(token string) string {
|
||||||
h := sha256.Sum256([]byte(token))
|
h := sha256.Sum256([]byte(token))
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
|
|||||||
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
|
// Determine route
|
||||||
route := "escalate"
|
route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired)
|
||||||
autonomyCheck := ""
|
|
||||||
|
|
||||||
if globalAutoAct == "off" || globalAutoAct == "false" {
|
|
||||||
route = "escalate"
|
|
||||||
autonomyCheck = "blocked: global auto_act disabled"
|
|
||||||
} else if entityAutoAct == "true" {
|
|
||||||
route = "escalate"
|
|
||||||
autonomyCheck = "blocked: per-entity kill-switch"
|
|
||||||
} else if approvalRequired == "none" {
|
|
||||||
route = "auto-act"
|
|
||||||
autonomyCheck = "allowed"
|
|
||||||
} else {
|
|
||||||
autonomyCheck = "requires approval: " + approvalRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute blast radius
|
// Compute blast radius
|
||||||
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
||||||
@@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// determineRoute evaluates autonomy settings and approval requirements to
|
||||||
|
// decide whether a signal should auto-act, escalate, or hold for approval.
|
||||||
|
func determineRoute(globalAutoAct, entityAutoAct, approvalRequired string) (route, autonomyCheck string) {
|
||||||
|
if globalAutoAct == "off" || globalAutoAct == "false" {
|
||||||
|
return "escalate", "blocked: global auto_act disabled"
|
||||||
|
}
|
||||||
|
if entityAutoAct == "true" {
|
||||||
|
return "escalate", "blocked: per-entity kill-switch"
|
||||||
|
}
|
||||||
|
if approvalRequired == "none" {
|
||||||
|
return "auto-act", "allowed"
|
||||||
|
}
|
||||||
|
return "escalate", "requires approval: " + approvalRequired
|
||||||
|
}
|
||||||
|
|
||||||
// computeBlastRadius traverses relationships to find affected entities.
|
// computeBlastRadius traverses relationships to find affected entities.
|
||||||
func computeBlastRadius(ctx context.Context, dbc interface {
|
func computeBlastRadius(ctx context.Context, dbc interface {
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
|||||||
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
|
# This root-level inventory.yaml is the Python-era topology file. It was
|
||||||
# mcp/build_host_files.py; do NOT edit those by hand.
|
# superseded on 2026-07-07 by the DB-native model (ADR 0003):
|
||||||
#
|
#
|
||||||
# Conventions:
|
# - The Postgres database is the single source of truth for all structured
|
||||||
# - hostname keys MUST match the actual `hostname` of the machine (on
|
# data. Query it via MCP `get_entity` / `list_entities` or the REST API.
|
||||||
# macOS: `scutil --get LocalHostName` if set).
|
# - `seeds/inventory.yaml` is the bootstrap + DR seed manifest, ingested
|
||||||
# - `os:` one of: linux, macos
|
# idempotently into the DB at deploy time (content-hashed via
|
||||||
# - `kind:` one of: proxmox-host, lxc, vm, workstation, external
|
# `seed_versions`). `oikos export` regenerates it from the DB for VC.
|
||||||
# ("external" is reserved for hosts the homelab CLI manages via ssh but
|
# - `archive/knowledge/` holds the frozen legacy narrative wiki.
|
||||||
# that aren't homelab clients themselves — e.g. the IONOS netbird VPS
|
|
||||||
# with no /etc/age/key.txt and no /opt/homelab-context clone.)
|
|
||||||
# - `mesh:` lists addresses the host is reachable at. Both `netbird` and
|
|
||||||
# `tailscale` are accepted during the migration (see infrastructure/mesh.md).
|
|
||||||
# Prefer netbird FQDNs over raw IPs.
|
|
||||||
# - `age_pubkey:` provisioned by secrets-issuance on first bootstrap and
|
|
||||||
# committed back via `homelab client add --finalize-pubkey <key>`.
|
|
||||||
# - When a service moves hosts, update only the `services:` section here;
|
|
||||||
# never duplicate addresses elsewhere.
|
|
||||||
# - `ssh.user:` per-host login user. Default is `root` if omitted (matches
|
|
||||||
# every LXC + the PVE host). Set explicitly for workstations whose login
|
|
||||||
# user differs from `root`. Used by the `homelab` CLI to build
|
|
||||||
# `user@host` and to inform anyone running raw `netbird ssh` (which
|
|
||||||
# defaults to the LOCAL username — the gotcha that creates "user not
|
|
||||||
# found" errors when ssh'ing INTO machines that only have `root`).
|
|
||||||
#
|
#
|
||||||
# `homelab client add/remove` does surgical line-edits — comments survive.
|
# This file is kept only because AGENTS.md §1/§2 still point clients at
|
||||||
# Avoid round-tripping the file through yaml.safe_dump (it strips comments).
|
# `/opt/homelab-context/inventory.yaml` (the on-client clone path). Reconcile
|
||||||
|
# that path to `seeds/inventory.yaml` and delete this stub — tracked as R13
|
||||||
mesh:
|
# in plans/2026-07-17-codebase-review-and-cleanup.md.
|
||||||
primary: netbird
|
#
|
||||||
accepted:
|
# The live topology is in the DB. The seed is `seeds/inventory.yaml`. This
|
||||||
- netbird
|
# file is not read by any Go code, script, or test in the repo.
|
||||||
- tailscale
|
---
|
||||||
netbird_subnet: 100.122.0.0/16
|
deprecated: true
|
||||||
netbird_domain: netbird.selfhosted
|
superseded_by: seeds/inventory.yaml
|
||||||
# Service contract (Oikos, 2026-07-05): each service should carry
|
source_of_truth: postgres (see ADR 0003)
|
||||||
# backend host/container that runs it (required)
|
see_also:
|
||||||
# url public URL if ingress-exposed
|
- seeds/inventory.yaml
|
||||||
# doc_page owning wiki page
|
- seeds/ontology.yaml
|
||||||
# config_repo tracked config repo, if any (mutations go commit+push)
|
- seeds/policy.yaml
|
||||||
# health health-check URL if it differs from `url`
|
- docs/adr/0003-db-native-ontology-yaml-seeds.md
|
||||||
# risk_notes what an agent must know before touching it
|
|
||||||
# See oikos/ontology.yaml + oikos/policy.yaml.
|
|
||||||
services:
|
|
||||||
proxmox_ui:
|
|
||||||
url: https://proxmox.hubris.network
|
|
||||||
backend: hubris
|
|
||||||
port: 8006
|
|
||||||
doc_page: knowledge/wiki/hosts/hubris.md
|
|
||||||
risk_notes: hypervisor UI — changes here affect every guest on the node
|
|
||||||
gitea:
|
|
||||||
url: https://git.hubris.network
|
|
||||||
backend: gitea
|
|
||||||
backend_url: http://192.168.8.121:3000
|
|
||||||
doc_page: knowledge/wiki/containers/104-gitea.md
|
|
||||||
config_repo: dtoro/gitea-customizations
|
|
||||||
risk_notes: hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync
|
|
||||||
caddy:
|
|
||||||
backend: caddy
|
|
||||||
role: reverse-proxy
|
|
||||||
note: terminates all *.hubris.network
|
|
||||||
doc_page: knowledge/wiki/containers/121-caddy.md
|
|
||||||
config_repo: dtoro/caddy-conf
|
|
||||||
risk_notes: wide blast radius — every *.hubris.network route rides on it (see oikos/policy.yaml service_overrides)
|
|
||||||
authentik:
|
|
||||||
url: https://auth.hubris.network
|
|
||||||
backend: netbird-vps
|
|
||||||
doc_page: knowledge/wiki/containers/106-auth-outpost.md
|
|
||||||
note: >-
|
|
||||||
core runs on the VPS since 2026-05-31; LAN forward-auth outpost is
|
|
||||||
auth-outpost (LXC 106) at 192.168.8.6:9000. Previous backend value
|
|
||||||
"authentik" referenced the retired embedded-outpost host (LXC 124).
|
|
||||||
risk_notes: SSO provider — outage locks login to OIDC/forward-auth services
|
|
||||||
dns:
|
|
||||||
backend: dns
|
|
||||||
note: Technitium DNS, split-horizon zone
|
|
||||||
doc_page: knowledge/wiki/containers/107-dns.md
|
|
||||||
risk_notes: LAN-wide resolver — misconfig breaks name resolution for every client
|
|
||||||
jellyfin:
|
|
||||||
url: https://media.hubris.network
|
|
||||||
backend: jellyfin
|
|
||||||
doc_page: knowledge/wiki/containers/101-jellyfin.md
|
|
||||||
risk_notes: native Authentik OIDC via SSO-Auth plugin, no Caddy forward-auth gate; VAAPI transcode depends on GPU passthrough on strong
|
|
||||||
nextcloud:
|
|
||||||
url: https://cloud.hubris.network
|
|
||||||
backend: nextcloud
|
|
||||||
doc_page: knowledge/wiki/containers/114-nextcloud.md
|
|
||||||
paperless:
|
|
||||||
url: https://paperless.hubris.network
|
|
||||||
backend: paperless
|
|
||||||
doc_page: knowledge/wiki/containers/103-paperless.md
|
|
||||||
risk_notes: document archive — treat data as irreplaceable; DB operations are destructive-class
|
|
||||||
matrix:
|
|
||||||
url: https://matrix.hubris.network
|
|
||||||
backend: elementsynapse
|
|
||||||
doc_page: knowledge/wiki/containers/118-elementsynapse.md
|
|
||||||
risk_notes: alert/approval channel for Oikos — outage silences agent escalation
|
|
||||||
photos:
|
|
||||||
url: https://photos.hubris.network
|
|
||||||
backend: mule-images
|
|
||||||
doc_page: knowledge/wiki/containers/120-mule-images.md
|
|
||||||
config_repo: dtoro/mule-image
|
|
||||||
arr_stack:
|
|
||||||
backend: arriman
|
|
||||||
note: jellyseerr / qbit / sab on docker compose
|
|
||||||
doc_page: knowledge/wiki/containers/122-arriman.md
|
|
||||||
artifacto:
|
|
||||||
backend: apps
|
|
||||||
url: https://artifacto.hubris.network
|
|
||||||
doc_page: knowledge/wiki/containers/105-apps.md
|
|
||||||
config_repo: dtoro/Artifacto
|
|
||||||
trmnl:
|
|
||||||
backend: trmnl
|
|
||||||
url: https://trmnl.hubris.network
|
|
||||||
note: self-hosted middleware for TRMNL e-ink plugins (polled by TRMNL cloud)
|
|
||||||
doc_page: knowledge/wiki/containers/128-trmnl.md
|
|
||||||
config_repo: dtoro/terminalito
|
|
||||||
zimaos:
|
|
||||||
url: https://zimaos.hubris.network
|
|
||||||
backend: zimaos
|
|
||||||
doc_page: knowledge/wiki/vms/100-zimaos.md
|
|
||||||
haos:
|
|
||||||
backend: haos
|
|
||||||
doc_page: knowledge/wiki/vms/108-haos.md
|
|
||||||
teddycloud:
|
|
||||||
url: https://teddy.hubris.network
|
|
||||||
backend: teddycloud
|
|
||||||
doc_page: knowledge/wiki/containers/131-teddycloud.md
|
|
||||||
note: self-hosted TeddyCloud (Toniebox cloud reimplementation), docker compose
|
|
||||||
risk_notes: no Caddy forward-auth gate (unlike sab.hubris.network on the same Caddyfile) —
|
|
||||||
reachable to anyone on the LAN/mesh who can resolve teddy.hubris.network; undocumented
|
|
||||||
in inventory.yaml until 2026-07-06 (drift-caught)
|
|
||||||
homelab_mcp:
|
|
||||||
backend: apps
|
|
||||||
port: 9810
|
|
||||||
systemd_unit: homelab-mcp
|
|
||||||
public_host: mcp.hubris.network
|
|
||||||
endpoint: https://mcp.hubris.network/mcp
|
|
||||||
doc_page: knowledge/wiki/infrastructure/homelab-context.md
|
|
||||||
config_repo: dtoro/oikos
|
|
||||||
note: MCP server. Read-only context + management. Reachable on the LAN via Caddy
|
|
||||||
and from off-LAN via Netbird (192.168.8.0/24 is a network resource routed through
|
|
||||||
hubris).
|
|
||||||
risk_notes: agents' primary read surface — outage degrades every agent to grepping the clone
|
|
||||||
secrets_issuance:
|
|
||||||
backend: apps
|
|
||||||
port: 9820
|
|
||||||
systemd_unit: secrets-issuance
|
|
||||||
public_host: secrets.hubris.network
|
|
||||||
endpoint: https://secrets.hubris.network/issue
|
|
||||||
doc_page: .agents/operations/agent-enrollment.md
|
|
||||||
config_repo: dtoro/oikos
|
|
||||||
note: Issues per-client age private keys. Gated at source-IP layer (mesh + LAN
|
|
||||||
subnets in MESH_SUBNETS).
|
|
||||||
risk_notes: identity issuance — any change is security-sensitive; key operations are destructive-class
|
|
||||||
hosts:
|
|
||||||
hubris:
|
|
||||||
kind: proxmox-host
|
|
||||||
os: linux
|
|
||||||
role: hypervisor
|
|
||||||
lan_ip: 192.168.8.77
|
|
||||||
mesh:
|
|
||||||
netbird:
|
|
||||||
ip: 100.122.38.109
|
|
||||||
fqdn: proxmox-server.netbird.selfhosted
|
|
||||||
ssh:
|
|
||||||
port: 22
|
|
||||||
netbird_port: 22022
|
|
||||||
user: root
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
|
||||||
trmnl:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 128
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: trmnl-middleware
|
|
||||||
lan_ip: 192.168.8.211
|
|
||||||
public_host: trmnl.hubris.network
|
|
||||||
# not yet mesh/SOPS-enrolled — see containers/128-trmnl.md
|
|
||||||
house:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 129
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: family-planner
|
|
||||||
lan_ip: 192.168.8.244
|
|
||||||
public_host: house.hubris.network
|
|
||||||
notes:
|
|
||||||
- Docker host for Yuvomi (family planner). Created 2026-06-26.
|
|
||||||
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
|
|
||||||
- Runs Yuvomi container + WebDAV doc bridge to paperless
|
|
||||||
- 192.168.8.212 was the hubris IP before migration (briefly picked up by teddycloud via
|
|
||||||
DHCP; teddycloud has since been given a static IP, see hosts.teddycloud)
|
|
||||||
age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h
|
|
||||||
jellyfin:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 101
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: media-server
|
|
||||||
lan_ip: 192.168.8.246
|
|
||||||
public_host: media.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: jellyfin
|
|
||||||
mounts:
|
|
||||||
- /mnt/media_local
|
|
||||||
notes:
|
|
||||||
- Jellyfin 10.11.11 with VAAPI hardware acceleration (Radeon 680M iGPU on strong)
|
|
||||||
- 4 cores / 8 GiB RAM / 1 GiB swap
|
|
||||||
- SSO-Auth plugin v4.0.0.4 with Authentik OIDC (no Caddy forward-auth gate)
|
|
||||||
- GPU passed via dev0+dev1: /dev/dri/renderD128 + card0
|
|
||||||
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
|
|
||||||
age_pubkey: ''
|
|
||||||
nfs-export:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 102
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: storage-export
|
|
||||||
lan_ip: 192.168.8.200
|
|
||||||
paperless:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 103
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: document-archive
|
|
||||||
lan_ip: 192.168.8.130
|
|
||||||
public_host: paperless.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: paperless
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
age_pubkey: ''
|
|
||||||
gitea:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 104
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: git-server
|
|
||||||
lan_ip: 192.168.8.121
|
|
||||||
public_host: git.hubris.network
|
|
||||||
backend_port: 3000
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: gitea
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
notes:
|
|
||||||
- Bare repos live at /mnt/library/repos/dtoro/*.git
|
|
||||||
age_pubkey: ''
|
|
||||||
apps:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 105
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: docker-apps
|
|
||||||
lan_ip: 192.168.8.205
|
|
||||||
public_hosts:
|
|
||||||
- artifacto.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
ip: 100.121.171.122
|
|
||||||
fqdn: apps
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
runs:
|
|
||||||
- artifacto
|
|
||||||
- plantuml
|
|
||||||
- homelab-mcp
|
|
||||||
- secrets-issuance
|
|
||||||
# booklore removed 2026-06-29 → migrated to grimmory (LXC 130)
|
|
||||||
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0
|
|
||||||
auth-outpost:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 106
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: authentik-gateway
|
|
||||||
lan_ip: 192.168.8.6
|
|
||||||
notes:
|
|
||||||
- Runs Authentik outpost (reverse-proxy/SSO enforcement) for protected services
|
|
||||||
dns:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 107
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: dns-server
|
|
||||||
lan_ip: 192.168.8.2
|
|
||||||
notes:
|
|
||||||
- Technitium DNS, split-horizon zone for *.hubris.network
|
|
||||||
- Primary DNS for 192.168.8.0/24 LAN (inventory.services.dns references this)
|
|
||||||
nextcloud:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 114
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: file-sync
|
|
||||||
lan_ip: 192.168.8.224
|
|
||||||
public_host: cloud.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: nextcloud
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
age_pubkey: ''
|
|
||||||
elementsynapse:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 118
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: matrix-server
|
|
||||||
lan_ip: 192.168.8.242
|
|
||||||
public_host: matrix.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale: {}
|
|
||||||
notes:
|
|
||||||
- Migrated from hubris to strong 2026-07-05 (Phase 1 of strong migration plan).
|
|
||||||
sophia:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 119
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: workshop
|
|
||||||
lan_ip: 192.168.8.109
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: sophia
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
age_pubkey: ''
|
|
||||||
mule-images:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 120
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: photo-management
|
|
||||||
lan_ip: 192.168.8.136
|
|
||||||
public_host: photos.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: muleimage
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
age_pubkey: ''
|
|
||||||
caddy:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 121
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: reverse-proxy
|
|
||||||
lan_ip: 192.168.8.175
|
|
||||||
notes:
|
|
||||||
- Terminates all *.hubris.network
|
|
||||||
- /etc/caddy is a git checkout of dtoro/caddy-conf
|
|
||||||
peers:
|
|
||||||
- authentik
|
|
||||||
- gitea
|
|
||||||
arriman:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 122
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: arr-stack
|
|
||||||
lan_ip: 192.168.8.245
|
|
||||||
public_hosts:
|
|
||||||
- jellyseerr.hubris.network
|
|
||||||
- qbit.hubris.network
|
|
||||||
- sab.hubris.network
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: arr
|
|
||||||
mounts:
|
|
||||||
- /mnt/media_local
|
|
||||||
notes:
|
|
||||||
- Migrated from hubris to strong 2026-07-05 (Phase 2). Library on ludo-lvm.
|
|
||||||
- Contains homarr, radarr, sonarr, lidarr, sabnzbd, qbittorrent, bazarr, flaresolverr, prowlarr, jellyseerr
|
|
||||||
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 (for seanime + Caddy access)
|
|
||||||
age_pubkey: ''
|
|
||||||
grimmory:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 130
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: book-library
|
|
||||||
lan_ip: 192.168.8.247
|
|
||||||
public_host: books.hubris.network
|
|
||||||
mounts:
|
|
||||||
- /mnt/media_local
|
|
||||||
notes:
|
|
||||||
- Docker host for Grimmory (community fork of Booklore). Created 2026-06-29.
|
|
||||||
- Migrated from hubris to strong 2026-07-05 (Phase 2d). Books on ludo-lvm.
|
|
||||||
age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet
|
|
||||||
teddycloud:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 131
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: teddycloud
|
|
||||||
lan_ip: 192.168.8.150
|
|
||||||
public_host: teddy.hubris.network
|
|
||||||
mounts:
|
|
||||||
- /mnt/library
|
|
||||||
state: active
|
|
||||||
notes:
|
|
||||||
- Docker host for TeddyCloud (ghcr.io/toniebox-reverse-engineering/teddycloud), a
|
|
||||||
self-hosted reimplementation of the Toniebox cloud backend. Debian 12 (bookworm).
|
|
||||||
- 1 core / 1 GiB RAM / 512 MiB swap / 16 GiB rootfs (local-lvm).
|
|
||||||
- Predates the client-enrollment convention — undocumented in inventory.yaml until
|
|
||||||
2026-07-06, when Oikos's drift detector (oikos/drift.py) caught pve_id 131 live on
|
|
||||||
hubris (`pct list`) with no inventory entry. Static IP assigned 2026-07-05 during the
|
|
||||||
strong migration (was picking up 192.168.8.243 via DHCP before that — see
|
|
||||||
hosts/strong.md's 2026-07-05 changelog).
|
|
||||||
- No age_pubkey / homelab-context enrollment — not a homelab CLI client, just a
|
|
||||||
docker-compose app container. Not a required follow-up unless it needs secrets.
|
|
||||||
seanime:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 133
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: anime-media-server
|
|
||||||
lan_ip: 192.168.8.248
|
|
||||||
public_host: seanime.hubris.network
|
|
||||||
mounts:
|
|
||||||
- /mnt/media_local/anime
|
|
||||||
notes:
|
|
||||||
- Seanime anime media server for online streaming + local library scanning
|
|
||||||
- Created 2026-07-05. Binary at /opt/seanime/bin/seanime, systemd service.
|
|
||||||
- Connected to qBittorrent on arriman (192.168.8.245:8080)
|
|
||||||
- 8 online streaming extensions installed (HiAnime, AniWatch, KickAssAnime, etc.)
|
|
||||||
- /anime mounted from strong ludo-lvm (/mnt/media_local/anime)
|
|
||||||
- Caddy: https://seanime.hubris.network → 192.168.8.248:43211
|
|
||||||
- qBittorrent auth subnet whitelist expanded to 192.168.8.0/24 for seanime access
|
|
||||||
romm:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 134
|
|
||||||
host: strong
|
|
||||||
os: linux
|
|
||||||
role: rom-manager
|
|
||||||
lan_ip: 192.168.8.249
|
|
||||||
public_host: roms.hubris.network
|
|
||||||
mounts:
|
|
||||||
- /mnt/media_local
|
|
||||||
notes:
|
|
||||||
- Docker host for RomM (romm.app) self-hosted ROM manager. Created 2026-07-05.
|
|
||||||
- MariaDB sidecar at /opt/romm/docker-compose.yml.
|
|
||||||
- ROMs on ludo-lvm media volume at /mnt/media_local/roms.
|
|
||||||
- 1 core / 2 GiB RAM / 16 GiB rootfs (ludo-lvm).
|
|
||||||
zimaos:
|
|
||||||
kind: vm
|
|
||||||
pve_id: 100
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: nas-frontend-eval
|
|
||||||
lan_ip: 192.168.8.195
|
|
||||||
public_host: zimaos.hubris.network
|
|
||||||
haos:
|
|
||||||
kind: vm
|
|
||||||
pve_id: 108
|
|
||||||
host: hubris
|
|
||||||
os: linux
|
|
||||||
role: home-automation
|
|
||||||
lan_ip: 192.168.8.101
|
|
||||||
mesh:
|
|
||||||
tailscale:
|
|
||||||
fqdn: homeassistant
|
|
||||||
republic-laptop:
|
|
||||||
kind: workstation
|
|
||||||
os: linux
|
|
||||||
role: primary-dev
|
|
||||||
mesh:
|
|
||||||
netbird:
|
|
||||||
fqdn: republic-laptop.netbird.selfhosted
|
|
||||||
ssh:
|
|
||||||
user: dtoro
|
|
||||||
mac-mini:
|
|
||||||
kind: workstation
|
|
||||||
os: macos
|
|
||||||
role: dev
|
|
||||||
lan_ip: 192.168.178.182
|
|
||||||
mesh:
|
|
||||||
netbird:
|
|
||||||
fqdn: mac-mini-234-17.netbird.selfhosted
|
|
||||||
ssh:
|
|
||||||
user: dtoro
|
|
||||||
notes:
|
|
||||||
- Only macOS in the fleet. Bootstrap uses launchd.
|
|
||||||
age_pubkey: age169104ee1a9e1577d493820830560197f0adf56bf8f1c369d57c152c03f9437ae
|
|
||||||
strong:
|
|
||||||
kind: proxmox-host
|
|
||||||
os: linux
|
|
||||||
role: hypervisor
|
|
||||||
lan_ip: 192.168.178.181
|
|
||||||
ssh:
|
|
||||||
user: root
|
|
||||||
notes:
|
|
||||||
- Reformatted from Linux workstation ("ludo-mini" in this wiki, still
|
|
||||||
the machine's nickname) to Proxmox VE 9.2.3 on 2026-07-01. Renamed
|
|
||||||
the inventory/wiki identity from ludo-mini to strong on the same day
|
|
||||||
so it matches the OS/cluster hostname everywhere (bootstrap looks up
|
|
||||||
hosts/$(hostname).yaml, so a mismatch would break enrollment).
|
|
||||||
- Joined hubris's "Homelab" cluster same day. 2-node, no QDevice
|
|
||||||
tiebreaker yet — see hosts/hubris.md quorum note.
|
|
||||||
- Netbird not yet installed (fresh OS wiped prior enrollment); reachable
|
|
||||||
today only via the household LAN / existing Fritz static route to
|
|
||||||
192.168.8.0/24. Re-enroll in mesh as a follow-up if off-LAN access
|
|
||||||
to this host itself (not just its future guests) is needed.
|
|
||||||
- First step of the planned library-SSD migration — see
|
|
||||||
.nomos/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md
|
|
||||||
(filename kept as-is, it's a historical planning doc). Only Phase 1
|
|
||||||
(Proxmox install + cluster join) is done; no physical
|
|
||||||
drive move, service migration, or GPU passthrough has happened yet.
|
|
||||||
age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4
|
|
||||||
netbird-vps:
|
|
||||||
kind: external
|
|
||||||
os: linux
|
|
||||||
role: netbird-mgmt
|
|
||||||
mesh:
|
|
||||||
netbird:
|
|
||||||
ip: 100.122.165.149
|
|
||||||
fqdn: netbird-ionos.netbird.selfhosted
|
|
||||||
ssh:
|
|
||||||
user: root
|
|
||||||
notes:
|
|
||||||
- Public IONOS VPS — hosts the vanilla netbird mgmt+signal+relay+dashboard
|
|
||||||
stack + host coturn (see infrastructure/vps-hardening.md +
|
|
||||||
infrastructure/mesh.md changelog 2026-05-21).
|
|
||||||
- NOT a homelab client. No /etc/age/key.txt, no /opt/homelab-context
|
|
||||||
clone. Managed via ssh from hubris; sshd is locked to hubris's pubkey.
|
|
||||||
- Public IPv4 82.165.190.79. Auto-patching via unattended-upgrades.
|
|
||||||
- Configs rendered by `homelab render-vps-configs` from
|
|
||||||
vps/turnserver.conf.tmpl + vps/management.json.tmpl, with secrets
|
|
||||||
decrypted from secrets/turn-shared-secret.yaml +
|
|
||||||
secrets/netbird-authentik-oidc.yaml on hubris.
|
|
||||||
rclone:
|
|
||||||
kind: lxc
|
|
||||||
os: linux
|
|
||||||
role: backup
|
|
||||||
mesh:
|
|
||||||
netbird:
|
|
||||||
fqdn: rclone.netbird.selfhosted
|
|
||||||
age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x
|
|
||||||
|
|
||||||
# Destroyed nodes (lifecycle state: destroyed — see oikos/ontology.yaml).
|
|
||||||
# Kept so agents can answer "what happened to X?" from structured data and
|
|
||||||
# so drift detectors can flag anything still referencing them.
|
|
||||||
# Full narrative table: containers/index.md "Recently destroyed".
|
|
||||||
archaeology:
|
|
||||||
claudio-bot:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 123
|
|
||||||
destroyed: 2026-06-04
|
|
||||||
reason: replaced by Nomos Agent on mac-mini; monitoring moved to homelab-health-watchdog cron
|
|
||||||
plato:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 126
|
|
||||||
destroyed: 2026-06-28
|
|
||||||
reason: notes workspace decommissioned; data retained at /mnt/library/documents/plato
|
|
||||||
mule-photos-new:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 127
|
|
||||||
destroyed: 2026-05-22
|
|
||||||
reason: PhotoPrism test stack promoted to LXC 120 (Mulimage 2.0 merge)
|
|
||||||
heaper:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 116
|
|
||||||
destroyed: 2026-05-14
|
|
||||||
reason: decommissioned; data retained at /mnt/library/heaper
|
|
||||||
syncthing:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 109
|
|
||||||
destroyed: 2026-05-14
|
|
||||||
reason: decommissioned; library subtree was empty
|
|
||||||
seafile:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 125
|
|
||||||
destroyed: 2026-05-13
|
|
||||||
reason: Seafile Pro evaluation rejected; files.hubris.network removed from caddy + dns
|
|
||||||
arr-yunohost:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 100
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: migrated to docker stack on arriman (LXC 122)
|
|
||||||
flaresolverr:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 106
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: folded into the arriman docker compose
|
|
||||||
marimo:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 107
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: decommissioned
|
|
||||||
photoprism:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 110
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: replaced by mule-images (LXC 120)
|
|
||||||
karakeep:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 111
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: decommissioned
|
|
||||||
immich:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 112
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: replaced by mule-images (LXC 120)
|
|
||||||
reticulum:
|
|
||||||
kind: lxc
|
|
||||||
pve_id: 115
|
|
||||||
destroyed: 2026-04-28
|
|
||||||
reason: decommissioned
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- 020_session_reliability.up.sql
|
-- 020_session_reliability.up.sql
|
||||||
-- Plan step generation tracking + audit log session linkage.
|
-- Plan step generation tracking + audit log session linkage.
|
||||||
-- See plans/2026-07-14-session-reliability-and-ux-audit.md.
|
-- See plans/done/2026-07-14-session-reliability-and-ux-audit.md.
|
||||||
|
|
||||||
-- Plan step generation: when the agent revises a plan mid-flight, new steps
|
-- Plan step generation: when the agent revises a plan mid-flight, new steps
|
||||||
-- get a higher generation number so the frontend can group/collapse old ones.
|
-- get a higher generation number so the frontend can group/collapse old ones.
|
||||||
|
|||||||
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.
|
||||||
@@ -14,10 +14,9 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — `request_execution` enum retired (60effcb); only auto-act revival (item 10) still open |
|
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — `request_execution` enum retired (60effcb); only auto-act revival (item 10) still open |
|
||||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||||
| 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed |
|
| 2026-07-14 | [Activity gaps](2026-07-14-activity-gaps.md) | In Progress |
|
||||||
| 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 |
|
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
|
||||||
| 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 |
|
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
|
||||||
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](2026-07-14-post-fix-session-remainders.md) | Done — all 18 fixes shipped, eval-validated (4/4 golden evals pass), committed (337d577 + 3de359b + dd3076a), deployed v0.5.3. OIDC token-refresh fix (PM) also shipped (3b98097) |
|
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -49,6 +48,12 @@ See [`done/`](done/) for executed plans:
|
|||||||
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
||||||
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
|
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
|
||||||
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
|
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
|
||||||
|
| 2026-07-14 | [Session reliability & UX audit](done/2026-07-14-session-reliability-and-ux-audit.md) |
|
||||||
|
| 2026-07-14 | [Tool timeline in sidebar](done/2026-07-14-tool-timeline-sidebar.md) |
|
||||||
|
| 2026-07-14 | [Unified agent activity indicator](done/2026-07-14-unified-agent-indicator.md) |
|
||||||
|
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](done/2026-07-14-post-fix-session-remainders.md) |
|
||||||
|
| 2026-07-15 | [Plan-first and iteration](done/2026-07-15-plan-first-and-iteration.md) |
|
||||||
|
| 2026-07-15 | [WhatsApp session audit](done/2026-07-15-whatsapp-session-audit.md) |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
4
web/.prettierignore
Normal file
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: '^_' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
3971
web/package-lock.json
generated
3971
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,23 +6,38 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@lucide/svelte": "^1.23.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"mode-watcher": "^1.1.0",
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-svelte": "^2.46.0",
|
||||||
|
"globals": "^15.0.0",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"prettier": "^3.3.0",
|
||||||
|
"prettier-plugin-svelte": "^3.3.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
"svelte-sonner": "^1.1.1",
|
"svelte-sonner": "^1.1.1",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^6.0.0"
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface Message {
|
|||||||
id: string
|
id: string
|
||||||
session_id: string
|
session_id: string
|
||||||
role: string
|
role: string
|
||||||
content: any
|
content: MessageContent | string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +102,6 @@ export async function answerQuestion(sessionId: string, questionId: string, answ
|
|||||||
return res.ok
|
return res.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatEvent {
|
|
||||||
type: string
|
|
||||||
data: any
|
|
||||||
session_id?: string
|
|
||||||
iteration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamChat(
|
export function streamChat(
|
||||||
message: string,
|
message: string,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
@@ -351,21 +344,6 @@ export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
|||||||
return data.items ?? []
|
return data.items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDigest {
|
|
||||||
session_id: string
|
|
||||||
total_executions: number
|
|
||||||
by_status: Record<string, number>
|
|
||||||
entities_touched: string[]
|
|
||||||
executions: { target: string; verb: string; summary: string; risk_class: string; status: string }[]
|
|
||||||
knowledge_created: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
|
|
||||||
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
|
|
||||||
if (!res.ok) return null
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CapabilityTimelineItem {
|
export interface CapabilityTimelineItem {
|
||||||
verb: string
|
verb: string
|
||||||
first_success: string | null
|
first_success: string | null
|
||||||
|
|||||||
@@ -100,7 +100,8 @@
|
|||||||
{:else if entry.status === 'failed'}
|
{:else if entry.status === 'failed'}
|
||||||
<CircleXIcon class="size-3.5" />
|
<CircleXIcon class="size-3.5" />
|
||||||
{:else if icon}
|
{:else if icon}
|
||||||
<svelte:component this={icon} class="size-3" />
|
{@const IconComp = icon}
|
||||||
|
<IconComp class="size-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<CircleDotIcon class="size-3" />
|
<CircleDotIcon class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
children: Snippet
|
children: Snippet
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let open = $state(defaultOpen)
|
let open = $state(false)
|
||||||
|
$effect(() => {
|
||||||
|
open = defaultOpen
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Lets a relation click inside the sheet drill into that entity in place,
|
// Lets a relation click inside the sheet drill into that entity in place,
|
||||||
// without closing/reopening. Resets to the externally-requested slug
|
// without closing/reopening. Resets to the externally-requested slug
|
||||||
// whenever the caller opens the sheet on a different entity.
|
// whenever the caller opens the sheet on a different entity.
|
||||||
let currentSlug = $state(slug)
|
let currentSlug = $state<string | null>(null)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
currentSlug = slug
|
currentSlug = slug
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PendingApproval } from '$lib/stores/chat'
|
|
||||||
import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { SvelteMap } from 'svelte/reactivity'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network'
|
|
||||||
|
|
||||||
let { approvals }: { approvals: PendingApproval[] } = $props()
|
|
||||||
|
|
||||||
// Downstream entities the target affects, keyed by executionId — fetched
|
|
||||||
// once per approval so the operator sees the graph-walk impact ("this
|
|
||||||
// affects 3 downstream") before deciding, not after. depth 0 is the target
|
|
||||||
// itself, excluded here since it's already shown as "on {target}".
|
|
||||||
const blastRadius = new SvelteMap<string, string[]>()
|
|
||||||
const blastRadiusFetched = new Set<string>()
|
|
||||||
async function loadBlastRadius(a: PendingApproval) {
|
|
||||||
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
|
|
||||||
blastRadiusFetched.add(a.executionId)
|
|
||||||
const items = await fetchBlastRadius(a.target)
|
|
||||||
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
|
|
||||||
if (affected.length) blastRadius.set(a.executionId, affected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
|
|
||||||
// action buttons permanently so the banner clears after a click and can
|
|
||||||
// never re-POST /decision.
|
|
||||||
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied' | 'stalled'
|
|
||||||
const phase = new SvelteMap<string, Phase>()
|
|
||||||
// Latest execution row (for status/result display), keyed by executionId.
|
|
||||||
const exec = new SvelteMap<string, Execution>()
|
|
||||||
|
|
||||||
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
|
|
||||||
|
|
||||||
function errorText(e: Execution | undefined): string {
|
|
||||||
const r = e?.result as Record<string, unknown> | undefined | null
|
|
||||||
const v = r?.error
|
|
||||||
return typeof v === 'string' && v ? v : 'Execution failed.'
|
|
||||||
}
|
|
||||||
|
|
||||||
function outputText(e: Execution | undefined): string {
|
|
||||||
const r = e?.result as Record<string, unknown> | undefined | null
|
|
||||||
const v = r?.output
|
|
||||||
return typeof v === 'string' ? v.trim() : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function elapsedSeconds(e: Execution | undefined): number | null {
|
|
||||||
if (!e?.created_at) return null
|
|
||||||
return Math.max(0, Math.round((now - new Date(e.created_at).getTime()) / 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(s: number): string {
|
|
||||||
if (s < 60) return `${s}s`
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
return `${m}m ${s % 60}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live clock for the elapsed-time display on running cards. Tied to
|
|
||||||
// component lifecycle via $effect so the interval is guaranteed cleared on
|
|
||||||
// unmount — a bare setInterval field here would leak a 1Hz timer for the
|
|
||||||
// lifetime of the page every time this component was mounted.
|
|
||||||
let now = $state(Date.now())
|
|
||||||
$effect(() => {
|
|
||||||
const t = setInterval(() => { now = Date.now() }, 1000)
|
|
||||||
return () => clearInterval(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Backend commands are hard-capped at 10 minutes (internal sshExec
|
|
||||||
// timeout) before the execution is force-finalized as failed — so polling
|
|
||||||
// must outlast that with margin, or the UI gives up and goes stale before
|
|
||||||
// the backend ever resolves. Poll for 14 minutes; anything still running
|
|
||||||
// past that is a genuine anomaly worth surfacing distinctly rather than
|
|
||||||
// silently going quiet.
|
|
||||||
const POLL_CEILING_MS = 14 * 60 * 1000
|
|
||||||
|
|
||||||
// Poll the execution until it reaches a terminal state, so the operator sees
|
|
||||||
// provisioning progress and the final outcome without leaving the chat.
|
|
||||||
async function track(id: string) {
|
|
||||||
const deadline = Date.now() + POLL_CEILING_MS
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const e = await getExecution(id)
|
|
||||||
if (e) {
|
|
||||||
exec.set(id, e)
|
|
||||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
|
||||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
|
||||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 2500))
|
|
||||||
}
|
|
||||||
// Genuinely outlasted the backend's own hard timeout — this means
|
|
||||||
// something is wrong beyond a slow command (e.g. the API is down).
|
|
||||||
// Say so explicitly instead of freezing on "running" with no signal.
|
|
||||||
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'stalled')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
|
|
||||||
const id = approval.executionId
|
|
||||||
const cur = phase.get(id)
|
|
||||||
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
|
|
||||||
phase.set(id, 'deciding')
|
|
||||||
const ok = await decideApproval(id, decision)
|
|
||||||
if (!ok) { phase.set(id, 'failed'); return }
|
|
||||||
if (decision === 'deny') { phase.set(id, 'denied'); return }
|
|
||||||
phase.set(id, 'running')
|
|
||||||
void track(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal: a pending approval can be decided somewhere other than this
|
|
||||||
// button — chat assent ("go ahead" in the next message), the Ops page, or
|
|
||||||
// Matrix. Without this, the banner would sit showing Approve/Deny forever
|
|
||||||
// while the action was already running or done behind the scenes. Poll
|
|
||||||
// every card that's still showing buttons; the moment its execution leaves
|
|
||||||
// pending_approval, adopt that outcome exactly as if the button had been
|
|
||||||
// clicked. Stops immediately if the operator clicks the button first
|
|
||||||
// (phase becomes non-empty, ending this loop's reason to exist).
|
|
||||||
const watching = new Set<string>()
|
|
||||||
async function watchExternal(id: string) {
|
|
||||||
if (watching.has(id)) return
|
|
||||||
watching.add(id)
|
|
||||||
for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
|
|
||||||
if (phase.get(id)) return // resolved locally (button click) or already picked up
|
|
||||||
const e = await getExecution(id)
|
|
||||||
if (e && e.status !== 'pending_approval') {
|
|
||||||
exec.set(id, e)
|
|
||||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
|
||||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
|
||||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
|
||||||
// 'approved' or 'running': someone said yes elsewhere — switch to
|
|
||||||
// the same tracking the button click would have started.
|
|
||||||
phase.set(id, 'running')
|
|
||||||
void track(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 3000))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
for (const a of approvals) {
|
|
||||||
if (!phase.get(a.executionId)) {
|
|
||||||
void watchExternal(a.executionId)
|
|
||||||
void loadBlastRadius(a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each approvals as approval (approval.executionId)}
|
|
||||||
{@const p = phase.get(approval.executionId)}
|
|
||||||
{@const e = exec.get(approval.executionId)}
|
|
||||||
{#if p === 'completed'}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<CheckIcon class="size-4 shrink-0" />
|
|
||||||
<span>Completed{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''} on {approval.target}.</span>
|
|
||||||
</div>
|
|
||||||
{#if outputText(e)}
|
|
||||||
<pre class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words pl-6 opacity-80">{outputText(e)}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if p === 'failed'}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<XIcon class="size-4 shrink-0" />
|
|
||||||
<span class="font-medium">Execution failed</span>
|
|
||||||
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
|
|
||||||
</div>
|
|
||||||
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
|
|
||||||
</div>
|
|
||||||
{:else if p === 'denied'}
|
|
||||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
||||||
<XIcon class="size-4" /><span>Denied.</span>
|
|
||||||
</div>
|
|
||||||
{:else if p === 'running' || p === 'deciding'}
|
|
||||||
{@const secs = elapsedSeconds(e)}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Spinner class="size-4 shrink-0 text-warning" />
|
|
||||||
<span>
|
|
||||||
{#if p === 'deciding'}
|
|
||||||
Submitting approval…
|
|
||||||
{:else}
|
|
||||||
Running on {approval.target}{secs !== null ? ` — ${fmtDuration(secs)} elapsed` : '…'}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if p === 'running' && approval.command}
|
|
||||||
<code class="ml-6 block truncate opacity-70">{approval.command}</code>
|
|
||||||
{/if}
|
|
||||||
{#if p === 'running'}
|
|
||||||
<span class="ml-6 opacity-60">
|
|
||||||
Execution <code>{approval.executionId.slice(0, 8)}</code> — long installs can take several minutes; this
|
|
||||||
will resolve on its own (capped at 10 min) or you can check the Operations page for live output.
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if p === 'stalled'}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<XIcon class="size-4 shrink-0" />
|
|
||||||
<span class="font-medium">No update from the server in over 14 minutes.</span>
|
|
||||||
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => { phase.delete(approval.executionId); void track(approval.executionId) }}>
|
|
||||||
Check again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<span class="pl-6 opacity-90">
|
|
||||||
The command itself is capped at 10 minutes server-side, so this is unusual — the API may be unreachable.
|
|
||||||
Execution <code>{approval.executionId}</code>. Check the Operations page directly.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else if approval.destructive}
|
|
||||||
{@const affected = blastRadius.get(approval.executionId)}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
|
||||||
<span class="flex-1 text-xs text-destructive">
|
|
||||||
<strong>DESTRUCTIVE</strong> — {approval.action} on {approval.target}. Type
|
|
||||||
"I confirm" in chat, or use the button.
|
|
||||||
</span>
|
|
||||||
<Button size="sm" variant="destructive" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
|
||||||
<CheckIcon class="size-3" /><span class="ml-1">Confirm</span>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
|
||||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{#if approval.command}
|
|
||||||
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
|
||||||
{/if}
|
|
||||||
{#if affected}
|
|
||||||
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
|
|
||||||
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
|
||||||
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{@const affected = blastRadius.get(approval.executionId)}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
|
||||||
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
|
||||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
|
||||||
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
|
||||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{#if affected}
|
|
||||||
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
|
|
||||||
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
|
||||||
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { planSteps } from '$lib/stores/workspace'
|
|
||||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
|
||||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
|
||||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
|
||||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
|
||||||
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
|
|
||||||
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
|
|
||||||
const total = $derived($planSteps.length)
|
|
||||||
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
|
|
||||||
|
|
||||||
// Group steps by generation. The latest generation is shown expanded;
|
|
||||||
// older ones are collapsible.
|
|
||||||
const byGeneration = $derived.by(() => {
|
|
||||||
const groups: Map<number, typeof $planSteps> = new Map()
|
|
||||||
for (const s of $planSteps) {
|
|
||||||
const g = s.generation ?? 1
|
|
||||||
if (!groups.has(g)) groups.set(g, [])
|
|
||||||
groups.get(g)!.push(s)
|
|
||||||
}
|
|
||||||
return Array.from(groups.entries()).sort(([a], [b]) => a - b)
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestGen = $derived(byGeneration.length > 0 ? byGeneration[byGeneration.length - 1][0] : 0)
|
|
||||||
|
|
||||||
let openGens = $state(new Set<number>())
|
|
||||||
|
|
||||||
let sheetSlug = $state<string | null>(null)
|
|
||||||
let sheetOpen = $state(false)
|
|
||||||
|
|
||||||
function openStep(targetSlug: string | undefined) {
|
|
||||||
if (!targetSlug) return
|
|
||||||
sheetSlug = targetSlug
|
|
||||||
sheetOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGen(gen: number) {
|
|
||||||
if (openGens.has(gen)) openGens.delete(gen)
|
|
||||||
else openGens.add(gen)
|
|
||||||
openGens = new Set(openGens)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if total > 0}
|
|
||||||
<div class="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
||||||
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
|
||||||
<span>{done}/{total}</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
||||||
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each byGeneration as [gen, steps] (gen)}
|
|
||||||
{@const isLatest = gen === latestGen}
|
|
||||||
{#if byGeneration.length > 1}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
|
||||||
onclick={() => toggleGen(gen)}
|
|
||||||
>
|
|
||||||
{#if openGens.has(gen) || isLatest}
|
|
||||||
<ChevronDownIcon class="size-3" />
|
|
||||||
{:else}
|
|
||||||
<ChevronRightIcon class="size-3" />
|
|
||||||
{/if}
|
|
||||||
<span>{isLatest ? 'Current plan' : `Plan v${gen} (replaced)`}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if isLatest || openGens.has(gen)}
|
|
||||||
<ol class="flex flex-col gap-1">
|
|
||||||
{#each steps as step (step.id)}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'} {gen !== latestGen ? 'opacity-50' : ''}"
|
|
||||||
onclick={() => openStep(step.target_slug)}
|
|
||||||
>
|
|
||||||
<span class="mt-0.5 shrink-0">
|
|
||||||
{#if step.status === 'done'}
|
|
||||||
<CircleCheckIcon class="size-3.5 text-success" />
|
|
||||||
{:else if step.status === 'failed'}
|
|
||||||
<CircleXIcon class="size-3.5 text-destructive" />
|
|
||||||
{:else if step.status === 'running'}
|
|
||||||
<Spinner class="size-3.5 text-primary" />
|
|
||||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
|
||||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
|
||||||
{:else if step.status === 'blocked'}
|
|
||||||
<CirclePauseIcon class="size-3.5 text-warning" />
|
|
||||||
{:else}
|
|
||||||
<CircleIcon class="size-3.5 text-muted-foreground" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0 flex-1">
|
|
||||||
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
|
||||||
{step.title}
|
|
||||||
</span>
|
|
||||||
{#if step.target_slug}
|
|
||||||
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
|
||||||
import { currentSession, streaming, toolTimeline, type ToolTimelineEntry } from '$lib/stores/chat'
|
|
||||||
import { currentTask, planSteps } from '$lib/stores/workspace'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
|
||||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
|
||||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
|
||||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
|
||||||
|
|
||||||
let digest = $state<SessionDigest | null>(null)
|
|
||||||
let open = $state(false)
|
|
||||||
let openTools = $state(false)
|
|
||||||
let loadedKey = $state<string | null>(null)
|
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const sid = $currentSession
|
|
||||||
const busy = $streaming
|
|
||||||
const status = $currentTask?.status ?? ''
|
|
||||||
if (!sid || busy) return
|
|
||||||
const key = `${sid}:${status}`
|
|
||||||
if (loadedKey !== key) {
|
|
||||||
loadedKey = key
|
|
||||||
fetchSessionDigest(sid).then((d) => (digest = d))
|
|
||||||
}
|
|
||||||
if (status !== 'done' && status !== 'failed') {
|
|
||||||
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
|
|
||||||
} else {
|
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
||||||
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
|
|
||||||
if (status === 'completed') return 'default'
|
|
||||||
if (['running', 'approved'].includes(status)) return 'secondary'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
|
||||||
const planTotal = $derived($planSteps.length)
|
|
||||||
|
|
||||||
// Group tool timeline entries by message (turn), showing only unique tool names per entry.
|
|
||||||
const toolGroups = $derived.by(() => {
|
|
||||||
const groups: { msgIndex: number; entries: ToolTimelineEntry[] }[] = []
|
|
||||||
for (const e of $toolTimeline) {
|
|
||||||
const last = groups[groups.length - 1]
|
|
||||||
if (last && last.msgIndex === e.msgIndex) {
|
|
||||||
last.entries.push(e)
|
|
||||||
} else {
|
|
||||||
groups.push({ msgIndex: e.msgIndex, entries: [e] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
const toolCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use').length)
|
|
||||||
const runningCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use' && !$toolTimeline.some((r) => r.type === 'tool_result' && r.id === t.id)).length)
|
|
||||||
|
|
||||||
function toolSummary(t: ToolTimelineEntry): string {
|
|
||||||
if (!t.args || typeof t.args !== 'object') return t.name
|
|
||||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
|
||||||
if (typeof firstArg === 'string') return `${t.name} ${firstArg.slice(0, 40)}`
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $streaming && $currentSession}
|
|
||||||
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
<Spinner class="size-3 shrink-0 text-primary" />
|
|
||||||
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $currentTask?.outcome}
|
|
||||||
<div
|
|
||||||
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
|
|
||||||
? 'bg-destructive/5 text-destructive'
|
|
||||||
: $currentTask.outcome === 'partial'
|
|
||||||
? 'bg-warning/5 text-warning'
|
|
||||||
: 'bg-success/5 text-success'}"
|
|
||||||
>
|
|
||||||
{#if $currentTask.outcome === 'failure'}
|
|
||||||
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
|
|
||||||
{:else}
|
|
||||||
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
|
|
||||||
{/if}
|
|
||||||
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $planSteps.length > 0}
|
|
||||||
<div class="flex shrink-0 flex-col gap-1 border-b px-3 py-2">
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
||||||
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
|
||||||
<span>{planDone}/{planTotal}</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
||||||
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
|
|
||||||
</div>
|
|
||||||
<ol class="flex flex-col gap-0.5">
|
|
||||||
{#each $planSteps as step (step.id)}
|
|
||||||
<li class="flex items-start gap-1.5 text-[11px] {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
|
||||||
<span class="mt-0.5 shrink-0">
|
|
||||||
{#if step.status === 'done'}
|
|
||||||
<CircleCheckIcon class="size-3 text-success" />
|
|
||||||
{:else if step.status === 'running'}
|
|
||||||
<Spinner class="size-3 text-primary" />
|
|
||||||
{:else if step.status === 'failed'}
|
|
||||||
<CircleXIcon class="size-3 text-destructive" />
|
|
||||||
{:else}
|
|
||||||
<CircleIcon class="size-3 text-muted-foreground" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="leading-tight">{step.title}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if toolCount > 0}
|
|
||||||
<div class="border-b">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
|
||||||
onclick={() => (openTools = !openTools)}
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
{#if openTools}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
|
||||||
<WrenchIcon class="size-3 text-muted-foreground" />
|
|
||||||
Tool activity
|
|
||||||
</span>
|
|
||||||
<span class="text-muted-foreground">{toolCount} call{toolCount === 1 ? '' : 's'}</span>
|
|
||||||
</button>
|
|
||||||
{#if openTools}
|
|
||||||
<div class="flex flex-col gap-0.5 px-3 pb-2 text-xs">
|
|
||||||
{#each toolGroups as group}
|
|
||||||
{@const isLatest = group.msgIndex === toolGroups[toolGroups.length - 1]?.msgIndex}
|
|
||||||
<div class="rounded border px-2 py-1 {isLatest && $streaming ? 'border-primary/30 bg-primary/5' : ''}">
|
|
||||||
{#each group.entries as t (t.id)}
|
|
||||||
<div class="flex items-start gap-1.5 {t.type === 'tool_result' && t.error ? 'text-destructive' : ''}">
|
|
||||||
<span class="mt-0.5 shrink-0">
|
|
||||||
{#if t.type === 'tool_result' && t.error}
|
|
||||||
<CircleXIcon class="size-3 text-destructive" />
|
|
||||||
{:else if t.type === 'tool_result'}
|
|
||||||
<CircleCheckIcon class="size-3 text-success" />
|
|
||||||
{:else}
|
|
||||||
<Spinner class="size-3 text-primary" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if digest && digest.total_executions > 0}
|
|
||||||
<div class="border-b">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
|
||||||
onclick={() => (open = !open)}
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
|
||||||
This session
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
{digest.total_executions} execution{digest.total_executions === 1 ? '' : 's'}
|
|
||||||
{#if digest.knowledge_created.length}
|
|
||||||
<span class="flex items-center gap-0.5 text-primary">
|
|
||||||
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if open}
|
|
||||||
<div class="flex flex-col gap-2 px-3 pb-3 text-xs">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each Object.entries(digest.by_status) as [status, count]}
|
|
||||||
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if digest.knowledge_created.length}
|
|
||||||
<div>
|
|
||||||
<div class="mb-1 flex items-center gap-1 text-primary">
|
|
||||||
<SparklesIcon class="size-3" />Learned this session
|
|
||||||
</div>
|
|
||||||
<ul class="list-inside list-disc text-muted-foreground">
|
|
||||||
{#each digest.knowledge_created as title}
|
|
||||||
<li>{title}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
|
|
||||||
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
|
||||||
|
|
||||||
let open = $state(false)
|
|
||||||
let wasActive = $state(active)
|
|
||||||
|
|
||||||
const bodyTools = $derived(unmatched ?? tools)
|
|
||||||
const inlineCount = $derived(tools.length - bodyTools.length)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (active && !wasActive) open = true
|
|
||||||
if (!active && wasActive) open = false
|
|
||||||
wasActive = active
|
|
||||||
})
|
|
||||||
|
|
||||||
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
|
||||||
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
|
||||||
const total = $derived(bodyTools.length)
|
|
||||||
|
|
||||||
const runningTool = $derived(
|
|
||||||
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
function toolLabel(t: ToolCallResult): string {
|
|
||||||
if (!t.args || typeof t.args !== 'object') return t.name
|
|
||||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
|
||||||
if (typeof firstArg === 'string' && firstArg.length < 50) return `${t.name} ${firstArg}`
|
|
||||||
return t.name
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if bodyTools.length}
|
|
||||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded border bg-card/50 text-xs">
|
|
||||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
|
||||||
<span class="shrink-0">
|
|
||||||
{#if active && doneCount < total}
|
|
||||||
<Spinner class="size-3 text-primary" />
|
|
||||||
{:else if hasError}
|
|
||||||
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<CheckIcon class="size-3 text-muted-foreground" aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-muted-foreground">
|
|
||||||
{#if active && doneCount < total}
|
|
||||||
{doneCount}/{total}
|
|
||||||
{:else}
|
|
||||||
{total} tool{total === 1 ? '' : 's'}
|
|
||||||
{/if}
|
|
||||||
{#if inlineCount > 0}
|
|
||||||
<span> · {inlineCount} inline</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if active && runningTool}
|
|
||||||
<span class="font-mono truncate">{toolLabel(runningTool)}<span class="animate-pulse">…</span></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ChevronDownIcon
|
|
||||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 ml-auto {open ? 'rotate-180' : ''}"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
|
|
||||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1">
|
|
||||||
<div class="flex flex-col divide-y border-t px-2 py-1">
|
|
||||||
{#each bodyTools as tool (tool.id)}
|
|
||||||
<div class="flex items-center gap-1.5 py-0.5">
|
|
||||||
{#if tool.type === 'tool_result' && tool.error}
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
|
||||||
{:else if tool.type === 'tool_result'}
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
|
||||||
{:else}
|
|
||||||
<Spinner class="size-3 shrink-0 text-primary" />
|
|
||||||
{/if}
|
|
||||||
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible.Root>
|
|
||||||
{/if}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const grouped = $derived.by(() => {
|
|
||||||
if (!rows) return null
|
|
||||||
const g: Record<number, string[]> = {}
|
|
||||||
for (const r of rows) {
|
|
||||||
const d = Number(r.depth) || 0
|
|
||||||
if (!g[d]) g[d] = []
|
|
||||||
g[d].push(r.slug)
|
|
||||||
}
|
|
||||||
return g
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
const total = $derived(rows?.length ?? 0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Blast radius</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">calculating…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Blast radius</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if grouped && total > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-56 overflow-y-auto divide-y">
|
|
||||||
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<div class="mb-1 font-medium text-muted-foreground">
|
|
||||||
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each slugs as slug}
|
|
||||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Blast radius</span>
|
|
||||||
<span class="text-muted-foreground">no affected entities found</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
|
|
||||||
function shortTs(ts: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(ts)
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
} catch {
|
|
||||||
return ts?.slice(11, 19) ?? ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDate(ts: string): string {
|
|
||||||
return ts?.slice(0, 10) ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortId(id: string): string {
|
|
||||||
if (!id) return ''
|
|
||||||
return id.length > 12 ? id.slice(0, 12) : id
|
|
||||||
}
|
|
||||||
|
|
||||||
const activityRows = $derived.by(() => {
|
|
||||||
if (!rows) return null
|
|
||||||
return rows.map((r) => ({
|
|
||||||
time: shortTs(r.timestamp || r.ts || ''),
|
|
||||||
date: shortDate(r.timestamp || r.ts || ''),
|
|
||||||
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
|
|
||||||
action: r.action || r.activity_type || '',
|
|
||||||
toolName: r.tool_name || r.path || '',
|
|
||||||
status: r.success ?? (r.error ? 'false' : undefined),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Activity log</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Activity log</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if activityRows && activityRows.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-56 overflow-y-auto divide-y">
|
|
||||||
{#each activityRows as row}
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
|
|
||||||
<span class="shrink-0 text-muted-foreground">{row.time}</span>
|
|
||||||
{#if row.date !== activityRows[0].date}
|
|
||||||
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="text-muted-foreground">{row.action}</span>
|
|
||||||
<span class="max-w-32 truncate">{row.toolName}</span>
|
|
||||||
<span class="text-muted-foreground/60">{row.actor}</span>
|
|
||||||
{#if row.status === 'true'}
|
|
||||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
|
|
||||||
{:else if row.status === 'false'}
|
|
||||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Activity log</span>
|
|
||||||
<span class="text-muted-foreground">no entries</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { relativeTime } from '$lib/utils'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import BoxIcon from '@lucide/svelte/icons/box'
|
|
||||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
|
||||||
import ContainerIcon from '@lucide/svelte/icons/container'
|
|
||||||
import GlobeIcon from '@lucide/svelte/icons/globe'
|
|
||||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
|
||||||
import ZapIcon from '@lucide/svelte/icons/zap'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const entity = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result') return null
|
|
||||||
const r = tool.result
|
|
||||||
if (!r) return null
|
|
||||||
if (Array.isArray(r)) return r[0]
|
|
||||||
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
|
|
||||||
return r
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
|
|
||||||
|
|
||||||
const typeIcon: Record<string, typeof BoxIcon> = {
|
|
||||||
host: MonitorIcon,
|
|
||||||
lxc: ContainerIcon,
|
|
||||||
service: GlobeIcon,
|
|
||||||
check: ZapIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
|
|
||||||
|
|
||||||
const keyAttrs = $derived.by(() => {
|
|
||||||
if (!entity) return [] as [string, string][]
|
|
||||||
const out: [string, string][] = []
|
|
||||||
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
|
|
||||||
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
|
|
||||||
const v = entity[k]
|
|
||||||
if (v && typeof v === 'string') {
|
|
||||||
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const attrs = entity.attributes ?? entity.attrs
|
|
||||||
if (attrs && typeof attrs === 'object') {
|
|
||||||
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
|
|
||||||
if (!skip.has(k) && v != null && v !== '') {
|
|
||||||
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.slice(0, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
const healthColor: Record<string, string> = {
|
|
||||||
healthy: 'var(--success)',
|
|
||||||
degraded: 'var(--warning)',
|
|
||||||
down: 'var(--destructive)',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if entity}
|
|
||||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug} — {entity.type} — {entity.health || 'no health data'}">
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
||||||
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
|
|
||||||
</div>
|
|
||||||
{#if entity.type}
|
|
||||||
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if entity.state}
|
|
||||||
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if entity.health && entity.health !== 'unknown'}
|
|
||||||
<span class="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
|
|
||||||
{entity.health}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if entity.last_check}
|
|
||||||
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if keyAttrs.length > 0}
|
|
||||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
|
|
||||||
{#each keyAttrs as [k, v]}
|
|
||||||
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
|
||||||
<span class="text-muted-foreground">no data</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
|
|
||||||
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Entities</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Entities</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if rows && rows.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
|
|
||||||
<div class="max-h-56 overflow-y-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b text-muted-foreground">
|
|
||||||
{#each cols as col}
|
|
||||||
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row}
|
|
||||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
|
||||||
{#each cols as col}
|
|
||||||
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
|
|
||||||
<XIcon class="size-3 shrink-0" aria-hidden="true" />
|
|
||||||
<span>No entities found</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import { getExecution } from '$lib/api'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
|
|
||||||
const executions = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = Array.isArray(tool.result) ? tool.result : (tool.result as any)?.data
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
completed: 'var(--success)',
|
|
||||||
failed: 'var(--destructive)',
|
|
||||||
cancelled: 'var(--destructive)',
|
|
||||||
denied: 'var(--destructive)',
|
|
||||||
revoked: 'var(--destructive)',
|
|
||||||
running: 'var(--warning)',
|
|
||||||
approved: 'var(--warning)',
|
|
||||||
pending_approval: 'var(--muted-foreground)',
|
|
||||||
queued: 'var(--muted-foreground)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(s: string): string {
|
|
||||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(ms?: number): string {
|
|
||||||
if (!ms) return ''
|
|
||||||
const s = Math.round(ms / 1000)
|
|
||||||
if (s < 60) return `${s}s`
|
|
||||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
|
||||||
if (!s) return ''
|
|
||||||
return s.length > n ? s.slice(0, n) + '…' : s
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Execution status</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">checking…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Execution status</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if executions && executions.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs">
|
|
||||||
<div class="flex flex-col divide-y">
|
|
||||||
{#each executions as exec (exec.execution_id ?? exec.id)}
|
|
||||||
{@const status = exec.status ?? 'unknown'}
|
|
||||||
{@const color = statusColors[status] ?? 'var(--muted-foreground)'}
|
|
||||||
{@const isRunning = status === 'running' || status === 'approved'}
|
|
||||||
<div class="flex items-center gap-2 px-3 py-2">
|
|
||||||
{#if status === 'completed'}
|
|
||||||
<CheckIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
|
|
||||||
{:else if status === 'failed' || status === 'cancelled' || status === 'denied' || status === 'revoked'}
|
|
||||||
<XIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
|
|
||||||
{:else if isRunning}
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin" style="color: {color}" aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<ClockIcon class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="font-mono font-medium">{truncate(exec.execution_id ?? exec.id ?? '', 12)}</span>
|
|
||||||
<span class="text-muted-foreground">{statusLabel(status)}</span>
|
|
||||||
{#if exec.action}
|
|
||||||
<span class="text-muted-foreground">· {truncate(exec.action, 40)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if exec.duration_ms}
|
|
||||||
<span class="text-muted-foreground">· {fmtDuration(exec.duration_ms)}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="ml-auto inline-block rounded px-1.5 py-0.5 font-medium text-[10px]" style="background: {color}22; color: {color}">
|
|
||||||
{statusLabel(status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if exec.result || exec.error}
|
|
||||||
<div class="max-h-32 overflow-y-auto bg-background/60 px-3 py-1.5">
|
|
||||||
{#if exec.error}
|
|
||||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-destructive">{exec.error}</pre>
|
|
||||||
{:else if exec.result}
|
|
||||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{typeof exec.result === 'string' ? exec.result : JSON.stringify(exec.result, null, 2)}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Execution status</span>
|
|
||||||
<span class="text-muted-foreground">no active executions</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const summary = $derived.by(() => {
|
|
||||||
if (!rows) return null
|
|
||||||
const health: Record<string, number> = {}
|
|
||||||
const types: Record<string, number> = {}
|
|
||||||
for (const r of rows) {
|
|
||||||
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
|
|
||||||
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
|
|
||||||
}
|
|
||||||
return { health, types, total: rows.length }
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
|
|
||||||
const healthColor: Record<string, string> = {
|
|
||||||
healthy: 'var(--success)',
|
|
||||||
degraded: 'var(--warning)',
|
|
||||||
down: 'var(--destructive)',
|
|
||||||
unknown: 'var(--muted-foreground)',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Fleet snapshot</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Fleet snapshot</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if summary && summary.total > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{summary.total} entities</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2 space-y-2">
|
|
||||||
<!-- Health -->
|
|
||||||
<div>
|
|
||||||
<div class="text-muted-foreground mb-1">Health</div>
|
|
||||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
|
||||||
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
|
|
||||||
{#if summary.health[h]}
|
|
||||||
<div class="flex items-center gap-1.5 font-mono">
|
|
||||||
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
|
|
||||||
<span class="text-muted-foreground">{h}</span>
|
|
||||||
<span class="tabular-nums">{summary.health[h]}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Types -->
|
|
||||||
{#if Object.keys(summary.types).length > 0}
|
|
||||||
<div>
|
|
||||||
<div class="text-muted-foreground mb-1">By type</div>
|
|
||||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
|
||||||
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
|
|
||||||
<div class="flex items-center gap-1.5 font-mono">
|
|
||||||
<span class="text-muted-foreground">{type}</span>
|
|
||||||
<span class="tabular-nums">{count}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Fleet snapshot</span>
|
|
||||||
<span class="text-muted-foreground">no data</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const counts = $derived.by(() => {
|
|
||||||
if (!rows) return null
|
|
||||||
const m: Record<string, number> = {}
|
|
||||||
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
|
|
||||||
return m
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
|
|
||||||
|
|
||||||
const bars: { label: string; count: number; color: string }[] = [
|
|
||||||
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
|
|
||||||
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
|
|
||||||
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
|
|
||||||
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Health summary</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Health summary</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if counts && total > 0}
|
|
||||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
|
|
||||||
<div class="flex items-center gap-2 mb-1.5">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{total} entities</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-5 w-full overflow-hidden rounded">
|
|
||||||
{#each bars as bar}
|
|
||||||
{#if bar.count > 0}
|
|
||||||
<div
|
|
||||||
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
|
|
||||||
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
|
|
||||||
title="{bar.label}: {bar.count}"
|
|
||||||
>
|
|
||||||
{bar.count}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
|
|
||||||
{#each bars as bar}
|
|
||||||
{#if bar.count > 0}
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
|
|
||||||
{bar.label} {bar.count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Health summary</span>
|
|
||||||
<span class="text-muted-foreground">no data</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import FileTextIcon from '@lucide/svelte/icons/file-text'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Knowledge</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">searching…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Knowledge</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if rows && rows.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-56 overflow-y-auto divide-y">
|
|
||||||
{#each rows as row}
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="font-mono font-medium truncate">{row.title}</div>
|
|
||||||
{#if row.snippet || row.headline}
|
|
||||||
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
|
|
||||||
{row.snippet || row.headline}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
|
|
||||||
{#if row.source}
|
|
||||||
<span>{row.source}</span>
|
|
||||||
{/if}
|
|
||||||
{#if row.slug}
|
|
||||||
<span class="font-mono opacity-60">{row.slug}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Knowledge</span>
|
|
||||||
<span class="text-muted-foreground">no results</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
|
|
||||||
const healthColor: Record<string, string> = {
|
|
||||||
healthy: 'var(--success)',
|
|
||||||
degraded: 'var(--warning)',
|
|
||||||
down: 'var(--destructive)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortName(slug: string): string {
|
|
||||||
return slug.split(':').pop() ?? slug
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">LXC containers</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">LXC containers</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if rows && rows.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-48 overflow-y-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b text-muted-foreground">
|
|
||||||
<th class="px-3 py-1 text-left font-medium">Name</th>
|
|
||||||
<th class="px-3 py-1 text-left font-medium">ID</th>
|
|
||||||
<th class="px-3 py-1 text-left font-medium">IP</th>
|
|
||||||
<th class="px-3 py-1 text-left font-medium">Health</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row}
|
|
||||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
|
||||||
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
|
|
||||||
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
|
|
||||||
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
|
|
||||||
<td class="px-3 py-1">
|
|
||||||
{#if row.health}
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
|
|
||||||
{row.health}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">LXC containers</span>
|
|
||||||
<span class="text-muted-foreground">no data</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
|
||||||
const data = (tool.result as any).data ?? tool.result
|
|
||||||
return Array.isArray(data) ? data as any[] : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = $derived(tool.type === 'tool_use')
|
|
||||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Metrics</span>
|
|
||||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Metrics</span>
|
|
||||||
<span class="text-destructive">{error}</span>
|
|
||||||
</div>
|
|
||||||
{:else if rows && rows.length > 0}
|
|
||||||
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
|
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-h-48 overflow-y-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b text-muted-foreground">
|
|
||||||
<th class="px-2 py-1 text-left font-medium">Time</th>
|
|
||||||
<th class="px-2 py-1 text-left font-medium">Metric</th>
|
|
||||||
<th class="px-2 py-1 text-right font-medium">Avg</th>
|
|
||||||
<th class="px-2 py-1 text-right font-medium">Min</th>
|
|
||||||
<th class="px-2 py-1 text-right font-medium">Max</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row}
|
|
||||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
|
||||||
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
|
|
||||||
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
|
|
||||||
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
|
|
||||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
|
|
||||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
|
||||||
<span class="font-medium">Metrics</span>
|
|
||||||
<span class="text-muted-foreground">no data</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import BlastRadius from './BlastRadius.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
|
|
||||||
component: BlastRadius,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import ChangeLog from './ChangeLog.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) =>
|
|
||||||
t.name === 'get_change_history' ||
|
|
||||||
t.name === 'get_agent_activity' ||
|
|
||||||
t.result?.__renderer === 'change_log',
|
|
||||||
component: ChangeLog,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import EntityCard from './EntityCard.svelte'
|
|
||||||
|
|
||||||
const TOOLS = ['get_entity', 'whoami', 'explain']
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
|
|
||||||
component: EntityCard,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import EntityTable from './EntityTable.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
|
|
||||||
component: EntityTable,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import ExecutionStatus from './ExecutionStatus.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => t.name === 'get_execution_status',
|
|
||||||
component: ExecutionStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import FleetSnapshot from './FleetSnapshot.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
|
|
||||||
component: FleetSnapshot,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import HealthSummary from './HealthSummary.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
|
|
||||||
component: HealthSummary,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { init as initEntityCard } from './entity-card'
|
|
||||||
import { init as initHealthSummary } from './health-summary'
|
|
||||||
import { init as initLXCList } from './lxc-list'
|
|
||||||
import { init as initEntityTable } from './entity-table'
|
|
||||||
import { init as initKnowledgeResults } from './knowledge-results'
|
|
||||||
import { init as initBlastRadius } from './blast-radius'
|
|
||||||
import { init as initChangeLog } from './change-log'
|
|
||||||
import { init as initFleetSnapshot } from './fleet-snapshot'
|
|
||||||
import { init as initMetricChart } from './metric-chart'
|
|
||||||
import { init as initExecutionStatus } from './execution-status'
|
|
||||||
|
|
||||||
initEntityCard()
|
|
||||||
initHealthSummary()
|
|
||||||
initLXCList()
|
|
||||||
initEntityTable()
|
|
||||||
initKnowledgeResults()
|
|
||||||
initBlastRadius()
|
|
||||||
initChangeLog()
|
|
||||||
initFleetSnapshot()
|
|
||||||
initMetricChart()
|
|
||||||
initExecutionStatus()
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
|
||||||
import KnowledgeResults from './KnowledgeResults.svelte'
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
registerToolRenderer({
|
|
||||||
match: (t) =>
|
|
||||||
t.name === 'search_knowledge' ||
|
|
||||||
t.name === 'get_entity_knowledge' ||
|
|
||||||
t.result?.__renderer === 'knowledge_results',
|
|
||||||
component: KnowledgeResults,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user